aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-08 09:25:03 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-08 09:25:03 +0200
commit3148742b323ab71f1ef768a2f4dc353c00944034 (patch)
tree8f88990514fb58d11a8669b58a7265dbd3420fe0 /src/lib
parent7d85a18867ca0fe2310218c63cfc696c0f916171 (diff)
Share grocery row component and list-page form actions
- GroceryRow.svelte renders the toggle row (checkbox, name, best ref, arrow link) for both the list and catalog pages - grocery-actions.ts grows full form actions: addCategoryAction, addGroceryAction, addGroceryAndEditAction, deleteByIdAction; the categories/groceries route servers become thin wrappers
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/components/GroceryRow.svelte55
-rw-r--r--src/lib/server/services/grocery-actions.ts76
2 files changed, 128 insertions, 3 deletions
diff --git a/src/lib/components/GroceryRow.svelte b/src/lib/components/GroceryRow.svelte
new file mode 100644
index 0000000..3305afa
--- /dev/null
+++ b/src/lib/components/GroceryRow.svelte
@@ -0,0 +1,55 @@
+<script lang="ts">
+ import BestRef from '$lib/components/BestRef.svelte';
+ import type { EnrichedGrocery } from '$lib/server/services/groceries';
+
+ let {
+ item,
+ from,
+ selectable = false,
+ checked = false,
+ oncheck,
+ }: {
+ item: EnrichedGrocery;
+ from: string;
+ selectable?: boolean;
+ checked?: boolean;
+ oncheck?: () => void;
+ } = $props();
+</script>
+
+<li class="item-row item-row--fixed">
+ {#if selectable}
+ <input type="checkbox" class="item-check" {checked} onchange={oncheck} />
+ {/if}
+ <button
+ class="item-btn item-btn--grow"
+ name="id"
+ value={item.id}
+ type="submit"
+ aria-pressed={item.checked}
+ >
+ <span class="item-text">
+ <span class:checked={item.checked}>{item.name}</span>
+ {#if item.bestRef}
+ <BestRef ref={item.bestRef} />
+ {/if}
+ </span>
+ </button>
+ <a class="ref-link" href="/grocery/{item.id}?from={from}">&rarr;</a>
+</li>
+
+<style>
+ .item-check {
+ width: 1.25rem;
+ height: 1.25rem;
+ accent-color: var(--accent);
+ cursor: pointer;
+ flex-shrink: 0;
+ }
+
+ .checked {
+ text-decoration: line-through;
+ color: var(--text-secondary);
+ font-weight: 400;
+ }
+</style>
diff --git a/src/lib/server/services/grocery-actions.ts b/src/lib/server/services/grocery-actions.ts
index 0966c92..c8974e9 100644
--- a/src/lib/server/services/grocery-actions.ts
+++ b/src/lib/server/services/grocery-actions.ts
@@ -1,6 +1,7 @@
-import { fail } from '@sveltejs/kit';
-import { toggle } from './groceries';
-import { numericId } from './validation';
+import { fail, redirect, type ActionFailure } from '@sveltejs/kit';
+import { addOrUncheck, loadUnchecked, toggle } from './groceries';
+import { add as addCategory } from './categories';
+import { numericId, parseFormString, parseOptionalNumber } from './validation';
export async function toggleGroceryAction(request: Request) {
const data = await request.formData();
@@ -12,3 +13,72 @@ export async function toggleGroceryAction(request: Request) {
}
return { success: true };
}
+
+export type DeleteRowResult =
+ ActionFailure<{ error: string }> | { success: boolean; type?: 'delete' };
+
+/**
+ * Shared 'delete a row by its id form field' action, used by the category
+ * list and the reference list (both submit one form whose buttons carry the
+ * row id).
+ */
+export async function deleteByIdAction(
+ request: Request,
+ deleteFn: (id: number) => Promise<unknown>,
+ extra?: { type: 'delete' },
+): Promise<DeleteRowResult> {
+ const data = await request.formData();
+ try {
+ await deleteFn(numericId(data.get('id')));
+ } catch {
+ return fail(400, { error: 'ID invalide' });
+ }
+ return { success: true, type: extra?.type };
+}
+
+/** Parses the required 'name' field of an add form, or returns the failure. */
+function requireFormName(
+ data: FormData,
+): string | ActionFailure<{ error: string }> {
+ const name = parseFormString(data.get('name'));
+ if (!name) {
+ return fail(400, { error: 'Le nom est requis' });
+ }
+ return name;
+}
+
+export async function addCategoryAction(request: Request) {
+ const data = await request.formData();
+ const name = requireFormName(data);
+ if (typeof name !== 'string') {
+ return name;
+ }
+
+ await addCategory(name);
+ return { success: true };
+}
+
+export async function addGroceryAction(request: Request) {
+ const data = await request.formData();
+ const name = requireFormName(data);
+ if (typeof name !== 'string') {
+ return name;
+ }
+ const categoryId = parseOptionalNumber(data.get('category_id'));
+
+ await addOrUncheck(name, categoryId);
+ const { items } = await loadUnchecked();
+ return { success: true, items };
+}
+
+export async function addGroceryAndEditAction(request: Request) {
+ const data = await request.formData();
+ const name = requireFormName(data);
+ if (typeof name !== 'string') {
+ return name;
+ }
+ const categoryId = parseOptionalNumber(data.get('category_id'));
+
+ const { item } = await addOrUncheck(name, categoryId);
+ throw redirect(303, `/grocery/${item.id}`);
+}