mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 05:59:39 +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
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