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:
Till JS 2026-04-01 21:31:04 +02:00
parent bd67e8d20b
commit c81b636f2f
9 changed files with 809 additions and 87 deletions

View file

@ -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;

View file

@ -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;
},
};
}

View file

@ -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>

View file

@ -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;

View 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>

View 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>

View 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>

View 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';

View 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;
}