Add feature requests page with upvotes and admin status workflow
Validate / validate (push) Successful in 22s
Validate / validate (push) Successful in 22s
- New /feature-requests route accessible to all logged-in users via sidebar nav - feature_requests + feature_request_votes tables (one vote per user per request) - Submit form (modal), upvote toggle, filter by status, sort by votes/newest - System admins can change status (open / in_review / waiting_for_checks / in_progress / resolved / closed) with optional note - Submitter auto-votes their own request on creation - Admin or original submitter can delete a request Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,16 @@
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/feature-requests"
|
||||
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Feature Requests
|
||||
</a>
|
||||
|
||||
{#if companies.length > 0}
|
||||
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Companies
|
||||
|
||||
@@ -609,6 +609,51 @@ export const shippingAccounts = pgTable(
|
||||
(table) => [uniqueIndex('shipping_accounts_company_carrier_idx').on(table.companyId, table.carrier)]
|
||||
);
|
||||
|
||||
// ── Feature Requests ───────────────────────────────────
|
||||
|
||||
export const featureRequestStatusEnum = pgEnum('feature_request_status', [
|
||||
'open',
|
||||
'in_review',
|
||||
'waiting_for_checks',
|
||||
'in_progress',
|
||||
'resolved',
|
||||
'closed'
|
||||
]);
|
||||
|
||||
export const featureRequests = pgTable(
|
||||
'feature_requests',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
status: featureRequestStatusEnum('status').notNull().default('open'),
|
||||
submittedBy: text('submitted_by')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'set null' }),
|
||||
statusChangedBy: text('status_changed_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
statusChangedAt: timestamp('status_changed_at', { withTimezone: true }),
|
||||
statusNote: text('status_note'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [index('feature_requests_status_idx').on(table.status)]
|
||||
);
|
||||
|
||||
export const featureRequestVotes = pgTable(
|
||||
'feature_request_votes',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
requestId: uuid('request_id')
|
||||
.notNull()
|
||||
.references(() => featureRequests.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [uniqueIndex('feature_request_votes_request_user_idx').on(table.requestId, table.userId)]
|
||||
);
|
||||
|
||||
// ── Company Log (Audit Trail) ──────────────────────────
|
||||
|
||||
export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||
|
||||
@@ -7,6 +7,13 @@ export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled';
|
||||
export type PayslipStatus = 'draft' | 'finalized' | 'paid';
|
||||
export type TxDirection = 'credit' | 'debit';
|
||||
export type IntegrationProvider = 'kasikorn_kbiz' | 'etherfi' | 'manual';
|
||||
export type FeatureRequestStatus =
|
||||
| 'open'
|
||||
| 'in_review'
|
||||
| 'waiting_for_checks'
|
||||
| 'in_progress'
|
||||
| 'resolved'
|
||||
| 'closed';
|
||||
|
||||
/**
|
||||
* Hierarchical roles — only these ranks. `hr` is orthogonal and excluded.
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { featureRequests, featureRequestVotes, users } from '$lib/server/db/schema.js';
|
||||
import { eq, and, sql, desc } from 'drizzle-orm';
|
||||
import { requireAuth } from '$lib/server/authorization.js';
|
||||
import type { FeatureRequestStatus } from '$lib/types/index.js';
|
||||
|
||||
const ALL_STATUSES: FeatureRequestStatus[] = [
|
||||
'open',
|
||||
'in_review',
|
||||
'waiting_for_checks',
|
||||
'in_progress',
|
||||
'resolved',
|
||||
'closed'
|
||||
];
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const user = requireAuth(locals);
|
||||
const status = url.searchParams.get('status') ?? 'all';
|
||||
const sort = url.searchParams.get('sort') ?? 'votes';
|
||||
|
||||
const baseSelect = db
|
||||
.select({
|
||||
id: featureRequests.id,
|
||||
title: featureRequests.title,
|
||||
description: featureRequests.description,
|
||||
status: featureRequests.status,
|
||||
submittedBy: featureRequests.submittedBy,
|
||||
submitterName: users.displayName,
|
||||
submitterEmail: users.email,
|
||||
statusNote: featureRequests.statusNote,
|
||||
statusChangedAt: featureRequests.statusChangedAt,
|
||||
createdAt: featureRequests.createdAt,
|
||||
voteCount: sql<number>`(
|
||||
SELECT COUNT(*)::int FROM ${featureRequestVotes}
|
||||
WHERE ${featureRequestVotes.requestId} = ${featureRequests.id}
|
||||
)`,
|
||||
myVote: sql<boolean>`EXISTS (
|
||||
SELECT 1 FROM ${featureRequestVotes}
|
||||
WHERE ${featureRequestVotes.requestId} = ${featureRequests.id}
|
||||
AND ${featureRequestVotes.userId} = ${user.id}
|
||||
)`
|
||||
})
|
||||
.from(featureRequests)
|
||||
.leftJoin(users, eq(featureRequests.submittedBy, users.id));
|
||||
|
||||
const filtered =
|
||||
status !== 'all' && (ALL_STATUSES as string[]).includes(status)
|
||||
? baseSelect.where(eq(featureRequests.status, status as FeatureRequestStatus))
|
||||
: baseSelect;
|
||||
|
||||
const ordered =
|
||||
sort === 'newest'
|
||||
? filtered.orderBy(desc(featureRequests.createdAt))
|
||||
: filtered.orderBy(
|
||||
desc(sql`(SELECT COUNT(*) FROM ${featureRequestVotes} WHERE ${featureRequestVotes.requestId} = ${featureRequests.id})`),
|
||||
desc(featureRequests.createdAt)
|
||||
);
|
||||
|
||||
const requests = await ordered;
|
||||
|
||||
return {
|
||||
requests,
|
||||
filters: { status, sort },
|
||||
isSystemAdmin: user.isSystemAdmin
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
const formData = await request.formData();
|
||||
const title = formData.get('title')?.toString().trim();
|
||||
const description = formData.get('description')?.toString().trim() || null;
|
||||
|
||||
if (!title) return fail(400, { error: 'Title is required' });
|
||||
if (title.length > 200) return fail(400, { error: 'Title is too long' });
|
||||
|
||||
const [created] = await db
|
||||
.insert(featureRequests)
|
||||
.values({ title, description, submittedBy: user.id })
|
||||
.returning({ id: featureRequests.id });
|
||||
|
||||
// Auto-upvote by submitter
|
||||
await db.insert(featureRequestVotes).values({ requestId: created.id, userId: user.id });
|
||||
|
||||
return { success: true, createdId: created.id };
|
||||
},
|
||||
|
||||
toggleVote: async ({ request, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
const formData = await request.formData();
|
||||
const requestId = formData.get('requestId')?.toString();
|
||||
if (!requestId) return fail(400, { error: 'Missing request ID' });
|
||||
|
||||
const existing = await db
|
||||
.select({ id: featureRequestVotes.id })
|
||||
.from(featureRequestVotes)
|
||||
.where(
|
||||
and(
|
||||
eq(featureRequestVotes.requestId, requestId),
|
||||
eq(featureRequestVotes.userId, user.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db.delete(featureRequestVotes).where(eq(featureRequestVotes.id, existing[0].id));
|
||||
} else {
|
||||
await db.insert(featureRequestVotes).values({ requestId, userId: user.id });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
updateStatus: async ({ request, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
if (!user.isSystemAdmin) {
|
||||
return fail(403, { error: 'Only system admins can change status' });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const requestId = formData.get('requestId')?.toString();
|
||||
const newStatus = formData.get('status')?.toString() as FeatureRequestStatus | undefined;
|
||||
const note = formData.get('statusNote')?.toString().trim() || null;
|
||||
|
||||
if (!requestId) return fail(400, { error: 'Missing request ID' });
|
||||
if (!newStatus || !ALL_STATUSES.includes(newStatus)) {
|
||||
return fail(400, { error: 'Invalid status' });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(featureRequests)
|
||||
.set({
|
||||
status: newStatus,
|
||||
statusChangedBy: user.id,
|
||||
statusChangedAt: new Date(),
|
||||
statusNote: note,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(featureRequests.id, requestId));
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
delete: async ({ request, locals }) => {
|
||||
const user = requireAuth(locals);
|
||||
const formData = await request.formData();
|
||||
const requestId = formData.get('requestId')?.toString();
|
||||
if (!requestId) return fail(400, { error: 'Missing request ID' });
|
||||
|
||||
// Allow deletion by system admin OR original submitter
|
||||
const [req] = await db
|
||||
.select({ submittedBy: featureRequests.submittedBy })
|
||||
.from(featureRequests)
|
||||
.where(eq(featureRequests.id, requestId))
|
||||
.limit(1);
|
||||
if (!req) return fail(404, { error: 'Not found' });
|
||||
|
||||
if (!user.isSystemAdmin && req.submittedBy !== user.id) {
|
||||
return fail(403, { error: 'You can only delete your own requests' });
|
||||
}
|
||||
|
||||
await db.delete(featureRequests).where(eq(featureRequests.id, requestId));
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { timeAgo } from '$lib/utils/date.js';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import type { FeatureRequestStatus } from '$lib/types/index.js';
|
||||
|
||||
let { data, form } = $props<{ data: PageData; form: ActionData }>();
|
||||
|
||||
let showCreate = $state(false);
|
||||
|
||||
const STATUS_LABELS: Record<FeatureRequestStatus, string> = {
|
||||
open: 'Open',
|
||||
in_review: 'In Review',
|
||||
waiting_for_checks: 'Waiting for Checks',
|
||||
in_progress: 'In Progress',
|
||||
resolved: 'Resolved',
|
||||
closed: 'Closed'
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<FeatureRequestStatus, string> = {
|
||||
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
in_review: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
waiting_for_checks: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
in_progress: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||
resolved: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
closed: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||
};
|
||||
|
||||
const FILTER_PILLS: { value: string; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'open', label: 'Open' },
|
||||
{ value: 'in_review', label: 'In Review' },
|
||||
{ value: 'waiting_for_checks', label: 'Waiting' },
|
||||
{ value: 'in_progress', label: 'In Progress' },
|
||||
{ value: 'resolved', label: 'Resolved' },
|
||||
{ value: 'closed', label: 'Closed' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Feature Requests - {data.appName}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Feature Requests</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Suggest improvements and upvote others' ideas.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showCreate = true)}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
+ New Request
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filters + sort -->
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each FILTER_PILLS as p}
|
||||
<a
|
||||
href="?status={p.value}&sort={data.filters.sort}"
|
||||
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {data.filters.status === p.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
{p.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex gap-2 text-xs">
|
||||
<a
|
||||
href="?status={data.filters.status}&sort=votes"
|
||||
class="rounded px-2 py-1 {data.filters.sort === 'votes'
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}"
|
||||
>
|
||||
Top voted
|
||||
</a>
|
||||
<a
|
||||
href="?status={data.filters.status}&sort=newest"
|
||||
class="rounded px-2 py-1 {data.filters.sort === 'newest'
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}"
|
||||
>
|
||||
Newest
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if data.requests.length === 0}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-gray-500 dark:text-gray-400">No feature requests yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-3">
|
||||
{#each data.requests as req}
|
||||
<li class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Vote button -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/toggleVote"
|
||||
use:enhance={() => async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
}}
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<input type="hidden" name="requestId" value={req.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="flex h-14 w-14 flex-col items-center justify-center rounded-md border-2 transition-colors {req.myVote
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
: 'border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600 dark:border-gray-600 dark:text-gray-400 dark:hover:border-blue-700'}"
|
||||
title={req.myVote ? 'Remove vote' : 'Upvote'}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
<span class="mt-0.5 text-sm font-bold">{req.voteCount}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">{req.title}</h3>
|
||||
<span class="flex-shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide {STATUS_BADGE[req.status as FeatureRequestStatus]}">
|
||||
{STATUS_LABELS[req.status as FeatureRequestStatus]}
|
||||
</span>
|
||||
</div>
|
||||
{#if req.description}
|
||||
<p class="mt-1 text-sm text-gray-600 whitespace-pre-line dark:text-gray-300">{req.description}</p>
|
||||
{/if}
|
||||
{#if req.statusNote}
|
||||
<p class="mt-2 rounded-md bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900/50 dark:text-gray-400">
|
||||
<span class="font-semibold">Note from admin:</span> {req.statusNote}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
Submitted by {req.submitterName ?? req.submitterEmail ?? '—'} · {timeAgo(req.createdAt)}
|
||||
</p>
|
||||
|
||||
<!-- Admin status controls -->
|
||||
{#if data.isSystemAdmin}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateStatus"
|
||||
use:enhance={() => async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
}}
|
||||
class="mt-3 flex flex-wrap items-center gap-2 rounded-md border border-gray-200 p-2 text-xs dark:border-gray-700"
|
||||
>
|
||||
<input type="hidden" name="requestId" value={req.id} />
|
||||
<label class="flex items-center gap-1 text-gray-600 dark:text-gray-400">
|
||||
Status:
|
||||
<select
|
||||
name="status"
|
||||
class="rounded border border-gray-300 px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
{#each Object.entries(STATUS_LABELS) as [val, label]}
|
||||
<option value={val} selected={req.status === val}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="statusNote"
|
||||
placeholder="Optional note (e.g. ETA, blocker)"
|
||||
value={req.statusNote ?? ''}
|
||||
class="flex-1 min-w-32 rounded border border-gray-300 px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
<button type="submit" class="rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Delete (admin can delete any) -->
|
||||
{#if data.isSystemAdmin}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm('Delete this feature request?')) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
return async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}
|
||||
class="mt-2"
|
||||
>
|
||||
<input type="hidden" name="requestId" value={req.id} />
|
||||
<button type="submit" class="text-xs text-red-600 hover:text-red-800 dark:text-red-400">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create modal -->
|
||||
{#if showCreate}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">New Feature Request</h2>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/create"
|
||||
use:enhance={() => async ({ update }) => {
|
||||
await update();
|
||||
showCreate = false;
|
||||
}}
|
||||
>
|
||||
<div class="mb-4">
|
||||
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Title <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
required
|
||||
maxlength="200"
|
||||
placeholder="Short, descriptive summary"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="description" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="5"
|
||||
placeholder="What problem does this solve? What would the ideal experience look like?"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCreate = false)}
|
||||
class="rounded-md px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user