feat: Fase 2a — shopping lists CRUD

DB: shopping_lists + shopping_items tables with full RLS, pg_cron jobs
(archive completed lists after 7 days, purge trash after 7 days).
item_frequency table with SECURITY DEFINER trigger that normalises and
upserts on every shopping_items INSERT.

Frontend: /lists overview (featured card + 2-col grid + trash toggle),
/lists/[id] detail (qty stepper, inline edit, swipe-to-reveal-delete,
drag & drop reorder via svelte-dnd-action, ItemSuggestions chips).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 22:25:31 +02:00
parent ce1bcfb7a6
commit 3af1276c15
11 changed files with 1609 additions and 12 deletions

View File

@@ -70,6 +70,23 @@
"invite_email_label": "Email address",
"invite_send": "Send invitation",
"lists_title": "Shopping Lists",
"lists_new_list": "Create",
"lists_no_lists": "No lists yet",
"lists_create_first": "Create your first shopping list",
"lists_trash": "Trash",
"list_status_active": "Active List",
"list_status_completed": "Completed",
"list_status_archived": "Archived",
"list_name_placeholder": "e.g. Weekly shop",
"list_items_empty": "List is empty",
"list_items_empty_hint": "Add your first item below",
"list_reset": "Reset list",
"list_archive": "Archive",
"list_delete": "Move to trash",
"list_actions": "List actions",
"list_item_placeholder": "Add item (e.g. Yogurt 500g)",
"list_qty_label": "Qty",
"list_unit_label": "Unit",
"list_add_item": "Add item…",
"list_to_buy": "To buy",
"list_checked": "Checked",

View File

@@ -70,6 +70,23 @@
"invite_email_label": "Correo electrónico",
"invite_send": "Enviar invitación",
"lists_title": "Listas de la compra",
"lists_new_list": "Crear",
"lists_no_lists": "Sin listas",
"lists_create_first": "Crea tu primera lista de la compra",
"lists_trash": "Papelera",
"list_status_active": "Lista activa",
"list_status_completed": "Completada",
"list_status_archived": "Archivada",
"list_name_placeholder": "p. ej. Compra semanal",
"list_items_empty": "Lista vacía",
"list_items_empty_hint": "Añade tu primer producto abajo",
"list_reset": "Reiniciar lista",
"list_archive": "Archivar",
"list_delete": "Mover a papelera",
"list_actions": "Acciones de lista",
"list_item_placeholder": "Añadir producto…",
"list_qty_label": "Cant.",
"list_unit_label": "Unidad",
"list_add_item": "Añadir producto…",
"list_to_buy": "Por comprar",
"list_checked": "Marcado",

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import type { ItemFrequency } from '@colectivo/types';
interface Props {
suggestions: ItemFrequency[];
activePrefix: string;
onselect: (name: string) => void;
}
let { suggestions, activePrefix, onselect }: Props = $props();
</script>
{#if suggestions.length > 0}
<div class="flex flex-wrap gap-1.5 px-4 pb-2 pt-1">
{#each suggestions as item (item.name)}
<button
type="button"
onclick={() => onselect(item.name)}
class="rounded-full px-3 py-1 text-[13px] font-medium transition-colors
{activePrefix && item.name.startsWith(activePrefix.toLowerCase().trim())
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700'}"
>
{item.name}
</button>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,215 @@
import { writable } from 'svelte/store';
import { getSupabase } from '$lib/supabase';
import type { ShoppingList, ShoppingItem, ItemFrequency } from '@colectivo/types';
// ── Global list store (overview page) ─────────────────────────────────────────
export const lists = writable<ShoppingList[]>([]);
export const listsLoading = writable(false);
export async function loadLists(collectiveId: string) {
listsLoading.set(true);
const { data, error } = await getSupabase()
.from('shopping_lists')
.select('*')
.eq('collective_id', collectiveId)
.is('deleted_at', null)
.order('created_at', { ascending: false });
if (!error && data) {
lists.set(data as ShoppingList[]);
}
listsLoading.set(false);
}
export async function loadTrashedLists(collectiveId: string): Promise<ShoppingList[]> {
const { data, error } = await getSupabase()
.from('shopping_lists')
.select('*')
.eq('collective_id', collectiveId)
.not('deleted_at', 'is', null)
.order('deleted_at', { ascending: false });
if (error || !data) return [];
return data as ShoppingList[];
}
// ── List mutations (optimistic) ───────────────────────────────────────────────
export async function createList(
collectiveId: string,
name: string,
userId: string
): Promise<ShoppingList | null> {
const tempId = crypto.randomUUID();
const optimistic: ShoppingList = {
id: tempId,
collective_id: collectiveId,
name,
status: 'active',
created_by: userId,
created_at: new Date().toISOString(),
completed_at: null,
deleted_at: null
};
lists.update((ls) => [optimistic, ...ls]);
const { data, error } = await getSupabase()
.from('shopping_lists')
.insert({ collective_id: collectiveId, name, created_by: userId })
.select()
.single();
if (error || !data) {
lists.update((ls) => ls.filter((l) => l.id !== tempId));
return null;
}
const real = data as ShoppingList;
lists.update((ls) => ls.map((l) => (l.id === tempId ? real : l)));
return real;
}
export async function renameList(id: string, name: string) {
lists.update((ls) => ls.map((l) => (l.id === id ? { ...l, name } : l)));
await getSupabase().from('shopping_lists').update({ name }).eq('id', id);
}
export async function softDeleteList(id: string) {
lists.update((ls) => ls.filter((l) => l.id !== id));
await getSupabase()
.from('shopping_lists')
.update({ deleted_at: new Date().toISOString() })
.eq('id', id);
}
export async function restoreList(id: string, collectiveId: string) {
await getSupabase().from('shopping_lists').update({ deleted_at: null }).eq('id', id);
await loadLists(collectiveId);
}
export async function permanentDeleteList(id: string) {
await getSupabase().from('shopping_lists').delete().eq('id', id);
}
export async function archiveList(id: string) {
lists.update((ls) => ls.filter((l) => l.id !== id));
await getSupabase().from('shopping_lists').update({ status: 'archived' }).eq('id', id);
}
export async function completeList(id: string) {
const completed_at = new Date().toISOString();
lists.update((ls) =>
ls.map((l) => (l.id === id ? { ...l, status: 'completed' as const, completed_at } : l))
);
await getSupabase()
.from('shopping_lists')
.update({ status: 'completed', completed_at })
.eq('id', id);
}
export async function resetList(id: string) {
lists.update((ls) =>
ls.map((l) =>
l.id === id ? { ...l, status: 'active' as const, completed_at: null } : l
)
);
await Promise.all([
getSupabase()
.from('shopping_lists')
.update({ status: 'active', completed_at: null })
.eq('id', id),
getSupabase()
.from('shopping_items')
.update({ is_checked: false, checked_by: null, checked_at: null })
.eq('list_id', id)
]);
}
// ── Item operations (used by [id] page) ───────────────────────────────────────
export async function loadItems(listId: string): Promise<ShoppingItem[]> {
const { data, error } = await getSupabase()
.from('shopping_items')
.select('*')
.eq('list_id', listId)
.order('sort_order', { ascending: true });
if (error || !data) return [];
return data as ShoppingItem[];
}
export async function addItem(
listId: string,
name: string,
quantity: number | null,
unit: string | null,
userId: string,
sortOrder: number
): Promise<ShoppingItem | null> {
const { data, error } = await getSupabase()
.from('shopping_items')
.insert({ list_id: listId, name, quantity, unit, sort_order: sortOrder, created_by: userId })
.select()
.single();
if (error || !data) return null;
return data as ShoppingItem;
}
export async function updateItem(
id: string,
patch: Partial<
Pick<
ShoppingItem,
'name' | 'quantity' | 'unit' | 'is_checked' | 'checked_by' | 'checked_at' | 'sort_order'
>
>
) {
await getSupabase().from('shopping_items').update(patch).eq('id', id);
}
export async function checkItem(id: string, userId: string, checked: boolean) {
const patch = checked
? { is_checked: true, checked_by: userId, checked_at: new Date().toISOString() }
: { is_checked: false, checked_by: null, checked_at: null };
await getSupabase().from('shopping_items').update(patch).eq('id', id);
}
export async function deleteItem(id: string) {
await getSupabase().from('shopping_items').delete().eq('id', id);
}
export async function reorderItems(items: Pick<ShoppingItem, 'id'>[]) {
await Promise.all(
items.map((item, index) =>
getSupabase().from('shopping_items').update({ sort_order: index }).eq('id', item.id)
)
);
}
// ── Suggestions (item_frequency) ──────────────────────────────────────────────
export async function fetchSuggestions(
collectiveId: string,
prefix: string
): Promise<ItemFrequency[]> {
if (!prefix.trim()) {
const { data } = await getSupabase()
.from('item_frequency')
.select('*')
.eq('collective_id', collectiveId)
.order('use_count', { ascending: false })
.limit(5);
return (data as ItemFrequency[]) ?? [];
}
const { data } = await getSupabase()
.from('item_frequency')
.select('*')
.eq('collective_id', collectiveId)
.ilike('name', `${prefix.toLowerCase().trim()}%`)
.order('use_count', { ascending: false })
.limit(10);
return (data as ItemFrequency[]) ?? [];
}

View File

@@ -1,17 +1,426 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { currentCollective } from '$lib/stores/collective';
import { currentUser } from '$lib/stores/auth';
import {
lists,
listsLoading,
loadLists,
loadTrashedLists,
createList,
softDeleteList,
restoreList,
permanentDeleteList,
archiveList,
resetList
} from '$lib/stores/lists';
import type { ShoppingList } from '@colectivo/types';
import { ShoppingCart, Plus, MoreHorizontal, Trash2, Archive, RotateCcw } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages';
// ── State ──────────────────────────────────────────────────────────────────
let newListName = $state('');
let creating = $state(false);
let nameInput: HTMLInputElement | undefined = $state();
let showTrash = $state(false);
let trashedLists = $state<ShoppingList[]>([]);
let trashLoading = $state(false);
let openMenuId = $state<string | null>(null);
// ── Derived ────────────────────────────────────────────────────────────────
const activeLists = $derived($lists.filter((l) => l.status === 'active'));
const completedLists = $derived($lists.filter((l) => l.status === 'completed'));
const featuredList = $derived(activeLists[0] ?? null);
const gridLists = $derived(activeLists.slice(1));
// ── Load ───────────────────────────────────────────────────────────────────
$effect(() => {
if ($currentCollective) {
loadLists($currentCollective.id);
}
});
async function toggleTrash() {
showTrash = !showTrash;
if (showTrash && $currentCollective) {
trashLoading = true;
trashedLists = await loadTrashedLists($currentCollective.id);
trashLoading = false;
}
}
// ── Create ─────────────────────────────────────────────────────────────────
async function handleCreate() {
const name = newListName.trim();
if (!name || !$currentCollective || !$currentUser) return;
creating = true;
newListName = '';
await createList($currentCollective.id, name, $currentUser.id);
creating = false;
nameInput?.focus();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') handleCreate();
}
// ── Actions ────────────────────────────────────────────────────────────────
function toggleMenu(id: string, e: MouseEvent) {
e.stopPropagation();
openMenuId = openMenuId === id ? null : id;
}
function closeMenu() {
openMenuId = null;
}
async function handleArchive(id: string) {
closeMenu();
await archiveList(id);
}
async function handleReset(id: string) {
closeMenu();
await resetList(id);
}
async function handleDelete(id: string) {
closeMenu();
await softDeleteList(id);
}
async function handleRestore(id: string) {
if (!$currentCollective) return;
await restoreList(id, $currentCollective.id);
trashedLists = trashedLists.filter((l) => l.id !== id);
}
async function handlePermanentDelete(id: string) {
await permanentDeleteList(id);
trashedLists = trashedLists.filter((l) => l.id !== id);
}
// Dismiss open menu on outside click
function handleWindowClick() {
openMenuId = null;
}
// ── Helpers ────────────────────────────────────────────────────────────────
function trashDaysLeft(deletedAt: string): number {
const expiry = new Date(deletedAt).getTime() + 7 * 24 * 60 * 60 * 1000;
return Math.max(0, Math.ceil((expiry - Date.now()) / (24 * 60 * 60 * 1000)));
}
</script>
<svelte:window onclick={handleWindowClick} />
<div class="flex flex-1 flex-col overflow-hidden">
<header class="px-8 pb-4 pt-8">
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.nav_lists()}
</p>
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
{m.lists_title()}
</h1>
<!-- Header -->
<header class="flex items-end justify-between px-8 pb-4 pt-8">
<div>
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{$currentCollective?.name ?? m.app_name()}
</p>
<h1 class="text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
{m.lists_title()}
</h1>
</div>
<button
onclick={toggleTrash}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[13px] font-medium
{showTrash
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900'
: 'text-text-secondary hover:bg-black/5 dark:hover:bg-white/5'}"
>
<Trash2 size={14} strokeWidth={1.5} />
{m.lists_trash()}
</button>
</header>
<div class="flex-1 overflow-y-auto px-8 py-6">
<!-- Fase 2a: shopping lists content -->
<!-- Content -->
<div class="flex-1 overflow-y-auto px-8 pb-24">
{#if showTrash}
<!-- Trash view -->
<div class="mb-6">
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.lists_trash()}
</p>
{#if trashLoading}
<p class="text-sm text-text-muted">{m.loading()}</p>
{:else if trashedLists.length === 0}
<p class="text-sm text-text-muted">{m.trash_empty()}</p>
{:else}
<div class="space-y-1">
{#each trashedLists as list (list.id)}
<div
class="flex items-center gap-3 rounded-lg bg-surface px-4 py-3"
>
<ShoppingCart size={16} strokeWidth={1.5} class="shrink-0 text-text-muted" />
<span class="min-w-0 flex-1 truncate text-sm text-text-primary">{list.name}</span>
<span class="shrink-0 text-[12px] text-text-muted">
{m.trash_expires_in({ days: trashDaysLeft(list.deleted_at!) })}
</span>
<button
onclick={() => handleRestore(list.id)}
class="shrink-0 rounded px-2 py-1 text-[12px] font-medium text-slate-600
hover:bg-black/5 dark:text-slate-400 dark:hover:bg-white/5"
>
{m.trash_restore()}
</button>
<button
onclick={() => handlePermanentDelete(list.id)}
class="shrink-0 rounded px-2 py-1 text-[12px] font-medium text-destructive
hover:bg-destructive/10"
>
{m.trash_delete_permanent()}
</button>
</div>
{/each}
</div>
{/if}
</div>
{:else if $listsLoading}
<!-- Loading state -->
<div class="space-y-3">
{#each [1, 2, 3] as i}
<div class="h-24 animate-pulse rounded-lg bg-surface-container-low" aria-hidden="true"></div>
{/each}
</div>
{:else if activeLists.length === 0 && completedLists.length === 0}
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-4 rounded-full bg-surface-container-low p-4">
<ShoppingCart size={24} strokeWidth={1.5} class="text-text-muted" />
</div>
<p class="mb-1 text-sm font-medium text-text-primary">{m.lists_no_lists()}</p>
<p class="text-sm text-text-muted">{m.lists_create_first()}</p>
</div>
{:else}
<!-- Featured list (first active list) -->
{#if featuredList}
<div class="relative mb-4">
<a
href="/lists/{featuredList.id}"
class="block rounded-lg bg-surface p-5 shadow-[0px_2px_8px_rgba(15,23,42,0.04)]
hover:shadow-[0px_4px_16px_rgba(15,23,42,0.08)] transition-shadow"
>
<div class="mb-3 flex items-start justify-between">
<div class="flex items-center gap-2">
<ShoppingCart size={16} strokeWidth={1.5} class="text-text-muted" />
<span class="text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.list_status_active()}
</span>
</div>
</div>
<h2 class="text-[18px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
{featuredList.name}
</h2>
</a>
<!-- Action menu -->
<div class="absolute right-3 top-3">
<button
onclick={(e) => toggleMenu(featuredList.id, e)}
class="rounded-md p-1.5 text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.list_actions()}
>
<MoreHorizontal size={16} strokeWidth={1.5} />
</button>
{#if openMenuId === featuredList.id}
<div
class="absolute right-0 top-full z-10 mt-1 w-44 rounded-lg bg-surface
shadow-[0px_20px_40px_rgba(15,23,42,0.1)] dark:shadow-[0px_20px_40px_rgba(0,0,0,0.3)]"
>
<button
onclick={() => handleReset(featuredList.id)}
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5 rounded-t-lg"
>
<RotateCcw size={14} strokeWidth={1.5} />
{m.list_reset()}
</button>
<button
onclick={() => handleArchive(featuredList.id)}
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5"
>
<Archive size={14} strokeWidth={1.5} />
{m.list_archive()}
</button>
<button
onclick={() => handleDelete(featuredList.id)}
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-destructive
hover:bg-destructive/10 rounded-b-lg"
>
<Trash2 size={14} strokeWidth={1.5} />
{m.list_delete()}
</button>
</div>
{/if}
</div>
</div>
{/if}
<!-- 2-column grid of remaining active lists -->
{#if gridLists.length > 0}
<div class="mb-4 grid grid-cols-2 gap-3">
{#each gridLists as list (list.id)}
<div class="relative">
<a
href="/lists/{list.id}"
class="block rounded-lg bg-surface p-4 shadow-[0px_2px_8px_rgba(15,23,42,0.04)]
hover:shadow-[0px_4px_16px_rgba(15,23,42,0.08)] transition-shadow"
>
<div class="mb-1 flex items-center gap-1.5">
<ShoppingCart size={14} strokeWidth={1.5} class="text-text-muted" />
</div>
<h3 class="text-[15px] font-semibold tracking-[-0.01em] text-slate-900
dark:text-slate-50 truncate">
{list.name}
</h3>
</a>
<div class="absolute right-2 top-2">
<button
onclick={(e) => toggleMenu(list.id, e)}
class="rounded-md p-1 text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.list_actions()}
>
<MoreHorizontal size={14} strokeWidth={1.5} />
</button>
{#if openMenuId === list.id}
<div
class="absolute right-0 top-full z-10 mt-1 w-44 rounded-lg bg-surface
shadow-[0px_20px_40px_rgba(15,23,42,0.1)] dark:shadow-[0px_20px_40px_rgba(0,0,0,0.3)]"
>
<button
onclick={() => handleReset(list.id)}
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5 rounded-t-lg"
>
<RotateCcw size={14} strokeWidth={1.5} />
{m.list_reset()}
</button>
<button
onclick={() => handleArchive(list.id)}
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5"
>
<Archive size={14} strokeWidth={1.5} />
{m.list_archive()}
</button>
<button
onclick={() => handleDelete(list.id)}
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-destructive
hover:bg-destructive/10 rounded-b-lg"
>
<Trash2 size={14} strokeWidth={1.5} />
{m.list_delete()}
</button>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
<!-- Completed lists -->
{#if completedLists.length > 0}
<div class="mt-6">
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.list_status_completed()}
</p>
<div class="space-y-1">
{#each completedLists as list (list.id)}
<div class="relative flex items-center gap-3 rounded-lg bg-surface px-4 py-3">
<ShoppingCart size={15} strokeWidth={1.5} class="shrink-0 text-text-muted" />
<a
href="/lists/{list.id}"
class="min-w-0 flex-1 truncate text-sm text-slate-600 hover:text-slate-900
dark:text-slate-400 dark:hover:text-slate-50"
>
{list.name}
</a>
<button
onclick={(e) => toggleMenu(list.id, e)}
class="shrink-0 rounded-md p-1 text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.list_actions()}
>
<MoreHorizontal size={14} strokeWidth={1.5} />
</button>
{#if openMenuId === list.id}
<div
class="absolute right-8 top-1 z-10 w-44 rounded-lg bg-surface
shadow-[0px_20px_40px_rgba(15,23,42,0.1)] dark:shadow-[0px_20px_40px_rgba(0,0,0,0.3)]"
>
<button
onclick={() => handleReset(list.id)}
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5 rounded-t-lg"
>
<RotateCcw size={14} strokeWidth={1.5} />
{m.list_reset()}
</button>
<button
onclick={() => handleArchive(list.id)}
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5"
>
<Archive size={14} strokeWidth={1.5} />
{m.list_archive()}
</button>
<button
onclick={() => handleDelete(list.id)}
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-destructive
hover:bg-destructive/10 rounded-b-lg"
>
<Trash2 size={14} strokeWidth={1.5} />
{m.list_delete()}
</button>
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{/if}
</div>
<!-- Sticky bottom: create new list -->
<div
class="absolute bottom-0 left-56 right-0 flex items-center gap-3 px-8 py-4
bg-background/80 backdrop-blur-[12px]"
>
<div class="flex flex-1 items-center gap-2 rounded-lg bg-surface px-4 py-2.5
shadow-[0px_2px_8px_rgba(15,23,42,0.06)]">
<Plus size={16} strokeWidth={1.5} class="shrink-0 text-text-muted" />
<input
bind:this={nameInput}
bind:value={newListName}
onkeydown={handleKeydown}
type="text"
placeholder={m.list_name_placeholder()}
class="min-w-0 flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-muted
outline-none"
/>
</div>
<button
onclick={handleCreate}
disabled={!newListName.trim() || creating}
class="rounded-md bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
hover:opacity-90 disabled:opacity-40 dark:bg-white dark:text-slate-900
transition-opacity"
>
{m.lists_new_list()}
</button>
</div>
</div>

View File

@@ -0,0 +1,581 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { flip } from 'svelte/animate';
import { dndzone } from 'svelte-dnd-action';
import { currentCollective } from '$lib/stores/collective';
import { currentUser } from '$lib/stores/auth';
import {
loadItems,
addItem,
updateItem,
checkItem,
deleteItem,
reorderItems,
fetchSuggestions
} from '$lib/stores/lists';
import { getSupabase } from '$lib/supabase';
import type { ShoppingItem, ShoppingList } from '@colectivo/types';
import ItemSuggestions from '$lib/components/ItemSuggestions.svelte';
import {
ArrowLeft,
GripVertical,
Minus,
Plus,
Trash2,
MoreHorizontal,
RotateCcw,
Check
} from 'lucide-svelte';
import * as m from '$lib/paraglide/messages';
const listId = $page.params.id!;
// ── State ──────────────────────────────────────────────────────────────────
let list = $state<ShoppingList | null>(null);
let items = $state<ShoppingItem[]>([]);
let loading = $state(true);
// Add-item form
let newName = $state('');
let newQty = $state<number | null>(null);
let newUnit = $state('');
let nameInput: HTMLInputElement | undefined = $state();
// Suggestions
let suggestions = $state<Awaited<ReturnType<typeof fetchSuggestions>>>([]);
let suggestionsTimer: ReturnType<typeof setTimeout>;
// Inline edit
let editingId = $state<string | null>(null);
let editName = $state('');
let editQty = $state<number | null>(null);
let editUnit = $state('');
// Swipe-to-delete (touch only)
let swipeOffsets = $state<Record<string, number>>({});
let openSwipeId = $state<string | null>(null);
let swipeStartX = 0;
let swipeStartY = 0;
let swipeHorizontal = false;
const SWIPE_MAX = 72;
const SWIPE_THRESHOLD = 48;
// List action menu
let showMenu = $state(false);
// ── Derived ────────────────────────────────────────────────────────────────
const uncheckedItems = $derived(items.filter((i) => !i.is_checked));
const checkedItems = $derived(items.filter((i) => i.is_checked));
// ── Load ───────────────────────────────────────────────────────────────────
onMount(async () => {
const [listRes, itemsRes] = await Promise.all([
getSupabase().from('shopping_lists').select('*').eq('id', listId).single(),
loadItems(listId)
]);
if (listRes.error || !listRes.data) {
goto('/lists');
return;
}
list = listRes.data as ShoppingList;
items = itemsRes;
loading = false;
// Load initial suggestions (empty prefix = top 5)
if ($currentCollective) {
suggestions = await fetchSuggestions($currentCollective.id, '');
}
});
// ── Suggestions ────────────────────────────────────────────────────────────
async function updateSuggestions(prefix: string) {
clearTimeout(suggestionsTimer);
if (!$currentCollective) return;
suggestionsTimer = setTimeout(async () => {
suggestions = await fetchSuggestions($currentCollective!.id, prefix);
}, 150);
}
$effect(() => {
updateSuggestions(newName);
});
function selectSuggestion(name: string) {
newName = name;
nameInput?.focus();
}
// ── Add item ───────────────────────────────────────────────────────────────
async function handleAdd() {
const name = newName.trim();
if (!name || !$currentUser) return;
const sortOrder = items.length;
const tempId = crypto.randomUUID();
const optimistic: ShoppingItem = {
id: tempId,
list_id: listId,
name,
quantity: newQty,
unit: newUnit.trim() || null,
is_checked: false,
checked_by: null,
checked_at: null,
sort_order: sortOrder,
created_by: $currentUser.id,
created_at: new Date().toISOString()
};
items = [...items, optimistic];
newName = '';
newQty = null;
newUnit = '';
nameInput?.focus();
const real = await addItem(listId, name, newQty, optimistic.unit, $currentUser.id, sortOrder);
if (real) {
items = items.map((i) => (i.id === tempId ? real : i));
} else {
items = items.filter((i) => i.id !== tempId);
}
}
function handleAddKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') handleAdd();
}
// ── Check / uncheck ────────────────────────────────────────────────────────
async function handleCheck(item: ShoppingItem) {
if (!$currentUser) return;
const checked = !item.is_checked;
items = items.map((i) =>
i.id === item.id
? {
...i,
is_checked: checked,
checked_by: checked ? $currentUser!.id : null,
checked_at: checked ? new Date().toISOString() : null
}
: i
);
await checkItem(item.id, $currentUser.id, checked);
}
// ── Quantity stepper ───────────────────────────────────────────────────────
async function adjustQty(item: ShoppingItem, delta: number) {
const current = item.quantity ?? 1;
const next = Math.max(1, current + delta);
items = items.map((i) => (i.id === item.id ? { ...i, quantity: next } : i));
await updateItem(item.id, { quantity: next });
}
// ── Inline edit ────────────────────────────────────────────────────────────
function startEdit(item: ShoppingItem) {
// Don't start edit while swiped open
if (openSwipeId === item.id) return;
editingId = item.id;
editName = item.name;
editQty = item.quantity;
editUnit = item.unit ?? '';
}
async function commitEdit(item: ShoppingItem) {
if (editingId !== item.id) return;
editingId = null;
const name = editName.trim();
if (!name) return;
if (name === item.name && editQty === item.quantity && (editUnit || null) === item.unit) return;
items = items.map((i) =>
i.id === item.id
? { ...i, name, quantity: editQty, unit: editUnit.trim() || null }
: i
);
await updateItem(item.id, {
name,
quantity: editQty,
unit: editUnit.trim() || null
});
}
function handleEditKeydown(e: KeyboardEvent, item: ShoppingItem) {
if (e.key === 'Enter') commitEdit(item);
if (e.key === 'Escape') editingId = null;
}
// ── Delete ─────────────────────────────────────────────────────────────────
async function handleDelete(id: string) {
swipeOffsets[id] = 0;
openSwipeId = null;
items = items.filter((i) => i.id !== id);
await deleteItem(id);
}
// ── Swipe to reveal delete (touch) ─────────────────────────────────────────
function onSwipeStart(e: PointerEvent, id: string) {
if (e.pointerType === 'mouse') return;
swipeStartX = e.clientX;
swipeStartY = e.clientY;
swipeHorizontal = false;
// Close other open swipe
if (openSwipeId && openSwipeId !== id) {
swipeOffsets[openSwipeId] = 0;
openSwipeId = null;
}
}
function onSwipeMove(e: PointerEvent, id: string) {
if (e.pointerType === 'mouse') return;
const dx = e.clientX - swipeStartX;
const dy = e.clientY - swipeStartY;
if (!swipeHorizontal) {
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
if (Math.abs(dy) >= Math.abs(dx)) return; // vertical dominates
swipeHorizontal = true;
}
swipeOffsets[id] = Math.max(-SWIPE_MAX, Math.min(0, dx));
}
function onSwipeEnd(e: PointerEvent, id: string) {
if (e.pointerType === 'mouse') return;
if (!swipeHorizontal) return;
const offset = swipeOffsets[id] ?? 0;
if (offset <= -SWIPE_THRESHOLD) {
swipeOffsets[id] = -SWIPE_MAX;
openSwipeId = id;
} else {
swipeOffsets[id] = 0;
openSwipeId = null;
}
swipeHorizontal = false;
}
// ── Drag & drop reorder ────────────────────────────────────────────────────
const flipDurationMs = 200;
function handleDndConsider(e: CustomEvent<{ items: ShoppingItem[] }>) {
const checked = items.filter((i) => i.is_checked);
items = [...e.detail.items, ...checked];
}
async function handleDndFinalize(e: CustomEvent<{ items: ShoppingItem[] }>) {
const checked = items.filter((i) => i.is_checked);
const reordered = e.detail.items;
items = [...reordered, ...checked];
await reorderItems(reordered);
}
// ── List actions ───────────────────────────────────────────────────────────
async function handleReset() {
if (!list) return;
showMenu = false;
items = items.map((i) => ({ ...i, is_checked: false, checked_by: null, checked_at: null }));
await getSupabase().from('shopping_lists').update({ status: 'active', completed_at: null }).eq('id', listId);
await getSupabase().from('shopping_items').update({ is_checked: false, checked_by: null, checked_at: null }).eq('list_id', listId);
if (list) list = { ...list, status: 'active', completed_at: null };
}
</script>
{#if loading}
<div class="flex flex-1 items-center justify-center">
<span class="text-sm text-text-muted">{m.loading()}</span>
</div>
{:else}
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Header -->
<header class="flex items-center gap-3 px-8 pb-4 pt-8">
<a
href="/lists"
class="rounded-md p-1.5 text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
aria-label="Back"
>
<ArrowLeft size={16} strokeWidth={1.5} />
</a>
<div class="flex-1 min-w-0">
<p class="mb-0.5 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.list_status_active()}
</p>
<h1 class="truncate text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
{list?.name}
</h1>
</div>
<!-- Actions menu -->
<div class="relative shrink-0">
<button
onclick={() => (showMenu = !showMenu)}
class="rounded-md p-1.5 text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.list_actions()}
>
<MoreHorizontal size={16} strokeWidth={1.5} />
</button>
{#if showMenu}
<div
class="absolute right-0 top-full z-10 mt-1 w-44 rounded-lg bg-surface
shadow-[0px_20px_40px_rgba(15,23,42,0.1)] dark:shadow-[0px_20px_40px_rgba(0,0,0,0.3)]"
>
<button
onclick={handleReset}
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5 rounded-lg"
>
<RotateCcw size={14} strokeWidth={1.5} />
{m.list_reset()}
</button>
</div>
{/if}
</div>
</header>
<!-- Items -->
<div class="flex-1 overflow-y-auto pb-32">
{#if uncheckedItems.length === 0 && checkedItems.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<p class="mb-1 text-sm font-medium text-text-primary">{m.list_items_empty()}</p>
<p class="text-sm text-text-muted">{m.list_items_empty_hint()}</p>
</div>
{:else}
<!-- Unchecked items (drag & droppable) -->
{#if uncheckedItems.length > 0}
<div
use:dndzone={{ items: uncheckedItems, flipDurationMs, type: 'items' }}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="px-8"
>
{#each uncheckedItems as item (item.id)}
<div animate:flip={{ duration: flipDurationMs }} class="relative overflow-hidden">
<!-- Delete zone (behind item, revealed on swipe) -->
<div
class="absolute inset-y-0 right-0 flex w-[72px] items-center justify-center
bg-red-500 rounded-r-lg"
>
<button
onclick={() => handleDelete(item.id)}
class="flex h-full w-full items-center justify-center text-white"
aria-label={m.action_delete()}
>
<Trash2 size={18} strokeWidth={1.5} />
</button>
</div>
<!-- Item row -->
<div
role="listitem"
class="relative flex items-center gap-3 py-3 bg-background touch-pan-y select-none
transition-transform duration-150"
style="transform: translateX({swipeOffsets[item.id] ?? 0}px)"
onpointerdown={(e) => onSwipeStart(e, item.id)}
onpointermove={(e) => onSwipeMove(e, item.id)}
onpointerup={(e) => onSwipeEnd(e, item.id)}
>
<!-- Drag handle (desktop) -->
<div class="shrink-0 cursor-grab text-text-muted active:cursor-grabbing hidden sm:flex">
<GripVertical size={16} strokeWidth={1.5} />
</div>
<!-- Checkbox -->
<button
onclick={() => handleCheck(item)}
class="shrink-0 h-5 w-5 rounded border-2 border-slate-300 dark:border-slate-600
flex items-center justify-center hover:border-slate-400 dark:hover:border-slate-500
transition-colors"
aria-label="Toggle item"
>
</button>
{#if editingId === item.id}
<!-- Inline edit form -->
<div class="flex flex-1 items-center gap-2">
<input
type="text"
bind:value={editName}
onblur={() => commitEdit(item)}
onkeydown={(e) => handleEditKeydown(e, item)}
class="flex-1 min-w-0 bg-transparent text-sm text-text-primary outline-none
border-b border-slate-300 dark:border-slate-600 pb-0.5"
/>
<input
type="number"
bind:value={editQty}
onblur={() => commitEdit(item)}
min="0"
placeholder={m.list_qty_label()}
class="w-14 bg-transparent text-sm text-text-secondary outline-none
border-b border-slate-300 dark:border-slate-600 pb-0.5 text-right"
/>
<input
type="text"
bind:value={editUnit}
onblur={() => commitEdit(item)}
onkeydown={(e) => handleEditKeydown(e, item)}
placeholder={m.list_unit_label()}
class="w-14 bg-transparent text-sm text-text-secondary outline-none
border-b border-slate-300 dark:border-slate-600 pb-0.5"
/>
</div>
{:else}
<!-- Item display -->
<button
class="flex-1 min-w-0 text-left"
onclick={() => startEdit(item)}
>
<span class="block truncate text-sm text-slate-800 dark:text-slate-200">
{item.name}
</span>
{#if item.unit}
<span class="text-[12px] text-text-muted">{item.unit}</span>
{/if}
</button>
<!-- Quantity stepper -->
<div class="flex shrink-0 items-center gap-1">
<button
onclick={() => adjustQty(item, -1)}
class="flex h-6 w-6 items-center justify-center rounded text-text-muted
hover:bg-black/5 dark:hover:bg-white/5"
aria-label="Decrease quantity"
>
<Minus size={12} strokeWidth={2} />
</button>
<span class="w-7 text-center text-sm tabular-nums text-slate-700 dark:text-slate-300">
{item.quantity ?? 1}
</span>
<button
onclick={() => adjustQty(item, 1)}
class="flex h-6 w-6 items-center justify-center rounded text-text-muted
hover:bg-black/5 dark:hover:bg-white/5"
aria-label="Increase quantity"
>
<Plus size={12} strokeWidth={2} />
</button>
</div>
<!-- Desktop delete button (hover) -->
<button
onclick={() => handleDelete(item.id)}
class="hidden shrink-0 rounded p-1 text-text-muted hover:text-destructive
hover:bg-destructive/10 sm:flex"
aria-label={m.action_delete()}
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
<!-- Checked items section -->
{#if checkedItems.length > 0}
<div class="px-8 mt-4">
<p class="mb-2 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-muted">
{m.list_checked()} ({checkedItems.length})
</p>
{#each checkedItems as item (item.id)}
<div class="flex items-center gap-3 py-3 opacity-50">
<!-- Drag handle placeholder -->
<div class="hidden sm:flex shrink-0 w-4"></div>
<!-- Checked checkbox -->
<button
onclick={() => handleCheck(item)}
class="shrink-0 h-5 w-5 rounded border-2 border-slate-400 bg-slate-400
dark:border-slate-500 dark:bg-slate-500
flex items-center justify-center transition-colors"
aria-label="Uncheck item"
>
<Check size={12} strokeWidth={2.5} class="text-white dark:text-slate-900" />
</button>
<span class="flex-1 min-w-0 truncate text-sm text-text-muted line-through">
{item.name}
</span>
<button
onclick={() => handleDelete(item.id)}
class="shrink-0 rounded p-1 text-text-muted hover:text-destructive
hover:bg-destructive/10"
aria-label={m.action_delete()}
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<!-- Sticky bottom: suggestions + add form -->
<div class="absolute bottom-0 left-56 right-0 bg-background/80 backdrop-blur-[12px]">
<!-- Suggestions -->
<ItemSuggestions
{suggestions}
activePrefix={newName}
onselect={selectSuggestion}
/>
<!-- Add input -->
<div class="flex items-center gap-2 px-4 pb-4 pt-1">
<div class="flex flex-1 items-center gap-2 rounded-lg bg-surface px-4 py-2.5
shadow-[0px_2px_8px_rgba(15,23,42,0.06)]">
<input
bind:this={nameInput}
bind:value={newName}
onkeydown={handleAddKeydown}
type="text"
placeholder={m.list_item_placeholder()}
class="min-w-0 flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-muted
outline-none"
/>
<input
bind:value={newQty}
type="number"
min="1"
placeholder={m.list_qty_label()}
class="w-12 bg-transparent text-sm text-text-secondary placeholder:text-text-muted
outline-none text-right"
/>
<input
bind:value={newUnit}
type="text"
placeholder={m.list_unit_label()}
onkeydown={handleAddKeydown}
class="w-12 bg-transparent text-sm text-text-secondary placeholder:text-text-muted
outline-none"
/>
</div>
<button
onclick={handleAdd}
disabled={!newName.trim()}
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg
bg-slate-900 text-white hover:opacity-90 disabled:opacity-40
dark:bg-white dark:text-slate-900 transition-opacity"
aria-label={m.add()}
>
<Plus size={18} strokeWidth={2} />
</button>
</div>
</div>
</div>
{/if}