diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 11e850d..5983750 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -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', diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 2921b49..645eae1 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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() +}); diff --git a/src/routes/(app)/feature-requests/+page.server.ts b/src/routes/(app)/feature-requests/+page.server.ts new file mode 100644 index 0000000..737b28a --- /dev/null +++ b/src/routes/(app)/feature-requests/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/(app)/feature-requests/+page.svelte b/src/routes/(app)/feature-requests/+page.svelte new file mode 100644 index 0000000..1eba639 --- /dev/null +++ b/src/routes/(app)/feature-requests/+page.svelte @@ -0,0 +1,129 @@ + + + + Feature Requests - My Collection + + +
+
+
+

Feature Requests

+

Suggest and vote on new features.

+
+ +
+ + {#if form?.error} +
{form.error}
+ {/if} + + {#if showNewForm} +
+
{ + return async ({ update, result }) => { + await update(); + if (result.type === 'success') showNewForm = false; + }; + }} class="space-y-3"> +
+ + +
+
+ + +
+ +
+
+ {/if} + + {#if data.requests.length === 0 && !showNewForm} +
+

No feature requests yet. Be the first to suggest one!

+
+ {:else} +
+ {#each data.requests as req} +
+
+ +
+ + +
+ + +
+
+

{req.title}

+ + {req.status} + +
+ {#if req.description} +

{req.description}

+ {/if} +
+ {#if req.createdBy} + {req.createdBy} + {/if} + {timeAgo(req.createdAt)} +
+
+ + +
+ `; + 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} + + {/each} + +
+ + +
+
+
+
+ {/each} +
+ {/if} +