Add feature requests page with voting and status tracking
Deploy to LXC / deploy (push) Successful in 21s

- feature_requests table with title, description, status, votes, createdBy
- Submit new requests with title and description
- Upvote button sorted by most votes
- Status dropdown: open, planned, in-progress, done, declined
- Color-coded status badges
- Delete button per request
- Sidebar nav item with lightbulb icon

Run db:push on the server to create the new table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 10:03:56 +07:00
parent f6f31341ea
commit e16c50d854
4 changed files with 206 additions and 0 deletions
+5
View File
@@ -58,6 +58,11 @@
label: 'Gallery',
icon: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'
},
{
href: '/feature-requests',
label: 'Requests',
icon: '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'
},
{
href: '/settings',
label: 'Settings',
+13
View File
@@ -339,3 +339,16 @@ export const todos = pgTable(
check('todos_priority_check', sql`${table.priority} IN (0, 1, 2, 3)`)
]
);
// ─── Feature Requests ───────────────────────────────────────────────
export const featureRequests = pgTable('feature_requests', {
id: uuid('id').defaultRandom().primaryKey(),
title: text('title').notNull(),
description: text('description'),
status: text('status').notNull().default('open'),
votes: integer('votes').notNull().default(0),
createdBy: text('created_by'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
@@ -0,0 +1,59 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db/index.js';
import { featureRequests } from '$lib/server/db/schema.js';
import { eq, desc, count } from 'drizzle-orm';
import { fail } from '@sveltejs/kit';
export const load: PageServerLoad = async () => {
const requests = await db
.select()
.from(featureRequests)
.orderBy(desc(featureRequests.votes), desc(featureRequests.createdAt));
return { requests };
};
export const actions: Actions = {
create: async ({ request, locals }) => {
const formData = await request.formData();
const title = (formData.get('title') as string)?.trim();
const description = (formData.get('description') as string)?.trim();
if (!title) return fail(400, { error: 'Title is required' });
await db.insert(featureRequests).values({
title,
description: description || null,
createdBy: locals.user?.displayName ?? locals.user?.email ?? 'Unknown'
});
return { created: true };
},
vote: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id') as string;
const [req] = await db.select({ votes: featureRequests.votes }).from(featureRequests).where(eq(featureRequests.id, id));
if (req) {
await db.update(featureRequests).set({ votes: req.votes + 1 }).where(eq(featureRequests.id, id));
}
return { voted: true };
},
updateStatus: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id') as string;
const status = formData.get('status') as string;
await db.update(featureRequests).set({ status, updatedAt: new Date() }).where(eq(featureRequests.id, id));
return { statusUpdated: true };
},
delete: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id') as string;
await db.delete(featureRequests).where(eq(featureRequests.id, id));
return { deleted: true };
}
};
@@ -0,0 +1,129 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { timeAgo } from '$lib/utils/date.js';
let { data, form } = $props();
let showNewForm = $state(false);
const statusColors: Record<string, string> = {
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
planned: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
'in-progress': 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
done: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
declined: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
};
</script>
<svelte:head>
<title>Feature Requests - My Collection</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<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 and vote on new features.</p>
</div>
<button type="button" onclick={() => (showNewForm = !showNewForm)}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
{showNewForm ? 'Cancel' : '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}
{#if showNewForm}
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<form method="POST" action="?/create" use:enhance={() => {
return async ({ update, result }) => {
await update();
if (result.type === 'success') showNewForm = false;
};
}} class="space-y-3">
<div>
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Title *</label>
<input type="text" id="title" name="title" required
placeholder="Short description of the feature..."
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
</div>
<div>
<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="3"
placeholder="Why is this needed? How should it work?"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"></textarea>
</div>
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Submit Request</button>
</form>
</div>
{/if}
{#if data.requests.length === 0 && !showNewForm}
<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. Be the first to suggest one!</p>
</div>
{:else}
<div class="space-y-3">
{#each data.requests as req}
<div 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-3">
<!-- Vote button -->
<form method="POST" action="?/vote" use:enhance class="flex-shrink-0">
<input type="hidden" name="id" value={req.id} />
<button type="submit" class="flex flex-col items-center rounded-md border border-gray-200 px-2 py-1 text-gray-500 hover:border-blue-300 hover:text-blue-600 dark:border-gray-600 dark:text-gray-400 dark:hover:border-blue-500 dark:hover:text-blue-400" title="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="text-xs font-medium">{req.votes}</span>
</button>
</form>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-medium text-gray-900 dark:text-white">{req.title}</h3>
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusColors[req.status] ?? statusColors.open}">
{req.status}
</span>
</div>
{#if req.description}
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{req.description}</p>
{/if}
<div class="mt-2 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
{#if req.createdBy}
<span>{req.createdBy}</span>
{/if}
<span>{timeAgo(req.createdAt)}</span>
</div>
</div>
<!-- Admin actions -->
<div class="flex items-center gap-1">
<select onchange={(e) => {
const form = document.createElement('form');
form.method = 'POST';
form.action = '?/updateStatus';
form.innerHTML = `<input type="hidden" name="id" value="${req.id}" /><input type="hidden" name="status" value="${(e.target as HTMLSelectElement).value}" />`;
document.body.appendChild(form);
form.submit();
}} class="rounded border border-gray-200 px-1 py-0.5 text-xs text-gray-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400">
{#each ['open', 'planned', 'in-progress', 'done', 'declined'] as s}
<option value={s} selected={s === req.status}>{s}</option>
{/each}
</select>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={req.id} />
<button type="submit" class="rounded p-1 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400" title="Delete">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>