aboutsummaryrefslogtreecommitdiff
path: root/src/routes/groceries
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes/groceries')
-rw-r--r--src/routes/groceries/+page.server.ts71
-rw-r--r--src/routes/groceries/+page.svelte207
-rw-r--r--src/routes/groceries/catalog/+page.server.ts49
-rw-r--r--src/routes/groceries/catalog/+page.svelte211
-rw-r--r--src/routes/groceries/categories/+page.server.ts53
-rw-r--r--src/routes/groceries/categories/+page.svelte164
6 files changed, 755 insertions, 0 deletions
diff --git a/src/routes/groceries/+page.server.ts b/src/routes/groceries/+page.server.ts
new file mode 100644
index 0000000..e462ab2
--- /dev/null
+++ b/src/routes/groceries/+page.server.ts
@@ -0,0 +1,71 @@
+import { fail, redirect, error } from '@sveltejs/kit';
+import {
+ loadUnchecked,
+ loadAll,
+ toggle,
+ addOrUncheck,
+} from '$lib/server/services/groceries';
+import {
+ numericId,
+ parseFormString,
+ parseOptionalNumber,
+} from '$lib/server/services/validation';
+import type { EnrichedGrocery } from '$lib/server/services/groceries';
+import type { Category } from '$lib/server/services/types';
+import type { Actions, PageServerLoad } from './$types';
+
+export interface GroceriesOutput {
+ items: EnrichedGrocery[];
+ categories: Category[];
+ catalog: EnrichedGrocery[];
+}
+
+export const load: PageServerLoad = async () => {
+ const unchecked = await loadUnchecked();
+ const all = await loadAll();
+ return {
+ items: unchecked.items,
+ categories: unchecked.categories,
+ catalog: all.items,
+ };
+};
+
+export const actions: Actions = {
+ add: async ({ request }) => {
+ const data = await request.formData();
+ const name = parseFormString(data.get('name'));
+ const categoryId = parseOptionalNumber(data.get('category_id'));
+
+ if (!name) {
+ return fail(400, { error: 'Le nom est requis' });
+ }
+
+ await addOrUncheck(name, categoryId);
+ const { items } = await loadUnchecked();
+ return { success: true, items };
+ },
+
+ toggle: async ({ request }) => {
+ const data = await request.formData();
+ try {
+ const id = numericId(data.get('id'));
+ await toggle(id);
+ } catch {
+ return fail(400, { error: 'ID invalide' });
+ }
+ return { success: true };
+ },
+
+ addAndEdit: async ({ request }) => {
+ const data = await request.formData();
+ const name = parseFormString(data.get('name'));
+ const categoryId = parseOptionalNumber(data.get('category_id'));
+
+ if (!name) {
+ return fail(400, { error: 'Le nom est requis' });
+ }
+
+ const { item, created } = await addOrUncheck(name, categoryId);
+ throw redirect(303, `/grocery/${item.id}`);
+ },
+};
diff --git a/src/routes/groceries/+page.svelte b/src/routes/groceries/+page.svelte
new file mode 100644
index 0000000..4471738
--- /dev/null
+++ b/src/routes/groceries/+page.svelte
@@ -0,0 +1,207 @@
+<script lang="ts">
+ import { deserialize } from '$app/forms';
+ import { goto } from '$app/navigation';
+ import { enhance } from '$app/forms';
+ import FuzzyAutocomplete from '$lib/components/FuzzyAutocomplete.svelte';
+ import type { EnrichedGrocery } from '$lib/server/services/groceries';
+
+ let { data, form } = $props();
+ let name = $state('');
+ // eslint-disable-next-line svelte/prefer-writable-derived
+ let localItems = $state<EnrichedGrocery[]>([]);
+ let addError = $state('');
+ // Sync local list with server data when it changes externally (toggle, navigation)
+ $effect(() => {
+ localItems = data?.items ?? [];
+ });
+
+ function groupByCategory(items: typeof localItems) {
+ const groups: Record<string, typeof localItems> = {};
+ for (const item of items) {
+ const key = item.categoryName ?? 'Non catégorisé';
+ (groups[key] ??= []).push(item);
+ }
+ const result: [string, typeof localItems][] = data.categories
+ .filter((cat) => groups[cat.name]?.length > 0)
+ .map((cat) => [cat.name, groups[cat.name]]);
+ if (groups['Non catégorisé']?.length > 0) {
+ result.unshift(['Non catégorisé', groups['Non catégorisé']]);
+ }
+ return result;
+ }
+
+ async function handleAdd(redirectToEdit: boolean) {
+ if (!name.trim()) {
+ addError = 'Le nom est requis';
+ return;
+ }
+ addError = '';
+
+ const formData = new FormData();
+ formData.append('name', name);
+
+ const action = redirectToEdit ? '?/addAndEdit' : '?/add';
+ name = '';
+
+ try {
+ const response = await fetch(action, {
+ method: 'POST',
+ headers: {
+ accept: 'application/json',
+ 'x-sveltekit-action': 'true',
+ },
+ body: formData,
+ });
+
+ const result = deserialize(await response.text());
+
+ if (result.type === 'error') {
+ addError =
+ typeof result.error === 'string'
+ ? result.error
+ : result.error?.message || 'Erreur';
+ return;
+ }
+
+ if (result.type === 'redirect') {
+ goto(result.location);
+ return;
+ }
+
+ if (result.type === 'success' && result.data?.items) {
+ localItems = result.data.items as EnrichedGrocery[];
+ }
+ } catch (err) {
+ addError = 'Erreur réseau';
+ console.error(err);
+ }
+ }
+</script>
+
+<div class="add-bar">
+ <FuzzyAutocomplete
+ items={data.catalog}
+ bind:value={name}
+ placeholder="Ajouter un article..."
+ onselect={(item) => {
+ name = item.name;
+ handleAdd(false);
+ }}
+ />
+ <button type="button" aria-label="Ajouter" onclick={() => handleAdd(false)}
+ >+</button
+ >
+ <button type="button" aria-label="Modifier" onclick={() => handleAdd(true)}
+ >&rarr;</button
+ >
+</div>
+
+<main>
+ {#if addError || form?.error}
+ <p class="error">{addError || form?.error}</p>
+ {/if}
+
+ {#if localItems.length === 0}
+ <p class="empty">Rien à acheter</p>
+ {:else}
+ <form method="POST" action="?/toggle" use:enhance>
+ {#each groupByCategory(localItems) as [cat, items] (cat)}
+ <h3 class="category-name">{cat}</h3>
+ <ul class="item-list">
+ {#each items as item (item.id)}
+ <li class="item-row item-row--fixed">
+ <button
+ class="item-btn item-btn--grow"
+ name="id"
+ value={item.id}
+ type="submit"
+ aria-pressed={item.checked}
+ >
+ <span class="item-text">
+ {item.name}
+ {#if item.bestRef}
+ <span class="best-ref"
+ >{item.bestRef.reference}
+ {#if item.bestRef.price && item.bestRef.unit}
+ &nbsp;{item.bestRef.price.toFixed(2)}
+ {item.bestRef.unit}
+ {/if}
+ {#if item.bestRef.qualityScore != null}
+ &nbsp;({item.bestRef.qualityScore}/100)
+ {/if}
+ </span>
+ {/if}
+ </span>
+ </button>
+ <a class="ref-link" href="/grocery/{item.id}?from=/groceries"
+ >&rarr;</a
+ >
+ </li>
+ {/each}
+ </ul>
+ {/each}
+ </form>
+ {/if}
+</main>
+
+<style>
+ .add-bar {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ position: sticky;
+ top: var(--nav-height);
+ background: var(--bg);
+ border-bottom: 1px solid var(--border);
+ z-index: 10;
+ }
+
+ .add-bar button {
+ width: 3rem;
+ height: 3rem;
+ flex-shrink: 0;
+ padding: 0;
+ border: none;
+ border-radius: 0.75rem;
+ font-size: 1.25rem;
+ font-weight: 500;
+ font-family: inherit;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ }
+
+ .add-bar > button:nth-of-type(1) {
+ background: var(--accent);
+ color: var(--white);
+ box-shadow: 0 1px 2px var(--shadow);
+ }
+
+ .add-bar > button:nth-of-type(1):hover {
+ background: var(--accent-hover);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 8px var(--shadow-accent-lg);
+ }
+
+ .add-bar > button:nth-of-type(2) {
+ background: var(--surface);
+ color: var(--text-secondary);
+ border: 1.5px solid var(--border);
+ }
+
+ .add-bar > button:nth-of-type(2):hover {
+ border-color: var(--accent);
+ color: var(--text);
+ }
+
+ .error {
+ margin-bottom: 1rem;
+ }
+
+ .empty {
+ margin-top: 1rem;
+ padding: 2rem;
+ }
+</style>
diff --git a/src/routes/groceries/catalog/+page.server.ts b/src/routes/groceries/catalog/+page.server.ts
new file mode 100644
index 0000000..22464dc
--- /dev/null
+++ b/src/routes/groceries/catalog/+page.server.ts
@@ -0,0 +1,49 @@
+import { fail } from '@sveltejs/kit';
+import {
+ loadAll,
+ toggle,
+ bulkUpdateGroceryCategory,
+} from '$lib/server/services/groceries';
+import {
+ numericId,
+ parseOptionalNumber,
+} from '$lib/server/services/validation';
+import type { EnrichedGrocery } from '$lib/server/services/groceries';
+import type { Category } from '$lib/server/services/types';
+import type { Actions, PageServerLoad } from './$types';
+
+export interface CatalogOutput {
+ items: EnrichedGrocery[];
+ categories: Category[];
+}
+
+export const load: PageServerLoad = async () => {
+ return loadAll();
+};
+
+export const actions: Actions = {
+ toggle: async ({ request }) => {
+ const data = await request.formData();
+ try {
+ const id = numericId(data.get('id'));
+ await toggle(id);
+ } catch {
+ return fail(400, { error: 'ID invalide' });
+ }
+ return { success: true };
+ },
+
+ bulkUpdateCategory: async ({ request }) => {
+ const data = await request.formData();
+ const ids = data.getAll('ids').map(numericId);
+ const rawCategoryId = parseOptionalNumber(data.get('category_id'));
+ const categoryId = rawCategoryId === -1 ? null : rawCategoryId;
+
+ if (ids.length === 0) {
+ return fail(400, { error: 'Aucun article sélectionné' });
+ }
+
+ await bulkUpdateGroceryCategory(ids, categoryId);
+ return { success: true, type: 'bulkUpdate' as const };
+ },
+};
diff --git a/src/routes/groceries/catalog/+page.svelte b/src/routes/groceries/catalog/+page.svelte
new file mode 100644
index 0000000..4cbcc27
--- /dev/null
+++ b/src/routes/groceries/catalog/+page.svelte
@@ -0,0 +1,211 @@
+<script lang="ts">
+ import { enhance } from '$app/forms';
+ import { SvelteSet } from 'svelte/reactivity';
+
+ let { data, form } = $props();
+
+ let catalogItems = $derived(data?.items ?? []);
+ let selectedIds = new SvelteSet<number>();
+ let searchQuery = $state('');
+
+ let filteredItems = $derived(
+ searchQuery.trim() === ''
+ ? catalogItems
+ : catalogItems.filter((item) =>
+ item.name.toLowerCase().includes(searchQuery.toLowerCase()),
+ ),
+ );
+
+ function toggleId(id: number) {
+ if (selectedIds.has(id)) selectedIds.delete(id);
+ else selectedIds.add(id);
+ }
+
+ function groupByCategory(items: typeof catalogItems) {
+ const groups: Record<string, typeof catalogItems> = {};
+ for (const item of items) {
+ const key = item.categoryName ?? 'Non catégorisé';
+ (groups[key] ??= []).push(item);
+ }
+ const result: [string, typeof catalogItems][] = [];
+ for (const cat of data.categories) {
+ if (groups[cat.name]?.length > 0) {
+ result.push([cat.name, groups[cat.name]]);
+ }
+ }
+ if (groups['Non catégorisé']?.length > 0) {
+ result.unshift(['Non catégorisé', groups['Non catégorisé']]);
+ }
+ return result;
+ }
+</script>
+
+{#if catalogItems.length > 0}
+ <div class="bulk-bar">
+ <form
+ method="POST"
+ action="?/bulkUpdateCategory"
+ use:enhance={() => {
+ return async ({ update }) => {
+ await update({ reset: false });
+ selectedIds.clear();
+ };
+ }}
+ >
+ {#each Array.from(selectedIds) as id (id)}
+ <input type="hidden" name="ids" value={id} />
+ {/each}
+ <select name="category_id" class="form-select">
+ <option value={-1}>Non catégorisé</option>
+ {#each data.categories as cat (cat.id)}
+ <option value={cat.id}>{cat.name}</option>
+ {/each}
+ </select>
+ <button
+ class="btn-primary"
+ type="submit"
+ disabled={selectedIds.size === 0}
+ >
+ Mettre à jour ({selectedIds.size})
+ </button>
+ </form>
+ </div>
+{/if}
+
+<main>
+ {#if form?.error}
+ <p class="error">{form.error}</p>
+ {/if}
+
+ {#if catalogItems.length === 0}
+ <p class="empty">Aucun article</p>
+ {:else}
+ <input
+ type="search"
+ bind:value={searchQuery}
+ placeholder="Rechercher un article..."
+ class="search-input"
+ />
+
+ {#if filteredItems.length === 0}
+ <p class="empty">Aucun résultat</p>
+ {:else}
+ <form method="POST" action="?/toggle" use:enhance>
+ {#each groupByCategory(filteredItems) as [cat, items] (cat)}
+ <h3 class="category-name">{cat}</h3>
+ <ul class="item-list">
+ {#each items as item (item.id)}
+ <li class="item-row item-row--fixed">
+ <input
+ type="checkbox"
+ class="item-check"
+ checked={selectedIds.has(item.id)}
+ onchange={() => toggleId(item.id)}
+ />
+ <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}
+ <span class="best-ref"
+ >{item.bestRef.reference}
+ {#if item.bestRef.price && item.bestRef.unit}
+ &nbsp;{item.bestRef.price.toFixed(2)}
+ {item.bestRef.unit}
+ {/if}
+ {#if item.bestRef.qualityScore != null}
+ &nbsp;({item.bestRef.qualityScore}/100)
+ {/if}
+ </span>
+ {/if}
+ </span>
+ </button>
+ <a
+ class="ref-link"
+ href="/grocery/{item.id}?from=/groceries/catalog">&rarr;</a
+ >
+ </li>
+ {/each}
+ </ul>
+ {/each}
+ </form>
+ {/if}
+ {/if}
+</main>
+
+<style>
+ .bulk-bar {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ position: sticky;
+ top: var(--nav-height);
+ background: var(--bg);
+ border-bottom: 1px solid var(--border);
+ z-index: 10;
+ }
+
+ .bulk-bar form {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ width: 100%;
+ }
+
+ .bulk-bar .form-select {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .bulk-bar .btn-primary {
+ padding: 0.75rem 1rem;
+ align-self: center;
+ white-space: nowrap;
+ }
+
+ .bulk-bar .btn-primary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+ }
+
+ .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;
+ }
+
+ .error {
+ margin-bottom: 1rem;
+ }
+
+ .empty {
+ margin-top: 1rem;
+ padding: 2rem;
+ }
+
+ .search-input {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ background: var(--surface);
+ color: var(--text);
+ font-size: 1rem;
+ margin-top: 1rem;
+ }
+</style>
diff --git a/src/routes/groceries/categories/+page.server.ts b/src/routes/groceries/categories/+page.server.ts
new file mode 100644
index 0000000..c97b619
--- /dev/null
+++ b/src/routes/groceries/categories/+page.server.ts
@@ -0,0 +1,53 @@
+import { fail } from '@sveltejs/kit';
+import {
+ loadAll,
+ add,
+ deleteCategory,
+ reorder,
+} from '$lib/server/services/categories';
+import { numericId, parseFormString } from '$lib/server/services/validation';
+import type { Category } from '$lib/server/services/types';
+import type { Actions, PageServerLoad } from './$types';
+
+export interface CategoriesOutput {
+ categories: Category[];
+}
+
+export const load: PageServerLoad = async () => {
+ return { categories: await loadAll() };
+};
+
+export const actions: Actions = {
+ add: async ({ request }) => {
+ const data = await request.formData();
+ const name = parseFormString(data.get('name'));
+
+ if (!name) {
+ return fail(400, { error: 'Le nom est requis' });
+ }
+
+ await add(name);
+ return { success: true };
+ },
+
+ delete: async ({ request }) => {
+ const data = await request.formData();
+ try {
+ const id = numericId(data.get('id'));
+ await deleteCategory(id);
+ } catch {
+ return fail(400, { error: 'ID invalide' });
+ }
+ return { success: true };
+ },
+
+ reorder: async ({ request }) => {
+ const data = await request.formData();
+ const ids = data.getAll('orderedIds').map(numericId);
+ if (ids.length === 0) {
+ return fail(400, { error: 'Aucune catégorie' });
+ }
+ await reorder(ids);
+ return { success: true };
+ },
+};
diff --git a/src/routes/groceries/categories/+page.svelte b/src/routes/groceries/categories/+page.svelte
new file mode 100644
index 0000000..267a287
--- /dev/null
+++ b/src/routes/groceries/categories/+page.svelte
@@ -0,0 +1,164 @@
+<script lang="ts">
+ import { enhance } from '$app/forms';
+ import type { Category } from '$lib/server/services/types';
+
+ let { data, form } = $props();
+ let name = $state('');
+ let draggedIndex = $state<number | null>(null);
+ // eslint-disable-next-line svelte/prefer-writable-derived
+ let localCategories = $state<Category[]>([]);
+
+ // Sync with server data on navigation
+ $effect(() => {
+ localCategories = data?.categories ?? [];
+ });
+
+ let reorderFormRef: HTMLFormElement;
+
+ function handleDragStart(index: number) {
+ draggedIndex = index;
+ }
+
+ function handleDragOver(e: DragEvent, index: number) {
+ e.preventDefault();
+ if (draggedIndex === null || draggedIndex === index) return;
+
+ const next = [...localCategories];
+ const [moved] = next.splice(draggedIndex, 1);
+ next.splice(index, 0, moved);
+ localCategories = next;
+ draggedIndex = index;
+ }
+
+ function handleDrop() {
+ draggedIndex = null;
+ reorderFormRef?.requestSubmit();
+ }
+</script>
+
+<main>
+ <form class="cat-add-bar" method="POST" action="?/add" use:enhance>
+ <input
+ class="form-input"
+ type="text"
+ name="name"
+ bind:value={name}
+ placeholder="Nouvelle catégorie..."
+ autocomplete="off"
+ />
+ <button class="btn-accent" type="submit" aria-label="Ajouter">+</button>
+ </form>
+
+ {#if form?.error}
+ <p class="error">{form.error}</p>
+ {/if}
+
+ <form
+ bind:this={reorderFormRef}
+ method="POST"
+ action="?/reorder"
+ use:enhance={() => {
+ return async ({ update }) => {
+ await update({ reset: false });
+ };
+ }}
+ >
+ {#each localCategories as cat (cat.id)}
+ <input type="hidden" name="orderedIds" value={cat.id} />
+ {/each}
+ </form>
+
+ <form method="POST" action="?/delete" use:enhance>
+ <ul class="item-list">
+ {#each localCategories as cat, index (cat.id)}
+ <li
+ class="item-row item-row--card"
+ draggable="true"
+ ondragstart={() => handleDragStart(index)}
+ ondragover={(e) => handleDragOver(e, index)}
+ ondrop={handleDrop}
+ ondragend={() => (draggedIndex = null)}
+ >
+ <span class="drag-handle">⋮⋮</span>
+ <span class="cat-name">{cat.name}</span>
+ <button
+ class="btn-ghost"
+ name="id"
+ value={cat.id}
+ type="submit"
+ aria-label="Supprimer"
+ >
+ &times;
+ </button>
+ </li>
+ {/each}
+ </ul>
+ </form>
+
+ {#if (localCategories.length ?? 0) === 0}
+ <p class="empty">Aucune catégorie</p>
+ {/if}
+</main>
+
+<style>
+ .cat-name {
+ flex-grow: 1;
+ font-weight: 500;
+ color: var(--text);
+ }
+
+ .drag-handle {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ cursor: grab;
+ padding-right: 0.5rem;
+ user-select: none;
+ }
+
+ .error {
+ margin-top: -0.25rem;
+ margin-bottom: 0.5rem;
+ }
+
+ .cat-add-bar {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.75rem 0;
+ }
+
+ .cat-add-bar input {
+ flex: 1;
+ background: var(--surface);
+ }
+
+ .btn-accent {
+ width: 3rem;
+ height: 3rem;
+ flex-shrink: 0;
+ padding: 0;
+ border: none;
+ border-radius: 0.75rem;
+ background: var(--accent);
+ color: var(--white);
+ font-size: 1.25rem;
+ font-weight: 500;
+ font-family: inherit;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+ box-shadow: 0 1px 2px var(--shadow);
+ }
+
+ .btn-accent:hover {
+ background: var(--accent-hover);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 8px var(--shadow-accent-lg);
+ }
+
+ .empty {
+ margin-top: 1rem;
+ padding: 2rem;
+ }
+</style>