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

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