feat(todo/web): add secondary pages system with centered layout

Add "Neue Seite" button to open filtered task pages (Erledigt, Heute,
Überfällig, etc.) alongside the main board view. Sheets are centered
via carousel-style padding, and all pages are closeable with X buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 16:37:16 +02:00
parent d3807b4bea
commit 85257212af
5 changed files with 697 additions and 20 deletions

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getContext, type Snippet } from 'svelte';
import type { Task } from '@todo/shared';
import type { LocalBoardView } from '$lib/data/local-store';
import { groupTasksByView, getDropActionUpdate } from '$lib/data/view-grouping';
@ -15,7 +15,9 @@
onColumnColorChange?: (colIdx: number, color: string) => void;
onColumnMove?: (colIdx: number, dir: -1 | 1) => void;
onColumnDelete?: (colIdx: number) => void;
onColumnClose?: (colIdx: number) => void;
onAddColumn?: () => void;
trailing?: Snippet;
}
let {
@ -25,7 +27,9 @@
onColumnColorChange,
onColumnMove,
onColumnDelete,
onColumnClose,
onAddColumn,
trailing,
}: Props = $props();
let activeLayout = $derived(layoutOverride || view.layout);
@ -89,7 +93,9 @@
{onColumnColorChange}
{onColumnMove}
{onColumnDelete}
{onColumnClose}
{onAddColumn}
{trailing}
/>
{:else if activeLayout === 'grid'}
<GridLayout

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID, type DndEvent } from 'svelte-dnd-action';
import { getContext } from 'svelte';
import { getContext, type Snippet } from 'svelte';
import { isToday } from 'date-fns';
import type { Task } from '@todo/shared';
import type { GroupedColumn } from '$lib/data/view-grouping';
@ -9,6 +9,7 @@
import ViewColumnHeader from './ViewColumnHeader.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { todoSettings } from '$lib/stores/settings.svelte';
import { X } from '@manacore/shared-icons';
interface Props {
columns: GroupedColumn[];
@ -20,7 +21,9 @@
onColumnColorChange?: (colIdx: number, color: string) => void;
onColumnMove?: (colIdx: number, dir: -1 | 1) => void;
onColumnDelete?: (colIdx: number) => void;
onColumnClose?: (colIdx: number) => void;
onAddColumn?: () => void;
trailing?: Snippet;
}
let {
@ -33,7 +36,9 @@
onColumnColorChange,
onColumnMove,
onColumnDelete,
onColumnClose,
onAddColumn,
trailing,
}: Props = $props();
// Today's completed tasks — shown at the bottom of every sheet
@ -130,17 +135,28 @@
{#each columns as column, i (column.id)}
{@const tasks = localTasksByColumn[column.id] || column.tasks}
<div class="fokus-sheet" class:sheet-completed={column.name === 'Erledigt'}>
<ViewColumnHeader
name={column.name}
color={column.color}
taskCount={tasks.length}
columnIndex={i}
totalColumns={columns.length}
onRename={onColumnRename ? (name) => onColumnRename(i, name) : undefined}
onColorChange={onColumnColorChange ? (c) => onColumnColorChange(i, c) : undefined}
onMove={onColumnMove ? (dir) => onColumnMove(i, dir) : undefined}
onDelete={onColumnDelete ? () => onColumnDelete(i) : undefined}
/>
<div class="sheet-header-row">
<ViewColumnHeader
name={column.name}
color={column.color}
taskCount={tasks.length}
columnIndex={i}
totalColumns={columns.length}
onRename={onColumnRename ? (name) => onColumnRename(i, name) : undefined}
onColorChange={onColumnColorChange ? (c) => onColumnColorChange(i, c) : undefined}
onMove={onColumnMove ? (dir) => onColumnMove(i, dir) : undefined}
onDelete={onColumnDelete ? () => onColumnDelete(i) : undefined}
/>
{#if onColumnClose}
<button
class="sheet-close-btn"
onclick={() => onColumnClose(i)}
title="Seite schließen"
>
<X size={14} />
</button>
{/if}
</div>
<div class="sheet-body">
<div
@ -198,6 +214,11 @@
</button>
</div>
{/if}
<!-- Trailing content (Neue Seite, secondary pages) -->
{#if trailing}
{@render trailing()}
{/if}
</div>
<!-- Page dots -->
@ -220,8 +241,10 @@
gap: 1.5rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding: 1.5rem;
padding: 1rem 1.5rem 1rem;
/* Centering padding: pushes first sheet to viewport center.
Works like a carousel — padding is scrollable. */
padding: 1rem calc(50% - var(--sheet-width) / 2);
scroll-padding: calc(50% - var(--sheet-width) / 2);
scrollbar-width: none;
}
.fokus-track::-webkit-scrollbar {
@ -251,6 +274,44 @@
opacity: 0.75;
}
.sheet-header-row {
display: flex;
align-items: center;
}
/* Let ViewColumnHeader fill available space */
.sheet-header-row > :global(:first-child) {
flex: 1;
min-width: 0;
}
.sheet-close-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
margin-right: 0.5rem;
border-radius: 0.25rem;
border: none;
background: transparent;
color: #d1d5db;
cursor: pointer;
transition: all 0.15s;
}
.sheet-close-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #6b7280;
}
:global(.dark) .sheet-close-btn {
color: #4b5563;
}
:global(.dark) .sheet-close-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #9ca3af;
}
.sheet-body {
flex: 1;
display: flex;

View file

@ -0,0 +1,265 @@
<script lang="ts">
import {
CheckCircle,
CalendarCheck,
Warning,
ListChecks,
Flag,
Calendar,
TagSimple,
X,
} from '@manacore/shared-icons';
export interface PageOption {
id: string;
title: string;
description: string;
icon: typeof CheckCircle;
color: string;
}
interface Props {
onSelect: (pageId: string) => void;
onClose: () => void;
activePageIds?: string[];
}
let { onSelect, onClose, activePageIds = [] }: Props = $props();
const PAGE_OPTIONS: PageOption[] = [
{
id: 'completed',
title: 'Erledigt',
description: 'Alle abgeschlossenen Aufgaben',
icon: CheckCircle,
color: '#22C55E',
},
{
id: 'today',
title: 'Heute',
description: 'Fällig heute & überfällig',
icon: CalendarCheck,
color: '#F59E0B',
},
{
id: 'overdue',
title: 'Überfällig',
description: 'Aufgaben nach Fälligkeitsdatum',
icon: Warning,
color: '#EF4444',
},
{
id: 'all',
title: 'Alle Aufgaben',
description: 'Vollständige Aufgabenliste',
icon: ListChecks,
color: '#3B82F6',
},
{
id: 'high-priority',
title: 'Hohe Priorität',
description: 'Dringend & hoch priorisiert',
icon: Flag,
color: '#EF4444',
},
{
id: 'this-week',
title: 'Diese Woche',
description: 'Aufgaben der nächsten 7 Tage',
icon: Calendar,
color: '#8B5CF6',
},
{
id: 'no-date',
title: 'Ohne Datum',
description: 'Aufgaben ohne Fälligkeitsdatum',
icon: TagSimple,
color: '#6B7280',
},
];
let availableOptions = $derived(PAGE_OPTIONS.filter((opt) => !activePageIds.includes(opt.id)));
</script>
<div class="page-picker">
<div class="picker-header">
<h3 class="picker-title">Neue Seite</h3>
<button class="close-btn" onclick={onClose} title="Schließen">
<X size={16} />
</button>
</div>
<div class="picker-list">
{#each availableOptions as option, i (option.id)}
{#if i > 0}
<div class="divider"></div>
{/if}
<button class="page-option" onclick={() => onSelect(option.id)}>
<div class="option-icon" style="color: {option.color}">
<option.icon size={20} />
</div>
<div class="option-text">
<span class="option-title">{option.title}</span>
<span class="option-desc">{option.description}</span>
</div>
</button>
{/each}
{#if availableOptions.length === 0}
<div class="empty-state">
<p>Alle Seiten sind bereits geöffnet</p>
</div>
{/if}
</div>
</div>
<style>
.page-picker {
flex: 0 0 auto;
width: min(320px, 85vw);
min-height: 60vh;
scroll-snap-align: center;
background: #fffef5;
border-radius: 0.375rem;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
animation: slideIn 0.25s ease-out;
}
:global(.dark) .page-picker {
background-color: #252220;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
}
.picker-title {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin: 0;
}
:global(.dark) .picker-title {
color: #f3f4f6;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 0.375rem;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.close-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #374151;
}
:global(.dark) .close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
.picker-list {
flex: 1;
padding: 0 0.5rem 0.75rem;
}
.divider {
height: 1px;
background: rgba(0, 0, 0, 0.06);
margin: 0 0.5rem;
}
:global(.dark) .divider {
background: rgba(255, 255, 255, 0.06);
}
.page-option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 0.5rem;
border: none;
background: transparent;
cursor: pointer;
border-radius: 0.375rem;
transition: background 0.15s;
text-align: left;
}
.page-option:hover {
background: rgba(0, 0, 0, 0.04);
}
:global(.dark) .page-option:hover {
background: rgba(255, 255, 255, 0.06);
}
.option-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 0.5rem;
background: color-mix(in srgb, currentColor 10%, transparent);
}
.option-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.option-title {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
}
:global(.dark) .option-title {
color: #f3f4f6;
}
.option-desc {
font-size: 0.75rem;
color: #9ca3af;
}
:global(.dark) .option-desc {
color: #6b7280;
}
.empty-state {
padding: 2rem 1rem;
text-align: center;
}
.empty-state p {
font-size: 0.8125rem;
color: #9ca3af;
}
</style>

View file

@ -0,0 +1,259 @@
<script lang="ts">
import { getContext } from 'svelte';
import { isToday, isPast, startOfDay, addDays } from 'date-fns';
import type { Task } from '@todo/shared';
import { X } from '@manacore/shared-icons';
import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { todoSettings } from '$lib/stores/settings.svelte';
interface Props {
pageId: string;
onClose: () => void;
}
let { pageId, onClose }: Props = $props();
const tasksCtx: { readonly value: Task[] } = getContext('tasks');
const PAGE_META: Record<string, { title: string; color: string }> = {
completed: { title: 'Erledigt', color: '#22C55E' },
today: { title: 'Heute', color: '#F59E0B' },
overdue: { title: 'Überfällig', color: '#EF4444' },
all: { title: 'Alle Aufgaben', color: '#3B82F6' },
'high-priority': { title: 'Hohe Priorität', color: '#EF4444' },
'this-week': { title: 'Diese Woche', color: '#8B5CF6' },
'no-date': { title: 'Ohne Datum', color: '#6B7280' },
};
let meta = $derived(PAGE_META[pageId] ?? { title: pageId, color: '#6B7280' });
let filteredTasks = $derived.by(() => {
const tasks = tasksCtx.value;
const today = startOfDay(new Date());
const weekEnd = addDays(today, 7);
switch (pageId) {
case 'completed':
return tasks.filter((t) => t.isCompleted);
case 'today':
return tasks.filter(
(t) =>
!t.isCompleted &&
t.dueDate &&
(isToday(new Date(t.dueDate)) || isPast(startOfDay(new Date(t.dueDate))))
);
case 'overdue':
return tasks.filter(
(t) =>
!t.isCompleted &&
t.dueDate &&
isPast(startOfDay(new Date(t.dueDate))) &&
!isToday(new Date(t.dueDate))
);
case 'all':
return tasks;
case 'high-priority':
return tasks.filter(
(t) => !t.isCompleted && (t.priority === 'urgent' || t.priority === 'high')
);
case 'this-week':
return tasks.filter(
(t) =>
!t.isCompleted &&
t.dueDate &&
new Date(t.dueDate) <= weekEnd &&
new Date(t.dueDate) >= today
);
case 'no-date':
return tasks.filter((t) => !t.isCompleted && !t.dueDate);
default:
return [];
}
});
function handleToggle(task: Task) {
if (task.isCompleted) {
tasksStore.uncompleteTask(task.id);
} else {
tasksStore.completeTask(task.id);
}
}
function handleDelete(taskId: string) {
tasksStore.deleteTask(taskId);
}
function handleUpdate(taskId: string, data: Partial<Task>) {
const updateData: Record<string, unknown> = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description;
if (data.priority !== undefined) updateData.priority = data.priority;
if (data.dueDate !== undefined) {
updateData.dueDate = data.dueDate instanceof Date ? data.dueDate.toISOString() : data.dueDate;
}
tasksStore.updateTask(taskId, updateData);
}
const PAGE_WIDTH_MAP: Record<string, string> = {
narrow: 'min(360px, 85vw)',
medium: 'min(480px, 85vw)',
wide: 'min(640px, 90vw)',
full: 'min(840px, 95vw)',
};
let sheetWidth = $derived(PAGE_WIDTH_MAP[todoSettings.pageWidth] || PAGE_WIDTH_MAP.medium);
</script>
<div class="secondary-page" style="width: {sheetWidth}">
<div class="page-header">
<div class="header-left">
<span class="color-dot" style="background-color: {meta.color}"></span>
<span class="page-title">{meta.title}</span>
<span class="task-count">{filteredTasks.length}</span>
</div>
<button class="close-btn" onclick={onClose} title="Seite schließen">
<X size={14} />
</button>
</div>
<div class="page-body">
{#if filteredTasks.length === 0}
<div class="empty-state">
<p>Keine Aufgaben</p>
</div>
{:else}
{#each filteredTasks as task (task.id)}
<div class="task-card-wrapper">
<KanbanTaskCard
{task}
onToggleComplete={() => handleToggle(task)}
onSave={(data) => handleUpdate(task.id, data)}
onDelete={() => handleDelete(task.id)}
/>
</div>
{/each}
{/if}
</div>
</div>
<style>
.secondary-page {
flex: 0 0 auto;
min-height: 60vh;
scroll-snap-align: center;
background: #fffef5;
border-radius: 0.375rem;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
animation: fadeIn 0.25s ease-out;
}
:global(.dark) .secondary-page {
background-color: #252220;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 9999px;
flex-shrink: 0;
}
.page-title {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
}
:global(.dark) .page-title {
color: #f3f4f6;
}
.task-count {
font-size: 0.75rem;
font-weight: 500;
color: #9ca3af;
background: rgba(0, 0, 0, 0.05);
padding: 0.125rem 0.5rem;
border-radius: 9999px;
}
:global(.dark) .task-count {
background: rgba(255, 255, 255, 0.1);
color: #6b7280;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 0.25rem;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.close-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #374151;
}
:global(.dark) .close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
.page-body {
flex: 1;
padding: 0.75rem 1rem;
overflow-y: auto;
}
.task-card-wrapper {
margin-bottom: 0.5rem;
}
.task-card-wrapper:last-child {
margin-bottom: 0;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
}
.empty-state p {
font-size: 0.8125rem;
color: #9ca3af;
}
</style>

View file

@ -4,6 +4,9 @@
import { BoardViewRenderer } from '$lib/components/board-views';
import { todoSettings, type PageWidth } from '$lib/stores/settings.svelte';
import { boardViewsStore } from '$lib/stores/board-views.svelte';
import { Plus } from '@manacore/shared-icons';
import PagePicker from '$lib/components/pages/PagePicker.svelte';
import SecondaryPage from '$lib/components/pages/SecondaryPage.svelte';
// Get active view + edit mode from layout context
const activeViewCtx: { readonly value: LocalBoardView | null } = getContext('activeView');
@ -14,6 +17,31 @@
let activeView = $derived(activeViewCtx.value);
let pageTitle = $derived(activeView?.name ?? 'Aufgaben');
// ── Secondary Pages ─────────────────────────────────────
let showPagePicker = $state(false);
let openPages = $state<string[]>([]);
function handleAddPage(pageId: string) {
if (!openPages.includes(pageId)) {
openPages = [...openPages, pageId];
}
showPagePicker = false;
}
function handleRemovePage(pageId: string) {
openPages = openPages.filter((p) => p !== pageId);
}
function togglePagePicker() {
showPagePicker = !showPagePicker;
}
function handleColumnClose(colIdx: number) {
if (!activeView || activeView.columns.length <= 1) return;
const columns = $state.snapshot(activeView.columns).filter((_, i) => i !== colIdx);
updateView({ columns });
}
// ── Edit helpers ────────────────────────────────────────
const GROUPBY_OPTIONS = [
@ -49,13 +77,15 @@
function updateColumn(colIdx: number, data: Record<string, unknown>) {
if (!activeView) return;
const cols = activeView.columns.map((c, i) => (i === colIdx ? { ...c, ...data } : { ...c }));
const raw = $state.snapshot(activeView.columns);
const cols = raw.map((c, i) => (i === colIdx ? { ...c, ...data } : c));
updateView({ columns: cols });
}
function removeColumn(colIdx: number) {
if (!activeView || activeView.columns.length <= 1) return;
updateView({ columns: activeView.columns.filter((_, i) => i !== colIdx) });
const columns = $state.snapshot(activeView.columns).filter((_, i) => i !== colIdx);
updateView({ columns });
}
function addColumn() {
@ -66,12 +96,12 @@
color: COLUMN_COLORS[activeView.columns.length % COLUMN_COLORS.length],
match: { type: 'custom' as const, value: `custom-${Date.now()}` },
};
updateView({ columns: [...activeView.columns, newCol] });
updateView({ columns: [...$state.snapshot(activeView.columns), newCol] });
}
function moveColumn(colIdx: number, dir: -1 | 1) {
if (!activeView) return;
const cols = [...activeView.columns];
const cols = $state.snapshot(activeView.columns);
const target = colIdx + dir;
if (target < 0 || target >= cols.length) return;
[cols[colIdx], cols[target]] = [cols[target], cols[colIdx]];
@ -148,8 +178,35 @@
onColumnColorChange={columnsEditable ? (i, color) => updateColumn(i, { color }) : undefined}
onColumnMove={columnsEditable ? moveColumn : undefined}
onColumnDelete={columnsEditable ? removeColumn : undefined}
onColumnClose={handleColumnClose}
onAddColumn={columnsEditable && editMode ? addColumn : undefined}
/>
>
{#snippet trailing()}
<!-- Secondary Pages -->
{#each openPages as pageId (pageId)}
<SecondaryPage {pageId} onClose={() => handleRemovePage(pageId)} />
{/each}
<!-- Neue Seite button (always last in track) -->
{#if !editMode}
{#if showPagePicker}
<PagePicker
onSelect={handleAddPage}
onClose={() => (showPagePicker = false)}
activePageIds={openPages}
/>
{:else}
<button
class="neue-seite-card"
onclick={togglePagePicker}
title="Neue Seite hinzufügen"
>
<Plus size={18} />
</button>
{/if}
{/if}
{/snippet}
</BoardViewRenderer>
{:else}
<div class="empty-state">
<p class="text-muted-foreground">Views werden geladen...</p>
@ -164,6 +221,35 @@
flex-direction: column;
}
.neue-seite-card {
flex: 0 0 auto;
width: 48px;
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed rgba(0, 0, 0, 0.08);
border-radius: 0.375rem;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.2s;
}
.neue-seite-card:hover {
border-color: var(--color-primary, #8b5cf6);
color: var(--color-primary, #8b5cf6);
background: color-mix(in srgb, var(--color-primary, #8b5cf6) 4%, transparent);
}
:global(.dark) .neue-seite-card {
border-color: rgba(255, 255, 255, 0.06);
color: #4b5563;
}
: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);
}
.empty-state {
display: flex;
align-items: center;