mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(todo/web): add page maximize/minimize/close controls and default todo page
- Show "To Do" page by default on app load - Add fullscreen (maximize) button to TodoPage with centered content - Consistent button order across all states: Minimize → Fullscreen → Close - Add maximize/restore/close buttons to minimized page tabs - Use ArrowLineUp icon for restore in tabs to differentiate from minimize - Larger tap targets for tab action buttons (24px) - Centered empty state when no pages are open with "Seite hinzufügen" button - Wire maximize through MinimizedPagesContext for tab-to-fullscreen flow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bd67e8d20b
commit
c81b636f2f
9 changed files with 809 additions and 87 deletions
|
|
@ -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 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="todo-page" style="width: {sheetWidth}">
|
||||
<div class="todo-page" class:maximized style="width: {maximized ? '100%' : sheetWidth}">
|
||||
<div class="drag-handle-bar">
|
||||
<span class="drag-handle">
|
||||
<DotsSixVertical size={14} />
|
||||
|
|
@ -204,6 +214,19 @@
|
|||
<Minus size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if onMaximize}
|
||||
<button
|
||||
class="header-btn"
|
||||
onclick={onMaximize}
|
||||
title={maximized ? 'Verkleinern' : 'Maximieren'}
|
||||
>
|
||||
{#if maximized}
|
||||
<CornersIn size={14} />
|
||||
{:else}
|
||||
<CornersOut size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="header-btn" onclick={onClose} title="Seite schließen">
|
||||
<X size={14} />
|
||||
</button>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, { title: string; color: string }> = {
|
|||
};
|
||||
|
||||
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<MinimizedPage[]>([]);
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<BottomNotification[]>([]);
|
||||
let guestNudgeInterval: ReturnType<typeof setInterval> | 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}
|
||||
<!-- BottomStack: computes offsets for the entire bottom bar stack -->
|
||||
<BottomStack
|
||||
pillNavVisible={true}
|
||||
tagStripVisible={isFilterStripVisible}
|
||||
pillNavVisible={isBottomBarsVisible}
|
||||
tagStripVisible={isBottomBarsVisible && isFilterStripVisible}
|
||||
bind:pillNavOffset
|
||||
bind:tagStripOffset
|
||||
bind:fabOffset
|
||||
>
|
||||
<MinimizedTabs
|
||||
pages={minimizedPages.pages}
|
||||
|
|
@ -427,6 +487,9 @@
|
|||
onMaximize={(id) => minimizedPages.maximize(id)}
|
||||
onAdd={() => minimizedPages.togglePicker()}
|
||||
/>
|
||||
{#snippet top()}
|
||||
<NotificationBar notifications={bottomNotifications} />
|
||||
{/snippet}
|
||||
</BottomStack>
|
||||
|
||||
<!-- 1. QuickInputBar (always at bottom) -->
|
||||
|
|
@ -444,52 +507,71 @@
|
|||
deferSearch={true}
|
||||
locale={$locale || 'de'}
|
||||
appIcon="todo"
|
||||
hasFabRight={true}
|
||||
bottomOffset="16px"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- 2. PillNav (above InputBar, always visible) -->
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Todo"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#8b5cf6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
themesHref="/themes"
|
||||
spiralHref="/spiral"
|
||||
helpHref="/help"
|
||||
onOpenInPanel={handleOpenInPanel}
|
||||
ariaLabel="Hauptnavigation"
|
||||
{spotlightActions}
|
||||
bottomOffset={pillNavOffset}
|
||||
/>
|
||||
<!-- 2. PillNav + TagStrip (toggled by FAB) -->
|
||||
{#if isBottomBarsVisible}
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Todo"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#8b5cf6"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
themesHref="/themes"
|
||||
spiralHref="/spiral"
|
||||
helpHref="/help"
|
||||
onOpenInPanel={handleOpenInPanel}
|
||||
ariaLabel="Hauptnavigation"
|
||||
{spotlightActions}
|
||||
bottomOffset={pillNavOffset}
|
||||
/>
|
||||
|
||||
<!-- 3. TagStrip (above PillNav) -->
|
||||
{#if isFilterStripVisible}
|
||||
<TagStrip bottomOffset={tagStripOffset} />
|
||||
<!-- 3. TagStrip (above PillNav) -->
|
||||
{#if isFilterStripVisible}
|
||||
<TagStrip bottomOffset={tagStripOffset} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- FAB to toggle bottom bars -->
|
||||
<button
|
||||
class="pillnav-fab"
|
||||
style="--fab-bottom: {fabOffset}"
|
||||
onclick={handleBottomBarsToggle}
|
||||
title={isBottomBarsVisible ? 'Navigation ausblenden' : 'Navigation anzeigen'}
|
||||
aria-label={isBottomBarsVisible ? 'Navigation ausblenden' : 'Navigation anzeigen'}
|
||||
data-umami-event="bottom-bars-toggle"
|
||||
>
|
||||
{#if isBottomBarsVisible}
|
||||
<X size={20} weight="bold" />
|
||||
{:else}
|
||||
<List size={20} weight="bold" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- DnD: floating preview + action zones -->
|
||||
<DragPreview />
|
||||
<ActionZone
|
||||
|
|
@ -543,13 +625,6 @@
|
|||
|
||||
{#if authStore.isAuthenticated}
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
{:else}
|
||||
<GuestRegistrationNudge
|
||||
appId="todo"
|
||||
onRegister={() => goto('/register')}
|
||||
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
||||
delayMinutes={3}
|
||||
/>
|
||||
{/if}
|
||||
</AuthGate>
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
|
||||
|
|
@ -193,15 +187,37 @@
|
|||
<TodoPage
|
||||
pageId={page.id}
|
||||
title={page.customTitle}
|
||||
maximized={page.maximized}
|
||||
onClose={() => handleRemovePage(page.id)}
|
||||
onMinimize={() => handleMinimizePage(page.id)}
|
||||
onMaximize={() => handleMaximizePage(page.id)}
|
||||
onRename={(name) => handleRenamePage(page.id, name)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Page picker -->
|
||||
{#if showPagePicker}
|
||||
<!-- Page picker / add button -->
|
||||
{#if expandedPages.length === 0}
|
||||
<!-- Wrapper matches sheet width so fokus-track centering works -->
|
||||
<div class="empty-pages-wrapper">
|
||||
{#if showPagePicker}
|
||||
<PagePicker
|
||||
onSelect={handleAddPage}
|
||||
onClose={() => (showPagePicker = false)}
|
||||
activePageIds={openPages.map((p) => p.id)}
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="neue-seite-card alone"
|
||||
onclick={togglePagePicker}
|
||||
title="Neue Seite hinzufügen"
|
||||
>
|
||||
<Plus size={24} />
|
||||
<span class="neue-seite-label">Seite hinzufügen</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if showPagePicker}
|
||||
<div bind:this={pagePickerEl}>
|
||||
<PagePicker
|
||||
onSelect={handleAddPage}
|
||||
|
|
@ -243,8 +259,10 @@
|
|||
width: 48px;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
border: 2px dashed rgba(0, 0, 0, 0.08);
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
|
|
@ -252,6 +270,21 @@
|
|||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.empty-pages-wrapper {
|
||||
flex: 0 0 auto;
|
||||
/* Match the sheet width from fokus-track so centering padding works */
|
||||
width: var(--sheet-width, min(480px, 85vw));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.neue-seite-card.alone {
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.neue-seite-card:hover {
|
||||
border-color: var(--color-primary, #8b5cf6);
|
||||
color: var(--color-primary, #8b5cf6);
|
||||
|
|
@ -261,12 +294,22 @@
|
|||
border-color: rgba(255, 255, 255, 0.06);
|
||||
color: #4b5563;
|
||||
}
|
||||
:global(.dark) .neue-seite-card.alone {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: #6b7280;
|
||||
}
|
||||
:global(.dark) .neue-seite-card:hover {
|
||||
border-color: var(--color-primary, #8b5cf6);
|
||||
color: var(--color-primary, #8b5cf6);
|
||||
background: color-mix(in srgb, var(--color-primary, #8b5cf6) 8%, transparent);
|
||||
}
|
||||
|
||||
.neue-seite-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
121
packages/shared-ui/src/bottom-stack/BottomStack.svelte
Normal file
121
packages/shared-ui/src/bottom-stack/BottomStack.svelte
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* 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`;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if children}
|
||||
<div
|
||||
class="bottom-stack-layer"
|
||||
style="bottom: calc({aboveFixedBars}px + env(safe-area-inset-bottom, 0px))"
|
||||
bind:clientHeight={middleHeight}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if top}
|
||||
<div
|
||||
class="bottom-stack-layer"
|
||||
style="bottom: calc({topBottom}px + env(safe-area-inset-bottom, 0px))"
|
||||
bind:clientHeight={topHeight}
|
||||
>
|
||||
{@render top()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bottom-stack-layer {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
174
packages/shared-ui/src/bottom-stack/MinimizedTabs.svelte
Normal file
174
packages/shared-ui/src/bottom-stack/MinimizedTabs.svelte
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<script lang="ts">
|
||||
import { X, Plus, CornersOut, ArrowLineUp } from '@manacore/shared-icons';
|
||||
import type { MinimizedPage } from './types';
|
||||
|
||||
interface Props {
|
||||
pages: MinimizedPage[];
|
||||
onRestore: (pageId: string) => void;
|
||||
onRemove: (pageId: string) => void;
|
||||
onMaximize?: (pageId: string) => void;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
let { pages, onRestore, onRemove, onMaximize, onAdd }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if pages.length > 0}
|
||||
<div class="minimized-tabs">
|
||||
{#each pages as pg (pg.id)}
|
||||
<div
|
||||
class="minimized-tab"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onRestore(pg.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onRestore(pg.id)}
|
||||
>
|
||||
<span class="minimized-tab-dot" style="background-color: {pg.color}"></span>
|
||||
<span class="minimized-tab-title">{pg.title}</span>
|
||||
<div class="minimized-tab-actions">
|
||||
<button
|
||||
class="minimized-tab-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRestore(pg.id);
|
||||
}}
|
||||
title="Wiederherstellen"
|
||||
>
|
||||
<ArrowLineUp size={12} />
|
||||
</button>
|
||||
{#if onMaximize}
|
||||
<button
|
||||
class="minimized-tab-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMaximize(pg.id);
|
||||
}}
|
||||
title="Maximieren"
|
||||
>
|
||||
<CornersOut size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="minimized-tab-btn"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(pg.id);
|
||||
}}
|
||||
title="Schließen"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="minimized-tab-add" onclick={onAdd} title="Neue Seite hinzufügen">
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.minimized-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--color-surface-elevated, #fffef5);
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
width: fit-content;
|
||||
}
|
||||
:global(.dark) .minimized-tabs {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.minimized-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.minimized-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.minimized-tab:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
:global(.dark) .minimized-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.minimized-tab-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.minimized-tab-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
}
|
||||
|
||||
.minimized-tab-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.minimized-tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground, #d1d5db);
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: all 0.15s;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.minimized-tab-btn:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
:global(.dark) .minimized-tab-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.minimized-tab-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 0.3rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground, #9ca3af);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.minimized-tab-add:hover {
|
||||
opacity: 1;
|
||||
color: var(--color-primary, #8b5cf6);
|
||||
}
|
||||
</style>
|
||||
149
packages/shared-ui/src/bottom-stack/NotificationBar.svelte
Normal file
149
packages/shared-ui/src/bottom-stack/NotificationBar.svelte
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<script lang="ts">
|
||||
import { X, ArrowRight } from '@manacore/shared-icons';
|
||||
import type { BottomNotification } from './types';
|
||||
|
||||
interface Props {
|
||||
notifications: BottomNotification[];
|
||||
}
|
||||
|
||||
let { notifications }: Props = $props();
|
||||
|
||||
// Show highest priority notification (error > warning > info)
|
||||
const PRIORITY: Record<string, number> = { error: 3, warning: 2, info: 1 };
|
||||
let active = $derived(
|
||||
notifications.length > 0
|
||||
? [...notifications].sort((a, b) => (PRIORITY[b.type] ?? 0) - (PRIORITY[a.type] ?? 0))[0]
|
||||
: null
|
||||
);
|
||||
|
||||
function handleDismiss() {
|
||||
if (active?.onDismiss) active.onDismiss();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if active}
|
||||
<div
|
||||
class="notification-bar"
|
||||
class:warning={active.type === 'warning'}
|
||||
class:error={active.type === 'error'}
|
||||
>
|
||||
<p class="notification-message">{active.message}</p>
|
||||
<div class="notification-actions">
|
||||
{#if active.action}
|
||||
<button class="notification-action" onclick={active.action.onClick}>
|
||||
{#if active.action.icon}
|
||||
<svelte:component this={active.action.icon} size={14} weight="bold" />
|
||||
{/if}
|
||||
{active.action.label}
|
||||
<ArrowRight size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if active.dismissible !== false}
|
||||
<button class="notification-dismiss" onclick={handleDismiss} aria-label="Schließen">
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.notification-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0.625rem 0.5rem 0.875rem;
|
||||
background: var(--color-surface-elevated, rgba(255, 255, 255, 0.95));
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
backdrop-filter: blur(12px);
|
||||
max-width: 480px;
|
||||
width: max-content;
|
||||
animation: slideUp 250ms ease-out;
|
||||
}
|
||||
:global(.dark) .notification-bar {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.notification-bar.warning {
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.notification-bar.error {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-muted-foreground, rgba(0, 0, 0, 0.65));
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: var(--color-primary, #7c3aed);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.notification-action:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.notification-dismiss {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
color: var(--color-muted-foreground, rgba(0, 0, 0, 0.35));
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.notification-dismiss:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: var(--color-foreground, rgba(0, 0, 0, 0.7));
|
||||
}
|
||||
:global(.dark) .notification-dismiss:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.notification-bar {
|
||||
max-width: calc(100vw - 2rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
4
packages/shared-ui/src/bottom-stack/index.ts
Normal file
4
packages/shared-ui/src/bottom-stack/index.ts
Normal file
|
|
@ -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';
|
||||
20
packages/shared-ui/src/bottom-stack/types.ts
Normal file
20
packages/shared-ui/src/bottom-stack/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue