diff options
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | src/lib/server/services/groceries.ts | 21 | ||||
| -rw-r--r-- | src/routes/catalog/+page.server.ts | 25 | ||||
| -rw-r--r-- | src/routes/catalog/+page.svelte | 97 |
4 files changed, 145 insertions, 7 deletions
@@ -35,9 +35,10 @@ on the list for the next shopping trip — there is only one persistent list. Tapping a suggestion re-adds the existing item; typing a new name creates it. 2. **Planning** — before shopping, sit down with your partner and browse the - catalog. Tap unchecked items to add them back to the list. The page - does not reload, so going through 20–30 items is not frustrating. - Stale one-off items can be deleted from the catalog. + catalog. Tap unchecked items to add them back to the list. Multi-select + and bulk-update category for several items at once. The page does not + reload, so going through 20–30 items is not frustrating. Stale one-off + items can be deleted from the catalog. 3. **Shopping** — items are grouped by category (matching store aisles). Check off as you grab them. Multiple devices can use the same list, @@ -66,7 +67,7 @@ worker precaches assets; works offline after first visit. ### Features - [ ] Live updates from server -- [ ] From catalog, multi-select and bulk-update category +- [x] From catalog, multi-select and bulk-update category - [x] Update item name/category on blur, no explicit save button - [ ] Auto-categorize new items (LLM-assisted) - [ ] Chores module (intro already promises "groceries, chores") diff --git a/src/lib/server/services/groceries.ts b/src/lib/server/services/groceries.ts index db9e736..3c6de70 100644 --- a/src/lib/server/services/groceries.ts +++ b/src/lib/server/services/groceries.ts @@ -1,6 +1,6 @@ import { db as defaultDb } from '$lib/server/db'; import { groceries, categories, references } from '$lib/server/db/schema'; -import { eq, desc } from 'drizzle-orm'; +import { eq, desc, inArray } from 'drizzle-orm'; import type { LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '$lib/server/db/schema'; import type { Category, Grocery, Reference } from './types'; @@ -109,6 +109,25 @@ export async function updateGrocery( .where(eq(groceries.id, id)); } +export async function updateGroceryCategory( + id: number, + categoryId: number | null, + db = defaultDb, +): Promise<void> { + await db.update(groceries).set({ categoryId }).where(eq(groceries.id, id)); +} + +export async function bulkUpdateGroceryCategory( + ids: number[], + categoryId: number | null, + db = defaultDb, +): Promise<void> { + await db + .update(groceries) + .set({ categoryId }) + .where(inArray(groceries.id, ids)); +} + export async function deleteGrocery(id: number, db = defaultDb): Promise<void> { await db.delete(groceries).where(eq(groceries.id, id)); } diff --git a/src/routes/catalog/+page.server.ts b/src/routes/catalog/+page.server.ts index 69a775a..22464dc 100644 --- a/src/routes/catalog/+page.server.ts +++ b/src/routes/catalog/+page.server.ts @@ -1,6 +1,13 @@ import { fail } from '@sveltejs/kit'; -import { loadAll, toggle } from '$lib/server/services/groceries'; -import { numericId } from '$lib/server/services/validation'; +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'; @@ -25,4 +32,18 @@ export const actions: Actions = { } 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/catalog/+page.svelte b/src/routes/catalog/+page.svelte index 7d3ebbe..2079d12 100644 --- a/src/routes/catalog/+page.svelte +++ b/src/routes/catalog/+page.svelte @@ -1,9 +1,16 @@ <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>(); + + 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> = {}; @@ -15,7 +22,43 @@ } </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} @@ -25,6 +68,12 @@ <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" @@ -60,12 +109,60 @@ </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; |
