diff --git a/apps/todo/apps/web/src/lib/components/pages/TodoPage.svelte b/apps/todo/apps/web/src/lib/components/pages/TodoPage.svelte index 61f2d30ae..8c1e86868 100644 --- a/apps/todo/apps/web/src/lib/components/pages/TodoPage.svelte +++ b/apps/todo/apps/web/src/lib/components/pages/TodoPage.svelte @@ -3,7 +3,7 @@ import { isToday, isPast, startOfDay, addDays, subHours, format } from 'date-fns'; import { t } from 'svelte-i18n'; import type { Task } from '@todo/shared'; - import { X, Circle, Minus, DotsSixVertical } from '@manacore/shared-icons'; + import { X, Circle, Minus, DotsSixVertical, CornersOut, CornersIn } from '@manacore/shared-icons'; import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte'; import { tasksStore } from '$lib/stores/tasks.svelte'; import { todoSettings } from '$lib/stores/settings.svelte'; @@ -11,12 +11,22 @@ interface Props { pageId: string; title?: string; + maximized?: boolean; onClose: () => void; onMinimize?: () => void; + onMaximize?: () => void; onRename?: (name: string) => void; } - let { pageId, title: customTitle, onClose, onMinimize, onRename }: Props = $props(); + let { + pageId, + title: customTitle, + maximized = false, + onClose, + onMinimize, + onMaximize, + onRename, + }: Props = $props(); const tasksCtx: { readonly value: Task[] } = getContext('tasks'); @@ -175,7 +185,7 @@ } -
+
@@ -204,6 +214,19 @@ {/if} + {#if onMaximize} + + {/if} @@ -295,6 +318,42 @@ 0 0 0 1px rgba(255, 255, 255, 0.06); } + .todo-page.maximized { + position: fixed; + inset: 0; + z-index: 50; + width: 100% !important; + min-height: 100vh; + border-radius: 0; + box-shadow: none; + animation: fadeInScale 0.2s ease-out; + align-items: center; + } + + .todo-page.maximized .page-header, + .todo-page.maximized .page-body { + width: 100%; + max-width: 720px; + } + + .todo-page.maximized .page-header { + margin: 0 auto; + } + + .todo-page.maximized .page-body { + margin: 0 auto; + } + @keyframes fadeInScale { + from { + opacity: 0.8; + transform: scale(0.97); + } + to { + opacity: 1; + transform: scale(1); + } + } + .drag-handle-bar { display: flex; justify-content: center; diff --git a/apps/todo/apps/web/src/lib/stores/minimized-pages.svelte.ts b/apps/todo/apps/web/src/lib/stores/minimized-pages.svelte.ts index 2ff6c1e47..387ac1fed 100644 --- a/apps/todo/apps/web/src/lib/stores/minimized-pages.svelte.ts +++ b/apps/todo/apps/web/src/lib/stores/minimized-pages.svelte.ts @@ -1,8 +1,8 @@ /** - * Minimized pages context — layout owns the state, page reads/writes via context. + * Minimized pages context — shared between layout and page via Svelte context. * - * Layout calls `createMinimizedPagesContext()` + `setContext`. - * Page calls `getContext('minimizedPages')` to get the same object. + * Layout creates the context, renders MinimizedTabs using its reactive state. + * Page registers callbacks (restore/remove/togglePicker) and syncs its openPages. */ import type { MinimizedPage } from '@manacore/shared-ui'; @@ -18,16 +18,39 @@ export const PAGE_META: Record = { }; export interface MinimizedPagesContext { + /** Reactive list of minimized pages (read by layout for rendering) */ readonly pages: MinimizedPage[]; + /** Whether there are any minimized pages */ readonly hasPages: boolean; - /** Called by page to sync its openPages state */ + /** Sync open pages state from page component */ sync(openPages: { id: string; minimized: boolean }[]): void; - /** Called by page on unmount */ + /** Clear all pages (called on page unmount) */ clear(): void; + /** Restore a minimized page — delegates to registered callback */ + restore(pageId: string): void; + /** Remove a page — delegates to registered callback */ + remove(pageId: string): void; + /** Maximize a minimized page — delegates to registered callback */ + maximize(pageId: string): void; + /** Toggle page picker — delegates to registered callback */ + togglePicker(): void; + /** Page registers its handlers here */ + registerHandlers(handlers: { + restore: (id: string) => void; + remove: (id: string) => void; + maximize: (id: string) => void; + togglePicker: () => void; + }): void; } export function createMinimizedPagesContext(): MinimizedPagesContext { let pages = $state([]); + let handlers = $state<{ + restore: (id: string) => void; + remove: (id: string) => void; + maximize: (id: string) => void; + togglePicker: () => void; + } | null>(null); return { get pages() { @@ -36,7 +59,7 @@ export function createMinimizedPagesContext(): MinimizedPagesContext { get hasPages() { return pages.length > 0; }, - sync(openPages: { id: string; minimized: boolean }[]) { + sync(openPages) { pages = openPages .filter((p) => p.minimized) .map((p) => { @@ -46,6 +69,22 @@ export function createMinimizedPagesContext(): MinimizedPagesContext { }, clear() { pages = []; + handlers = null; + }, + restore(pageId) { + handlers?.restore(pageId); + }, + remove(pageId) { + handlers?.remove(pageId); + }, + maximize(pageId) { + handlers?.maximize(pageId); + }, + togglePicker() { + handlers?.togglePicker(); + }, + registerHandlers(h) { + handlers = h; }, }; } diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte index 950e7f6e6..d3f5cfd05 100644 --- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte @@ -9,7 +9,9 @@ ImmersiveModeToggle, BottomStack, MinimizedTabs, + NotificationBar, } from '@manacore/shared-ui'; + import type { BottomNotification } from '@manacore/shared-ui'; import { SplitPaneContainer, setSplitPanelContext, @@ -52,14 +54,18 @@ SessionExpiredBanner, AuthGate, GuestWelcomeModal, - GuestRegistrationNudge, + startGuestSession, + shouldShowGuestNudge, + dismissGuestNudge, } from '@manacore/shared-auth-ui'; import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui'; + import { UserPlus } from '@manacore/shared-icons'; import { TodoEvents } from '@manacore/shared-utils/analytics'; import { todoStore, taskCollection } from '$lib/data/local-store'; import type { LocalBoardView } from '$lib/data/local-store'; import { useAllTasks, useAllBoardViews } from '$lib/data/task-queries'; import SyncIndicator from '$lib/components/SyncIndicator.svelte'; + import { List, X } from '@manacore/shared-icons'; import { createMinimizedPagesContext } from '$lib/stores/minimized-pages.svelte'; // Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs) @@ -119,6 +125,48 @@ }, }); + // ── Bottom notifications ──────────────────────────────── + let bottomNotifications = $state([]); + let guestNudgeInterval: ReturnType | null = null; + + function initGuestNudge() { + if (authStore.isAuthenticated) return; + startGuestSession('todo'); + + function checkNudge() { + if (shouldShowGuestNudge('todo', 3)) { + bottomNotifications = [ + { + id: 'guest-nudge', + message: + ($locale || 'de') === 'de' + ? 'Gefällt es dir? Sichere deine Daten geräteübergreifend.' + : 'Like what you see? Save your data across devices.', + type: 'info' as const, + action: { + label: ($locale || 'de') === 'de' ? 'Konto erstellen' : 'Create account', + icon: UserPlus, + onClick: () => { + dismissGuestNudge('todo'); + bottomNotifications = []; + goto('/register'); + }, + }, + dismissible: true, + onDismiss: () => { + dismissGuestNudge('todo'); + bottomNotifications = bottomNotifications.filter((n) => n.id !== 'guest-nudge'); + }, + }, + ]; + if (guestNudgeInterval) clearInterval(guestNudgeInterval); + } + } + + checkNudge(); + guestNudgeInterval = setInterval(checkNudge, 30_000); + } + // Guest welcome modal state let showGuestWelcome = $state(false); @@ -200,12 +248,16 @@ } } + // Bottom bar visibility (controlled by FAB) + let isBottomBarsVisible = $state(true); + // FilterStrip visibility (toggle via Filter button in PillNav) let isFilterStripVisible = $derived(!todoSettings.filterStripCollapsed); // BottomStack computes these offsets automatically let pillNavOffset = $state('0px'); let tagStripOffset = $state('72px'); + let fabOffset = $state('20px'); // Use theme store's isDark directly let isDark = $derived(theme.isDark); @@ -314,6 +366,10 @@ } } + function handleBottomBarsToggle() { + isBottomBarsVisible = !isBottomBarsVisible; + } + function handleToggleTheme() { theme.toggleMode(); } @@ -377,6 +433,9 @@ // Show guest welcome modal on first visit initGuestWelcome(); + // Start guest registration nudge timer + initGuestNudge(); + // Tags and projects are now loaded reactively via useLiveQuery — no fetch needed // User settings need auth @@ -415,10 +474,11 @@ {#if !todoSettings.immersiveModeEnabled} minimizedPages.maximize(id)} onAdd={() => minimizedPages.togglePicker()} /> + {#snippet top()} + + {/snippet} @@ -444,52 +507,71 @@ deferSearch={true} locale={$locale || 'de'} appIcon="todo" + hasFabRight={true} bottomOffset="16px" /> {/if} - - + + {#if isBottomBarsVisible} + - - {#if isFilterStripVisible} - + + {#if isFilterStripVisible} + + {/if} {/if} + + + - {:else} - goto('/register')} - locale={($locale || 'de') === 'de' ? 'de' : 'en'} - delayMinutes={3} - /> {/if} @@ -620,4 +695,42 @@ padding-bottom: calc(100px + env(safe-area-inset-bottom)); } } + + /* FAB to toggle bottom bars — next to QuickInputBar */ + .pillnav-fab { + position: fixed; + bottom: calc(var(--fab-bottom, 20px) + env(safe-area-inset-bottom, 0px)); + left: calc(50% + 350px + 0.75rem); + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--color-surface-elevated-2); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-xl); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 1002; + transition: all 0.2s ease; + } + + @media (max-width: 900px) { + .pillnav-fab { + left: auto; + right: 1rem; + } + } + + .pillnav-fab:hover { + transform: scale(1.05); + } + + .pillnav-fab:active { + transform: scale(0.95); + } + + .pillnav-fab :global(svg) { + color: var(--color-foreground); + } diff --git a/apps/todo/apps/web/src/routes/(app)/+page.svelte b/apps/todo/apps/web/src/routes/(app)/+page.svelte index c02e44d95..c6b1d68ed 100644 --- a/apps/todo/apps/web/src/routes/(app)/+page.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+page.svelte @@ -6,50 +6,38 @@ import { Plus } from '@manacore/shared-icons'; import PagePicker from '$lib/components/pages/PagePicker.svelte'; import TodoPage from '$lib/components/pages/TodoPage.svelte'; - import { minimizedPagesStore } from '$lib/stores/minimized-pages.svelte'; + import type { MinimizedPagesContext } from '$lib/stores/minimized-pages.svelte'; // Get active view from layout context const activeViewCtx: { readonly value: LocalBoardView | null } = getContext('activeView'); + const minimizedPages: MinimizedPagesContext = getContext('minimizedPages'); let activeView = $derived(activeViewCtx.value); let pageTitle = $derived(activeView?.name ?? 'Aufgaben'); // ── Pages ─────────────────────────────────────────────── let showPagePicker = $state(false); - let openPages = $state<{ id: string; minimized: boolean; customTitle?: string }[]>([ - { id: 'todo', minimized: false }, - ]); + let openPages = $state< + { id: string; minimized: boolean; maximized?: boolean; customTitle?: string }[] + >([{ id: 'todo', minimized: false }]); let expandedPages = $derived(openPages.filter((p) => !p.minimized)); - // Sync minimized pages to shared store so layout can render tabs + // Sync minimized pages to layout via context $effect(() => { - minimizedPagesStore.set(openPages); + minimizedPages.sync(openPages); }); - onDestroy(() => minimizedPagesStore.clear()); - // Listen for events from layout's minimized tab bar - function onRestorePage(e: Event) { - handleRestorePage((e as CustomEvent).detail); - } - function onRemovePage(e: Event) { - handleRemovePage((e as CustomEvent).detail); - } - function onTogglePagePicker() { - togglePagePicker(); - } - - $effect(() => { - window.addEventListener('restore-page', onRestorePage); - window.addEventListener('remove-page', onRemovePage); - window.addEventListener('toggle-page-picker', onTogglePagePicker); - return () => { - window.removeEventListener('restore-page', onRestorePage); - window.removeEventListener('remove-page', onRemovePage); - window.removeEventListener('toggle-page-picker', onTogglePagePicker); - }; + // Register handlers so layout can delegate tab actions back to us + minimizedPages.registerHandlers({ + restore: (id) => handleRestorePage(id), + remove: (id) => handleRemovePage(id), + maximize: (id) => handleMaximizePage(id), + togglePicker: () => togglePagePicker(), }); + onDestroy(() => minimizedPages.clear()); + function handleAddPage(pageId: string) { if (!openPages.some((p) => p.id === pageId)) { openPages = [...openPages, { id: pageId, minimized: false }]; @@ -75,6 +63,12 @@ openPages = openPages.map((p) => (p.id === pageId ? { ...p, customTitle: name } : p)); } + function handleMaximizePage(pageId: string) { + openPages = openPages.map((p) => + p.id === pageId ? { ...p, maximized: !p.maximized, minimized: false } : p + ); + } + // ── Page drag reorder ─────────────────────────────────── let dragPageId = $state(null); @@ -193,15 +187,37 @@ handleRemovePage(page.id)} onMinimize={() => handleMinimizePage(page.id)} + onMaximize={() => handleMaximizePage(page.id)} onRename={(name) => handleRenamePage(page.id, name)} />
{/each} - - {#if showPagePicker} + + {#if expandedPages.length === 0} + +
+ {#if showPagePicker} + (showPagePicker = false)} + activePageIds={openPages.map((p) => p.id)} + /> + {:else} + + {/if} +
+ {:else if showPagePicker}
+ /** + * BottomStack — offset coordinator for the fixed bottom bar stack. + * + * Stack order (bottom → top): + * QuickInputBar (always at bottom, fixed offset) + * → PillNav (above input bar) + * → TagStrip (above PillNav) + * → children (e.g. MinimizedTabs) + * → top (e.g. NotificationBar) + * + * Computes and exposes offsets for each layer so apps don't + * need manual pixel arithmetic. Renders "middle" and "top" + * content at the correct positions. + */ + import type { Snippet } from 'svelte'; + + interface Props { + /** Height of the QuickInputBar in px (default: 72) */ + inputBarHeight?: number; + /** Is PillNav currently visible? */ + pillNavVisible?: boolean; + /** Height of PillNav in px (default: 68) */ + pillNavHeight?: number; + /** Is TagStrip currently visible? */ + tagStripVisible?: boolean; + /** Height of TagStrip in px (default: 50) */ + tagStripHeight?: number; + /** Content rendered above TagStrip (e.g. MinimizedTabs) */ + children?: Snippet; + /** Content rendered at the very top (e.g. NotificationBar) */ + top?: Snippet; + /** Computed bottom offset for PillNav (bind this) */ + pillNavOffset?: string; + /** Computed bottom offset for TagStrip (bind this) */ + tagStripOffset?: string; + /** Computed bottom offset for FAB (bind this) */ + fabOffset?: string; + } + + let { + inputBarHeight = 72, + pillNavVisible = false, + pillNavHeight = 68, + tagStripVisible = false, + tagStripHeight = 50, + children, + top, + pillNavOffset = $bindable('0px'), + tagStripOffset = $bindable('72px'), + fabOffset = $bindable('20px'), + }: Props = $props(); + + const BASE = 16; + + // PillNav sits above the InputBar + let pillNavBottom = $derived(inputBarHeight); + + // TagStrip sits above PillNav (only when PillNav is visible) + let tagStripBottom = $derived(inputBarHeight + (pillNavVisible ? pillNavHeight : 0)); + + // Middle content sits above all fixed bars + let aboveFixedBars = $derived( + inputBarHeight + + (pillNavVisible ? pillNavHeight : 0) + + (pillNavVisible && tagStripVisible ? tagStripHeight : 0) + ); + + // Measure middle and top content heights + let middleHeight = $state(0); + let topHeight = $state(0); + + // Top content sits above middle + let topBottom = $derived(aboveFixedBars + middleHeight); + + // FAB should be above everything + let fabBottom = $derived(BASE + 4 + aboveFixedBars + middleHeight + topHeight); + + // Sync bindable outputs + $effect(() => { + pillNavOffset = `${pillNavBottom}px`; + }); + $effect(() => { + tagStripOffset = `${tagStripBottom}px`; + }); + $effect(() => { + fabOffset = `${fabBottom}px`; + }); + + +{#if children} +
+ {@render children()} +
+{/if} + +{#if top} +
+ {@render top()} +
+{/if} + + diff --git a/packages/shared-ui/src/bottom-stack/MinimizedTabs.svelte b/packages/shared-ui/src/bottom-stack/MinimizedTabs.svelte new file mode 100644 index 000000000..f17e4621a --- /dev/null +++ b/packages/shared-ui/src/bottom-stack/MinimizedTabs.svelte @@ -0,0 +1,174 @@ + + +{#if pages.length > 0} +
+ {#each pages as pg (pg.id)} +
onRestore(pg.id)} + onkeydown={(e) => e.key === 'Enter' && onRestore(pg.id)} + > + + {pg.title} +
+ + {#if onMaximize} + + {/if} + +
+
+ {/each} + +
+{/if} + + diff --git a/packages/shared-ui/src/bottom-stack/NotificationBar.svelte b/packages/shared-ui/src/bottom-stack/NotificationBar.svelte new file mode 100644 index 000000000..3926b26fb --- /dev/null +++ b/packages/shared-ui/src/bottom-stack/NotificationBar.svelte @@ -0,0 +1,149 @@ + + +{#if active} +
+

{active.message}

+
+ {#if active.action} + + {/if} + {#if active.dismissible !== false} + + {/if} +
+
+{/if} + + diff --git a/packages/shared-ui/src/bottom-stack/index.ts b/packages/shared-ui/src/bottom-stack/index.ts new file mode 100644 index 000000000..b0b589f01 --- /dev/null +++ b/packages/shared-ui/src/bottom-stack/index.ts @@ -0,0 +1,4 @@ +export { default as BottomStack } from './BottomStack.svelte'; +export { default as MinimizedTabs } from './MinimizedTabs.svelte'; +export { default as NotificationBar } from './NotificationBar.svelte'; +export type { MinimizedPage, MinimizedTabsCallbacks, BottomNotification } from './types'; diff --git a/packages/shared-ui/src/bottom-stack/types.ts b/packages/shared-ui/src/bottom-stack/types.ts new file mode 100644 index 000000000..e14eb0e52 --- /dev/null +++ b/packages/shared-ui/src/bottom-stack/types.ts @@ -0,0 +1,20 @@ +export interface MinimizedPage { + id: string; + title: string; + color: string; +} + +export interface MinimizedTabsCallbacks { + restore: (pageId: string) => void; + remove: (pageId: string) => void; + add: () => void; +} + +export interface BottomNotification { + id: string; + message: string; + type: 'info' | 'warning' | 'error'; + action?: { label: string; icon?: any; onClick: () => void }; + dismissible?: boolean; + onDismiss?: () => void; +}