diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-07-27 19:53:11 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-07-27 19:53:11 +0200 |
| commit | e075698891d653330b5d5d0f80c2c3495aee6262 (patch) | |
| tree | f0b04b3d003fb49f820d1da86b811a9bd05b9a56 | |
| parent | 2ded7d512470b2e9faabf2f61a0a97eef95a6f89 (diff) | |
feat: product references + grocery detail page
- references table (brand, price, unit, quality_score) with FK to groceries
- /grocery/[id] page for managing references per item
- Unit dropdown (€/kg, €/L, €/u) defaulting to €/kg
- → link on all items navigates to detail page
- Delete item moved from catalog to detail page
| -rw-r--r-- | drizzle/0001_dry_meteorite.sql | 9 | ||||
| -rw-r--r-- | drizzle/meta/0001_snapshot.json | 172 | ||||
| -rw-r--r-- | drizzle/meta/_journal.json | 7 | ||||
| -rw-r--r-- | eslint.config.js | 1 | ||||
| -rw-r--r-- | src/lib/server/db/schema.ts | 13 | ||||
| -rw-r--r-- | src/routes/+page.server.ts | 9 | ||||
| -rw-r--r-- | src/routes/+page.svelte | 32 | ||||
| -rw-r--r-- | src/routes/grocery/[id]/+page.server.ts | 71 | ||||
| -rw-r--r-- | src/routes/grocery/[id]/+page.svelte | 253 |
9 files changed, 539 insertions, 28 deletions
diff --git a/drizzle/0001_dry_meteorite.sql b/drizzle/0001_dry_meteorite.sql new file mode 100644 index 0000000..ae22f4b --- /dev/null +++ b/drizzle/0001_dry_meteorite.sql @@ -0,0 +1,9 @@ +CREATE TABLE `references` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `grocery_id` integer NOT NULL, + `brand` text NOT NULL, + `price` real, + `unit` text, + `quality_score` integer, + FOREIGN KEY (`grocery_id`) REFERENCES `groceries`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..21f509e --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,172 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "52dffa39-fdb4-4a7c-8688-136e0ad485fc", + "prevId": "54c7ae45-9838-4ffb-ad46-29995def7272", + "tables": { + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "categories_name_unique": { + "name": "categories_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groceries": { + "name": "groceries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked": { + "name": "checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "groceries_name_unique": { + "name": "groceries_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": { + "groceries_category_id_categories_id_fk": { + "name": "groceries_category_id_categories_id_fk", + "tableFrom": "groceries", + "tableTo": "categories", + "columnsFrom": ["category_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "references": { + "name": "references", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "grocery_id": { + "name": "grocery_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quality_score": { + "name": "quality_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "references_grocery_id_groceries_id_fk": { + "name": "references_grocery_id_groceries_id_fk", + "tableFrom": "references", + "tableTo": "groceries", + "columnsFrom": ["grocery_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2923632..9872346 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1785169427572, "tag": "0000_youthful_mercury", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1785170535526, + "tag": "0001_dry_meteorite", + "breakpoints": true } ] } diff --git a/eslint.config.js b/eslint.config.js index e49e39f..060a8a7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,6 +7,7 @@ export default [ { rules: { 'svelte/no-at-html-tags': 'off', + 'svelte/no-navigation-without-resolve': 'off', }, }, { diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 1d5fd9e..9cd6af8 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; +import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'; export const categories = sqliteTable('categories', { id: integer('id').primaryKey({ autoIncrement: true }), @@ -12,3 +12,14 @@ export const groceries = sqliteTable('groceries', { createdAt: text('created_at').notNull(), categoryId: integer('category_id').references(() => categories.id), }); + +export const references = sqliteTable('references', { + id: integer('id').primaryKey({ autoIncrement: true }), + groceryId: integer('grocery_id') + .notNull() + .references(() => groceries.id, { onDelete: 'cascade' }), + brand: text('brand').notNull(), + price: real('price'), + unit: text('unit'), + qualityScore: integer('quality_score'), +}); diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index e77b27f..6d547e6 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -75,13 +75,4 @@ export const actions: Actions = { return { success: true }; }, - - delete: async ({ request }) => { - const data = await request.formData(); - const id = Number(data.get('id')); - - await db.delete(groceries).where(eq(groceries.id, id)); - - return { success: true }; - }, }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9490a51..64c8410 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -57,10 +57,11 @@ <h3 class="category-name">{cat}</h3> <ul class="item-list"> {#each items as item (item.id)} - <li> + <li class="catalog-item"> <button name="id" value={item.id} type="submit"> {item.name} </button> + <a class="ref-link" href="/grocery/{item.id}">→</a> </li> {/each} </ul> @@ -84,15 +85,7 @@ <button name="id" value={item.id} type="submit"> {item.name} </button> - <button - class="delete-btn" - name="id" - value={item.id} - type="submit" - formaction="?/delete" - > - × - </button> + <a class="ref-link" href="/grocery/{item.id}">→</a> </li> {/each} </ul> @@ -191,20 +184,23 @@ align-items: center; } - .catalog-item .delete-btn { - border: 1px solid #e0e0e0; - border-radius: 0.5rem; - background: #fff; - color: #d00; - transition: background 0.15s; + .ref-link { width: 2rem; + aspect-ratio: 1; display: flex; align-items: center; justify-content: center; + border: 1px solid #e0e0e0; + border-radius: 0.5rem; + background: #fff; + color: #333; + text-decoration: none; + font-size: 1rem; + transition: background 0.15s; } - .catalog-item .delete-btn:hover { - background: #fee; + .ref-link:hover { + background: #f5f5f5; } .empty { diff --git a/src/routes/grocery/[id]/+page.server.ts b/src/routes/grocery/[id]/+page.server.ts new file mode 100644 index 0000000..88aa137 --- /dev/null +++ b/src/routes/grocery/[id]/+page.server.ts @@ -0,0 +1,71 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { groceries, categories, references } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params }) => { + const id = Number(params.id); + + const [item] = await db.select().from(groceries).where(eq(groceries.id, id)); + if (!item) throw error(404, 'Item not found'); + + const [cat] = item.categoryId + ? await db + .select() + .from(categories) + .where(eq(categories.id, item.categoryId)) + : [null]; + + const refs = await db + .select() + .from(references) + .where(eq(references.groceryId, id)); + + return { + item: { + ...item, + categoryName: cat?.name ?? null, + }, + references: refs, + }; +}; + +export const actions: Actions = { + add: async ({ request, params }) => { + const data = await request.formData(); + const brand = data.get('brand')?.toString().trim(); + const price = data.get('price')?.toString().trim(); + const unit = data.get('unit')?.toString().trim(); + const qualityScore = data.get('quality_score')?.toString().trim(); + + if (!brand) { + return fail(400, { error: 'Brand is required' }); + } + + await db.insert(references).values({ + groceryId: Number(params.id), + brand, + price: price ? Number(price) : null, + unit: unit || null, + qualityScore: qualityScore ? Number(qualityScore) : null, + }); + + return { success: true }; + }, + + delete: async ({ request }) => { + const data = await request.formData(); + const id = Number(data.get('id')); + + await db.delete(references).where(eq(references.id, id)); + + return { success: true }; + }, + + deleteItem: async ({ params }) => { + await db.delete(groceries).where(eq(groceries.id, Number(params.id))); + + throw redirect(303, '/'); + }, +}; diff --git a/src/routes/grocery/[id]/+page.svelte b/src/routes/grocery/[id]/+page.svelte new file mode 100644 index 0000000..15c4e8b --- /dev/null +++ b/src/routes/grocery/[id]/+page.svelte @@ -0,0 +1,253 @@ +<script lang="ts"> + import '../../../app.css'; + import { enhance } from '$app/forms'; + import { page } from '$app/stores'; + + let { data, form } = $props(); + let brand = $state(''); + let price = $state(''); + let unit = $state('€/kg'); + let qualityScore = $state(''); +</script> + +<h1>{data.item.name}</h1> + +{#if data.item.categoryName} + <p class="category">{data.item.categoryName}</p> +{/if} + +<hr /> + +<h2>References</h2> + +<form class="add-form" method="POST" action="?/add" use:enhance> + <label> + <span class="label">Brand</span> + <input + type="text" + name="brand" + bind:value={brand} + placeholder="e.g. Carte Noire" + autocomplete="off" + /> + </label> + <div class="ref-row"> + <label> + <span class="label">Price</span> + <input + type="number" + name="price" + bind:value={price} + placeholder="5.99" + step="0.01" + /> + </label> + <label> + <span class="label">Unit</span> + <select name="unit" bind:value={unit}> + <option value="€/kg">€/kg</option> + <option value="€/L">€/L</option> + <option value="€/u">€/u</option> + </select> + </label> + <label> + <span class="label">Score</span> + <input + type="number" + name="quality_score" + bind:value={qualityScore} + placeholder="0-100" + min="0" + max="100" + /> + </label> + </div> + <button type="submit">Add reference</button> +</form> + +{#if form?.error} + <p class="error">{form.error}</p> +{/if} + +<form method="POST" action="?/delete" use:enhance> + <ul class="item-list"> + {#each data?.references ?? [] as ref (ref.id)} + <li class="ref-item"> + <div class="ref-info"> + <strong>{ref.brand}</strong> + {#if ref.price} + <span class="ref-detail" + >{ref.price.toFixed(2)}{ref.unit ? ` ${ref.unit}` : ''}</span + > + {/if} + {#if ref.qualityScore != null} + <span class="ref-detail">Score: {ref.qualityScore}/100</span> + {/if} + </div> + <button class="delete-btn" name="id" value={ref.id} type="submit"> + × + </button> + </li> + {/each} + </ul> +</form> + +{#if (data?.references?.length ?? 0) === 0} + <p class="empty">No references yet</p> +{/if} + +<form method="POST" action="?/deleteItem" use:enhance> + <button type="submit" class="delete-item-btn">Delete item</button> +</form> + +<a href="/" class="back-link">← Back to list</a> + +<style> + .category { + color: #999; + font-size: 0.875rem; + } + + .add-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; + margin-top: 0.5rem; + } + + .add-form label { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: #999; + } + + input, + select { + padding: 0.75rem; + border: 1px solid #ccc; + border-radius: 0.5rem; + font-size: 1rem; + font-family: inherit; + background: #fff; + } + + .ref-row { + display: flex; + gap: 0.5rem; + } + + .ref-row label { + flex: 1; + min-width: 0; + } + + .add-form button[type='submit'] { + padding: 0.75rem 1.25rem; + border: none; + border-radius: 0.5rem; + background: #333; + color: #fff; + font-size: 1rem; + align-self: flex-start; + } + + .error { + color: #d00; + margin-top: -0.5rem; + margin-bottom: 1rem; + } + + hr { + border: none; + border-top: 1px solid #ddd; + margin: 1.5rem 0; + } + + .item-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .ref-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + border: 1px solid #e0e0e0; + border-radius: 0.5rem; + background: #fff; + } + + .ref-info { + flex-grow: 1; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: baseline; + } + + .ref-detail { + color: #666; + font-size: 0.875rem; + } + + .delete-btn { + flex-shrink: 0; + width: 2rem; + aspect-ratio: 1; + padding: 0; + border: 1px solid #e0e0e0; + border-radius: 0.5rem; + background: #fff; + color: #d00; + font-size: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.15s; + } + + .delete-btn:hover { + background: #fee; + } + + .empty { + color: #999; + font-size: 0.875rem; + margin-top: 0.5rem; + } + + .back-link { + display: inline-block; + margin-top: 1.5rem; + color: #333; + text-decoration: none; + font-size: 0.875rem; + } + + .delete-item-btn { + margin-top: 1.5rem; + padding: 0.5rem 1rem; + border: 1px solid #d00; + border-radius: 0.5rem; + background: #fff; + color: #d00; + font-size: 0.875rem; + cursor: pointer; + } + + .delete-item-btn:hover { + background: #fee; + } +</style> |
