aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md9
-rw-r--r--src/lib/server/services/groceries.ts21
-rw-r--r--src/routes/catalog/+page.server.ts25
-rw-r--r--src/routes/catalog/+page.svelte97
4 files changed, 145 insertions, 7 deletions
diff --git a/README.md b/README.md
index 689e454..2bf5ab3 100644
--- a/README.md
+++ b/README.md
@@ -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;