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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
28
apps/web/src/lib/components/ItemSuggestions.svelte
Normal file
28
apps/web/src/lib/components/ItemSuggestions.svelte
Normal 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}
|
||||
215
apps/web/src/lib/stores/lists.ts
Normal file
215
apps/web/src/lib/stores/lists.ts
Normal 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[]) ?? [];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
581
apps/web/src/routes/(app)/lists/[id]/+page.svelte
Normal file
581
apps/web/src/routes/(app)/lists/[id]/+page.svelte
Normal 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}
|
||||
Reference in New Issue
Block a user