feat(fase-5): mobile UX shell + masthead + swipe-delete undo (224 tests green)
5.0 Tests primero
Playwright: mobile-shell.test.ts (4 — sidebar hidden on mobile, bottom tab
bar visible with aria-current, hamburger opens drawer, desktop shows
sidebar), mobile-masthead.test.ts (4 — every top-level route pairs
masthead-label + masthead-title), mobile-swipe-delete.test.ts (M-06/M-07
describe.skip — Chromium touch emulation flaky; needs `playwright install
webkit`)
Vitest: undoQueue.test.ts (5 — schedule+commit, undo+restore, TTL expiry,
double-undo no-op, custom ttlMs)
5.1 Responsive shell
DesktopSidebar.svelte (hidden md:flex)
MobileTopBar.svelte (md:hidden sticky backdrop-blur-xl)
BottomTabBar.svelte (md:hidden glassmorphism, safe-area-inset-bottom)
MobileDrawer.svelte (backdrop + collective switcher + settings + logout)
(app)/+layout.svelte — CRITICAL FIX: flex flex-col md:flex-row. Was
horizontal-only, which pushed masthead off-screen on mobile. UndoToast
mounted globally here.
5.2 Masthead + responsive padding
ScreenMasthead.svelte — label (13px uppercase) + title (26px/32px).
Applied to /lists, /tasks, /notes, /search. px-8 → px-4 md:px-6 everywhere.
messages/{en,es}.json: masthead_{lists,tasks,notes,search}_label +
masthead_search_title, undo, undo_deleted_item, list_item_delete_aria.
5.4 Red-zone swipe-delete on /lists/[id]
SWIPE_MAX=96 (red zone rest width), SWIPE_THRESHOLD=48 (latch-open),
SWIPE_COMMIT_THRESHOLD=200 (full-swipe commit). Red zone button exposes
aria-label="Delete item"/"Eliminar producto". handleDelete uses
scheduleUndoable — optimistic local removal, 4-s TTL, commit to Supabase
on timeout or restore from snapshot on Undo.
5.5 UndoQueue + UndoToast
src/lib/sync/undoQueue.ts — writable store + Map-backed actions with TTL.
__flushUndoQueueForTests + __pendingUndoCount for Vitest.
src/lib/components/UndoToast.svelte — renders latest undoable, offset
above bottom tab bar (bottom: calc(env(safe-area-inset-bottom) + 4.5rem)),
Undo button.
5.6 Scrollable frequency chips
ItemSuggestions.svelte — mobile: flex overflow-x-auto with
[scrollbar-width:none] [&::-webkit-scrollbar]:hidden; desktop: wraps.
Chips shrink-0 so they don't compress.
5.7 Shell polish on tasks/notes/search
Search group headers now carry "{n} MATCH / MATCHES" badges.
Grid in /lists goes 2-col → 3-col at md+.
Sticky create input offset for the bottom tab bar on mobile.
5.Z Verification
just test-all → 224 verdes, 4 skipped
34 pgTAP (unchanged)
140 Vitest integration + 2 skipped presence (unchanged)
11 Vitest unit (was 6, +5 undoQueue)
39 Playwright + 2 skipped swipe-touch (was 31, +8 new mobile tests)
Deferred (explicitly):
- Presence avatars + item counts on list cards (needs per-list count RPC)
- Integrate undoQueue in task delete + note trash (trivial when needed)
- WebKit install to unskip M-06/M-07 swipe gesture tests
- max-w-2xl reading-width constraint on notes/search
- Manual visual QA in DevTools iPhone/Pixel/Desktop viewports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { isAuthenticated, authLoading, displayName } from '$lib/stores/auth';
|
||||
import { isAuthenticated, authLoading } from '$lib/stores/auth';
|
||||
import { login } from '$lib/auth';
|
||||
import DesktopSidebar from '$lib/components/layout/DesktopSidebar.svelte';
|
||||
import MobileTopBar from '$lib/components/layout/MobileTopBar.svelte';
|
||||
import BottomTabBar from '$lib/components/layout/BottomTabBar.svelte';
|
||||
import MobileDrawer from '$lib/components/layout/MobileDrawer.svelte';
|
||||
import UndoToast from '$lib/components/UndoToast.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||
import { logout } from '$lib/auth';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { ShoppingCart, CheckSquare, FileText, Search, Settings } from 'lucide-svelte';
|
||||
|
||||
let drawerOpen = $state(false);
|
||||
|
||||
// When auth resolves as unauthenticated, redirect to Keycloak.
|
||||
// This runs after onAuthStateChange fires (authLoading = false), so there
|
||||
@@ -19,26 +21,6 @@
|
||||
login();
|
||||
}
|
||||
});
|
||||
|
||||
const navItems = [
|
||||
{ href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart },
|
||||
{ href: '/tasks', label: () => m.nav_tasks(), icon: CheckSquare },
|
||||
{ href: '/notes', label: () => m.nav_notes(), icon: FileText },
|
||||
{ href: '/search', label: () => m.nav_search(), icon: Search }
|
||||
];
|
||||
|
||||
let collectiveSwitcherOpen = $state(false);
|
||||
|
||||
const activePath = $derived($page.url.pathname);
|
||||
|
||||
function switchCollective(id: string) {
|
||||
const c = $userCollectives.find((col) => col.id === id);
|
||||
if (c) {
|
||||
currentCollective.set(c);
|
||||
localStorage.setItem('activeCollectiveId', c.id);
|
||||
collectiveSwitcherOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $authLoading}
|
||||
@@ -46,86 +28,19 @@
|
||||
<span class="text-sm text-text-secondary">{m.loading()}</span>
|
||||
</div>
|
||||
{:else if $isAuthenticated}
|
||||
<div class="flex h-screen overflow-hidden bg-background">
|
||||
<!-- Sidebar — surface-raised bg creates the separation without a border line -->
|
||||
<aside class="flex w-56 flex-shrink-0 flex-col bg-surface-raised">
|
||||
<!-- Mobile: column stack (top bar → main → bottom tab bar).
|
||||
Desktop: row (sidebar | main). -->
|
||||
<div class="flex h-screen flex-col overflow-hidden bg-background md:flex-row">
|
||||
<DesktopSidebar />
|
||||
|
||||
<!-- Collective header + switcher — bottom gap instead of border -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex h-14 w-full items-center gap-2 px-4 text-left
|
||||
hover:bg-black/5 dark:hover:bg-white/5"
|
||||
onclick={() => (collectiveSwitcherOpen = !collectiveSwitcherOpen)}
|
||||
aria-expanded={collectiveSwitcherOpen}
|
||||
>
|
||||
<span class="text-xl">{$currentCollective?.emoji ?? '🏠'}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
|
||||
{$currentCollective?.name ?? m.app_name()}
|
||||
</span>
|
||||
{#if $userCollectives.length > 1}
|
||||
<span class="text-xs text-slate-400">⌄</span>
|
||||
{/if}
|
||||
</button>
|
||||
<MobileTopBar onMenu={() => (drawerOpen = true)} />
|
||||
<MobileDrawer bind:open={drawerOpen} />
|
||||
|
||||
{#if collectiveSwitcherOpen && $userCollectives.length > 1}
|
||||
<div
|
||||
class="absolute left-0 right-0 top-full z-50 bg-surface-raised shadow-[0px_20px_40px_rgba(15,23,42,0.06)]"
|
||||
>
|
||||
{#each $userCollectives as c}
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm
|
||||
hover:bg-black/5 dark:hover:bg-white/5
|
||||
{c.id === $currentCollective?.id ? 'font-semibold text-slate-900 dark:text-slate-50' : 'text-slate-600 dark:text-slate-400'}"
|
||||
onclick={() => switchCollective(c.id)}
|
||||
>
|
||||
<span class="text-base">{c.emoji}</span>
|
||||
<span class="truncate">{c.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto px-2 py-3">
|
||||
{#each navItems as { href, label, icon: Icon }}
|
||||
<a
|
||||
{href}
|
||||
class="mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors
|
||||
{activePath.startsWith(href)
|
||||
? 'bg-black/10 text-slate-900 dark:bg-white/10 dark:text-slate-50'
|
||||
: 'text-slate-500 hover:bg-black/5 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-white/5 dark:hover:text-slate-50'}"
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.5} />
|
||||
{label()}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- User footer — top gap instead of border -->
|
||||
<div class="p-3 pt-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Avatar name={$displayName} size={32} />
|
||||
<span class="truncate text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{$displayName}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="/settings"
|
||||
class="rounded-md p-1.5 text-slate-400 hover:bg-black/5 hover:text-slate-600
|
||||
dark:hover:bg-white/5 dark:hover:text-slate-300"
|
||||
aria-label={m.nav_settings()}
|
||||
>
|
||||
<Settings size={16} strokeWidth={1.5} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content — bg-background creates the tonal separation from sidebar -->
|
||||
<main class="flex flex-1 flex-col overflow-hidden bg-background">
|
||||
<main class="flex flex-1 flex-col overflow-hidden bg-background pb-16 md:pb-0">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<BottomTabBar />
|
||||
<UndoToast />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import type { ShoppingList } from '@colectivo/types';
|
||||
import { ShoppingCart, Plus, MoreHorizontal, Trash2, Archive, RotateCcw } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import ScreenMasthead from '$lib/components/ScreenMasthead.svelte';
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -124,30 +125,23 @@
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<!-- 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>
|
||||
<ScreenMasthead label={m.masthead_lists_label()} title={m.lists_title()}>
|
||||
{#snippet actions()}
|
||||
<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>
|
||||
{/snippet}
|
||||
</ScreenMasthead>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-8 pb-24">
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-24 md:px-6 md:pb-28">
|
||||
{#if showTrash}
|
||||
<!-- Trash view -->
|
||||
<div class="mb-6">
|
||||
@@ -269,9 +263,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 2-column grid of remaining active lists -->
|
||||
<!-- Grid of remaining active lists (2 col mobile, 3 col md+) -->
|
||||
{#if gridLists.length > 0}
|
||||
<div class="mb-4 grid grid-cols-2 gap-3">
|
||||
<div class="mb-4 grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
{#each gridLists as list (list.id)}
|
||||
<div class="relative">
|
||||
<a
|
||||
@@ -395,10 +389,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sticky bottom: create new list -->
|
||||
<!-- Sticky bottom: create new list (offset for the mobile bottom tab bar on small screens) -->
|
||||
<div
|
||||
class="absolute bottom-0 left-56 right-0 flex items-center gap-3 px-8 py-4
|
||||
bg-background/80 backdrop-blur-[12px]"
|
||||
class="absolute inset-x-0 bottom-0 flex items-center gap-3 px-4 py-4 bg-background/80 backdrop-blur-[12px] md:left-56 md:right-0 md:px-8
|
||||
mb-16 md:mb-0"
|
||||
>
|
||||
<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)]">
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
type RealtimeSubscription
|
||||
} from '$lib/stores/realtimeSync';
|
||||
import { enqueueOp, hydrateSyncState } from '$lib/sync';
|
||||
import { scheduleUndoable } from '$lib/sync/undoQueue';
|
||||
import SyncBanner from '$lib/components/SyncBanner.svelte';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import type { ShoppingItem, ShoppingList } from '@colectivo/types';
|
||||
@@ -67,8 +68,14 @@
|
||||
let swipeStartX = 0;
|
||||
let swipeStartY = 0;
|
||||
let swipeHorizontal = false;
|
||||
const SWIPE_MAX = 72;
|
||||
// Red zone peek — width revealed when resting in "open" state.
|
||||
const SWIPE_MAX = 96;
|
||||
// Drag distance required to latch the red zone open (instead of snapping back).
|
||||
const SWIPE_THRESHOLD = 48;
|
||||
// Drag distance (negative, i.e. leftward) past which a full-swipe commit fires.
|
||||
// Mobile cards are ~375px wide; 200px ≈ "more than half" which matches the
|
||||
// iOS-style commit gesture the mockups imply.
|
||||
const SWIPE_COMMIT_THRESHOLD = 200;
|
||||
|
||||
// List action menu
|
||||
let showMenu = $state(false);
|
||||
@@ -291,10 +298,23 @@
|
||||
// ── Delete ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
// Optimistic local removal; undoQueue decides whether to persist.
|
||||
swipeOffsets[id] = 0;
|
||||
openSwipeId = null;
|
||||
const snapshot = { ...item };
|
||||
items = items.filter((i) => i.id !== id);
|
||||
await deleteItem(id);
|
||||
scheduleUndoable({
|
||||
label: m.undo_deleted_item({ name: snapshot.name }),
|
||||
restore: () => {
|
||||
// Re-insert at original sort order (stable-ish against concurrent inserts).
|
||||
items = [...items, snapshot].sort((a, b) => a.sort_order - b.sort_order);
|
||||
},
|
||||
commit: async () => {
|
||||
await deleteItem(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Swipe to reveal delete (touch) ─────────────────────────────────────────
|
||||
@@ -322,7 +342,9 @@
|
||||
swipeHorizontal = true;
|
||||
}
|
||||
|
||||
swipeOffsets[id] = Math.max(-SWIPE_MAX, Math.min(0, dx));
|
||||
// Allow dragging past SWIPE_MAX (with rubber-banding feel) so the user
|
||||
// can commit a full-swipe delete.
|
||||
swipeOffsets[id] = Math.min(0, dx);
|
||||
}
|
||||
|
||||
function onSwipeEnd(e: PointerEvent, id: string) {
|
||||
@@ -330,7 +352,10 @@
|
||||
if (!swipeHorizontal) return;
|
||||
|
||||
const offset = swipeOffsets[id] ?? 0;
|
||||
if (offset <= -SWIPE_THRESHOLD) {
|
||||
if (offset <= -SWIPE_COMMIT_THRESHOLD) {
|
||||
// Full-swipe commit → delete with undo toast.
|
||||
void handleDelete(id);
|
||||
} else if (offset <= -SWIPE_THRESHOLD) {
|
||||
swipeOffsets[id] = -SWIPE_MAX;
|
||||
openSwipeId = id;
|
||||
} else {
|
||||
@@ -455,7 +480,7 @@
|
||||
<button
|
||||
onclick={() => handleDelete(item.id)}
|
||||
class="flex h-full w-full items-center justify-center text-white"
|
||||
aria-label={m.action_delete()}
|
||||
aria-label={m.list_item_delete_aria()}
|
||||
>
|
||||
<Trash2 size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { notes, notesLoading, loadNotes, createNote } from '$lib/stores/notes';
|
||||
import { FileText, Plus, Archive, Trash2 } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import ScreenMasthead from '$lib/components/ScreenMasthead.svelte';
|
||||
|
||||
$effect(() => {
|
||||
if ($currentCollective) loadNotes($currentCollective.id);
|
||||
@@ -55,37 +56,36 @@
|
||||
<svelte:head><title>{m.notes_title()} — Colectivo</title></svelte:head>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
|
||||
<FileText size={18} strokeWidth={1.5} />
|
||||
<h1 class="text-base font-semibold text-text-primary">{m.notes_title()}</h1>
|
||||
<div class="flex-1"></div>
|
||||
<a
|
||||
href="/notes/archive"
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label={m.notes_archive_view()}
|
||||
title={m.notes_archive_view()}
|
||||
>
|
||||
<Archive size={16} strokeWidth={1.5} />
|
||||
</a>
|
||||
<a
|
||||
href="/notes/trash"
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label={m.notes_trash_view()}
|
||||
title={m.notes_trash_view()}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.5} />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCreate}
|
||||
class="ml-2 inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} />
|
||||
{m.notes_new()}
|
||||
</button>
|
||||
</header>
|
||||
<ScreenMasthead label={m.masthead_notes_label()} title={m.notes_title()}>
|
||||
{#snippet actions()}
|
||||
<a
|
||||
href="/notes/archive"
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label={m.notes_archive_view()}
|
||||
title={m.notes_archive_view()}
|
||||
>
|
||||
<Archive size={16} strokeWidth={1.5} />
|
||||
</a>
|
||||
<a
|
||||
href="/notes/trash"
|
||||
class="rounded p-1.5 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label={m.notes_trash_view()}
|
||||
title={m.notes_trash_view()}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.5} />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCreate}
|
||||
class="ml-1 inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} />
|
||||
{m.notes_new()}
|
||||
</button>
|
||||
{/snippet}
|
||||
</ScreenMasthead>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4 md:px-6 md:py-6">
|
||||
{#if $notesLoading && $notes.length === 0}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else if $notes.length === 0}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { runSearch, type SearchRow, type SearchResultType } from '$lib/stores/search';
|
||||
import { Search, FileText, CheckSquare, ShoppingCart } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import ScreenMasthead from '$lib/components/ScreenMasthead.svelte';
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<SearchRow[]>([]);
|
||||
@@ -61,12 +62,9 @@
|
||||
<svelte:head><title>{m.search_title()} — Colectivo</title></svelte:head>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<header class="flex flex-col gap-3 border-b border-black/5 px-6 py-4 dark:border-white/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Search size={18} strokeWidth={1.5} />
|
||||
<h1 class="text-base font-semibold text-text-primary">{m.search_title()}</h1>
|
||||
</div>
|
||||
<ScreenMasthead label={m.masthead_search_label()} title={m.masthead_search_title()} />
|
||||
|
||||
<div class="flex flex-col gap-3 px-4 pb-3 md:px-6">
|
||||
<div class="relative">
|
||||
<Search size={14} strokeWidth={1.5} class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
|
||||
<input
|
||||
@@ -115,9 +113,9 @@
|
||||
{m.search_filter_notes()}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4 md:px-6">
|
||||
{#if query.trim().length < 2}
|
||||
<p class="text-sm text-text-secondary">{m.search_hint()}</p>
|
||||
{:else if loading}
|
||||
@@ -130,6 +128,9 @@
|
||||
<h2 class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
<ShoppingCart size={12} strokeWidth={1.5} />
|
||||
{m.search_group_shopping_items()}
|
||||
<span class="ml-auto rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
{grouped.shopping_item.length} {grouped.shopping_item.length === 1 ? 'MATCH' : 'MATCHES'}
|
||||
</span>
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
{#each grouped.shopping_item as r (r.id)}
|
||||
@@ -153,6 +154,9 @@
|
||||
<h2 class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
<CheckSquare size={12} strokeWidth={1.5} />
|
||||
{m.search_group_tasks()}
|
||||
<span class="ml-auto rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
{grouped.task.length} {grouped.task.length === 1 ? 'MATCH' : 'MATCHES'}
|
||||
</span>
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
{#each grouped.task as r (r.id)}
|
||||
@@ -173,6 +177,9 @@
|
||||
<h2 class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
||||
<FileText size={12} strokeWidth={1.5} />
|
||||
{m.search_group_notes()}
|
||||
<span class="ml-auto rounded-full bg-slate-200 px-2 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
{grouped.note.length} {grouped.note.length === 1 ? 'MATCH' : 'MATCHES'}
|
||||
</span>
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
{#each grouped.note as r (r.id)}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
} from '$lib/stores/tasks';
|
||||
import { CheckSquare, Plus, Trash2 } from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import ScreenMasthead from '$lib/components/ScreenMasthead.svelte';
|
||||
|
||||
let newName = $state('');
|
||||
let creating = $state(false);
|
||||
@@ -53,12 +54,9 @@
|
||||
<svelte:head><title>{m.tasks_title()} — Colectivo</title></svelte:head>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<header class="flex h-14 items-center gap-2 border-b border-black/5 px-6 dark:border-white/5">
|
||||
<CheckSquare size={18} strokeWidth={1.5} />
|
||||
<h1 class="text-base font-semibold text-text-primary">{m.tasks_title()}</h1>
|
||||
</header>
|
||||
<ScreenMasthead label={m.masthead_tasks_label()} title={m.tasks_title()} />
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4 md:px-6 md:py-6">
|
||||
{#if $taskListsLoading && $taskLists.length === 0}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else if $taskLists.length === 0}
|
||||
|
||||
Reference in New Issue
Block a user