Add feature requests page with voting and status tracking
Deploy to LXC / deploy (push) Successful in 21s
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:
@@ -58,6 +58,11 @@
|
|||||||
label: 'Gallery',
|
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'
|
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',
|
href: '/settings',
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
|
|||||||
@@ -339,3 +339,16 @@ export const todos = pgTable(
|
|||||||
check('todos_priority_check', sql`${table.priority} IN (0, 1, 2, 3)`)
|
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>
|
||||||
Reference in New Issue
Block a user