diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 10:55:06 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 10:55:06 +0200 |
| commit | 113415ce357a9cea1759d0452ebae6352f8fe36b (patch) | |
| tree | 109d55552e7007ba1a8c2752692e85eeb0cc6168 /src/routes/groceries/+page.svelte | |
| parent | 8cd634e51b79200848068ef45bd3fb700758c394 (diff) | |
Split groceries domain out of the app root
Move the grocery list, catalog, and categories routes under /groceries/
so the app home can host the projects/routines overview next. Update
nav to exact-path matching, login redirect default, and layout title.
Diffstat (limited to 'src/routes/groceries/+page.svelte')
| -rw-r--r-- | src/routes/groceries/+page.svelte | 207 |
1 files changed, 207 insertions, 0 deletions
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)} + >→</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} + {item.bestRef.price.toFixed(2)} + {item.bestRef.unit} + {/if} + {#if item.bestRef.qualityScore != null} + ({item.bestRef.qualityScore}/100) + {/if} + </span> + {/if} + </span> + </button> + <a class="ref-link" href="/grocery/{item.id}?from=/groceries" + >→</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> |
