feat(todo): unify view modes into single route with Fokus/Übersicht/Matrix layouts

Consolidate the two separate view systems (homepage paper pages + /kanban board)
into one unified system on `/`. All three layout modes (Fokus, Übersicht, Matrix)
share the same LocalBoardView data model and BoardViewRenderer, with layout
switching via PillNav tabs instead of route navigation.

- Add FokusLayout component (scroll-snap paper sheets with DnD)
- Add activeLayoutMode setting (fokus/uebersicht/matrix)
- Add layoutOverride prop to BoardViewRenderer
- Rewrite homepage to use BoardViewRenderer + ViewSelector
- Unify DnD type to 'task-dnd' across all layouts
- Convert PillNav from route-based to state-based view switching
- Delete /kanban route (redirect to / with uebersicht mode)
- Update PWA shortcuts, settings, onboarding, help content

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 12:03:58 +02:00
parent 9f6e463eae
commit 3f2e6a3ee5
14 changed files with 775 additions and 1869 deletions

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { TaskPriority } from '@todo/shared';
import { PRIORITY_OPTIONS } from '@todo/shared';
import { getContext } from 'svelte';
@ -8,6 +7,7 @@
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import { viewStore, type SortBy } from '$lib/stores/view.svelte';
import { todoSettings } from '$lib/stores/settings.svelte';
import { PillToolbarButton, PillToolbarDivider, PillViewSwitcher } from '@manacore/shared-ui';
interface Props {
@ -82,8 +82,15 @@
<svelte:window onclick={closeAllDropdowns} />
<div class="toolbar-content" class:vertical>
<!-- Kanban View Button -->
<PillToolbarButton onclick={() => goto('/kanban')} title="Kanban-Ansicht">
<!-- Board View Button — cycle layout mode -->
<PillToolbarButton
onclick={() => {
const modes = ['fokus', 'uebersicht', 'matrix'] as const;
const idx = modes.indexOf(todoSettings.activeLayoutMode);
todoSettings.set('activeLayoutMode', modes[(idx + 1) % modes.length]);
}}
title="Ansicht wechseln"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"

View file

@ -6,12 +6,16 @@
import { tasksStore } from '$lib/stores/tasks.svelte';
import KanbanLayout from './KanbanLayout.svelte';
import GridLayout from './GridLayout.svelte';
import FokusLayout from './FokusLayout.svelte';
interface Props {
view: LocalBoardView;
layoutOverride?: 'kanban' | 'grid' | 'fokus';
}
let { view }: Props = $props();
let { view, layoutOverride }: Props = $props();
let activeLayout = $derived(layoutOverride || view.layout);
// Get tasks and projects from context (set by layout)
const tasksCtx: { readonly value: Task[] } = getContext('tasks');
@ -63,7 +67,15 @@
}
</script>
{#if view.layout === 'grid'}
{#if activeLayout === 'fokus'}
<FokusLayout
{columns}
onTaskDrop={handleTaskDrop}
onTaskToggle={handleTaskToggle}
onTaskDelete={handleTaskDelete}
onTaskUpdate={handleTaskUpdate}
/>
{:else if activeLayout === 'grid'}
<GridLayout
{columns}
onTaskDrop={handleTaskDrop}

View file

@ -0,0 +1,264 @@
<script lang="ts">
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID, type DndEvent } from 'svelte-dnd-action';
import type { Task } from '@todo/shared';
import type { GroupedColumn } from '$lib/data/view-grouping';
import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte';
import QuickAddTaskInline from '../kanban/QuickAddTaskInline.svelte';
import ViewColumnHeader from './ViewColumnHeader.svelte';
import { tasksStore } from '$lib/stores/tasks.svelte';
import { todoSettings } from '$lib/stores/settings.svelte';
interface Props {
columns: GroupedColumn[];
onTaskDrop: (taskId: string, columnId: string) => void;
onTaskToggle: (task: Task) => void;
onTaskDelete: (taskId: string) => void;
onTaskUpdate: (taskId: string, data: Partial<Task>) => void;
}
let { columns, onTaskDrop, onTaskToggle, onTaskDelete, onTaskUpdate }: Props = $props();
const PAGE_WIDTH_MAP: Record<string, string> = {
narrow: 'min(640px, 85vw)',
medium: 'min(840px, 85vw)',
wide: 'min(1024px, 92vw)',
full: '92vw',
};
let sheetWidth = $derived(PAGE_WIDTH_MAP[todoSettings.pageWidth] || PAGE_WIDTH_MAP.medium);
// Track active page for dots
let scrollContainer: HTMLDivElement | undefined = $state();
let activePage = $state(0);
function handleScroll() {
if (!scrollContainer) return;
const sheets = scrollContainer.querySelectorAll('.fokus-sheet');
const containerRect = scrollContainer.getBoundingClientRect();
const center = containerRect.left + containerRect.width / 2;
let closest = 0;
let closestDist = Infinity;
sheets.forEach((sheet, i) => {
const rect = sheet.getBoundingClientRect();
const dist = Math.abs(rect.left + rect.width / 2 - center);
if (dist < closestDist) {
closestDist = dist;
closest = i;
}
});
activePage = closest;
}
// Per-column local task state for DnD
let localTasksByColumn = $state<Record<string, Task[]>>({});
$effect(() => {
const updated: Record<string, Task[]> = {};
for (const col of columns) {
updated[col.id] = [...col.tasks];
}
localTasksByColumn = updated;
});
function handleDndConsider(columnId: string, e: CustomEvent<DndEvent<Task>>) {
localTasksByColumn = { ...localTasksByColumn, [columnId]: e.detail.items };
}
function handleDndFinalize(
columnId: string,
column: GroupedColumn,
e: CustomEvent<DndEvent<Task>>
) {
const newItems = e.detail.items.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID);
const movedTaskId = e.detail.info.id;
const wasInThisColumn = column.tasks.some((t) => t.id === movedTaskId);
if (!wasInThisColumn) {
onTaskDrop(movedTaskId, column.id);
} else {
tasksStore.reorderTasks(newItems.map((t) => t.id));
}
localTasksByColumn = { ...localTasksByColumn, [columnId]: newItems };
}
function handleAddTask(column: GroupedColumn, title: string) {
const createData: Record<string, unknown> = { title };
if (column.onDrop) {
if (column.onDrop.setPriority) createData.priority = column.onDrop.setPriority;
if (column.onDrop.setProjectId !== undefined)
createData.projectId = column.onDrop.setProjectId;
}
tasksStore.createTask(
createData as {
title: string;
projectId?: string;
priority?: 'low' | 'medium' | 'high' | 'urgent';
}
);
}
</script>
<div class="fokus-layout">
<div
class="fokus-track"
style="--sheet-width: {sheetWidth}"
bind:this={scrollContainer}
onscroll={handleScroll}
>
{#each columns as column (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} />
<div
class="sheet-content"
use:dndzone={{
items: tasks,
flipDurationMs: 200,
dropTargetStyle: {},
dropTargetClasses: ['fokus-drop-target'],
type: 'task-dnd',
}}
onconsider={(e) => handleDndConsider(column.id, e)}
onfinalize={(e) => handleDndFinalize(column.id, column, e)}
>
{#each tasks.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID) as task (task.id)}
<div class="task-card-wrapper">
<KanbanTaskCard
{task}
onToggleComplete={() => onTaskToggle(task)}
onSave={(data) => onTaskUpdate(task.id, data)}
onDelete={() => onTaskDelete(task.id)}
/>
</div>
{/each}
</div>
<div class="sheet-footer">
<QuickAddTaskInline onAdd={(title) => handleAddTask(column, title)} />
</div>
</div>
{/each}
</div>
<!-- Page dots -->
{#if columns.length > 1}
<div class="page-dots">
{#each columns as _, i}
<div class="page-dot" class:active={activePage === i}></div>
{/each}
</div>
{/if}
</div>
<style>
.fokus-layout {
padding-bottom: 100px;
}
.fokus-track {
display: flex;
gap: 1.5rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding: 1.5rem;
padding: 1rem 1.5rem 1rem;
scrollbar-width: none;
}
.fokus-track::-webkit-scrollbar {
display: none;
}
.fokus-sheet {
flex: 0 0 auto;
width: var(--sheet-width, min(840px, 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;
overflow: hidden;
}
:global(.dark) .fokus-sheet {
background-color: #252220;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
.sheet-completed {
opacity: 0.75;
}
.sheet-content {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem;
min-height: 120px;
}
.sheet-content::-webkit-scrollbar {
width: 4px;
}
.sheet-content::-webkit-scrollbar-track {
background: transparent;
}
.sheet-content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.08);
border-radius: 2px;
}
:global(.dark) .sheet-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
}
.task-card-wrapper {
margin-bottom: 0.5rem;
}
.task-card-wrapper:last-child {
margin-bottom: 0;
}
.sheet-footer {
padding: 0.5rem 1rem 0.75rem;
border-top: 1px solid rgba(0, 0, 0, 0.04);
}
:global(.dark) .sheet-footer {
border-top-color: rgba(255, 255, 255, 0.04);
}
:global(.fokus-drop-target) {
outline: 2px dashed #8b5cf6;
outline-offset: -2px;
border-radius: 0.375rem;
background: rgba(139, 92, 246, 0.04);
}
/* Page dots */
.page-dots {
display: flex;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 0;
}
.page-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.15);
transition: all 0.2s ease;
}
.page-dot.active {
background: hsl(var(--color-primary));
transform: scale(1.3);
}
:global(.dark) .page-dot {
background: rgba(255, 255, 255, 0.2);
}
:global(.dark) .page-dot.active {
background: hsl(var(--color-primary));
}
</style>

View file

@ -90,7 +90,13 @@
createData.projectId = column.onDrop.setProjectId;
}
}
await tasksStore.createTask(createData as { title: string; projectId?: string; priority?: 'low' | 'medium' | 'high' | 'urgent' });
await tasksStore.createTask(
createData as {
title: string;
projectId?: string;
priority?: 'low' | 'medium' | 'high' | 'urgent';
}
);
}
</script>
@ -106,7 +112,7 @@
flipDurationMs,
dropTargetStyle: {},
dropTargetClasses: ['drop-target'],
type: 'board-view-tasks',
type: 'task-dnd',
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}

View file

@ -2,6 +2,7 @@ export { default as ViewColumnHeader } from './ViewColumnHeader.svelte';
export { default as ViewColumn } from './ViewColumn.svelte';
export { default as KanbanLayout } from './KanbanLayout.svelte';
export { default as GridLayout } from './GridLayout.svelte';
export { default as FokusLayout } from './FokusLayout.svelte';
export { default as BoardViewRenderer } from './BoardViewRenderer.svelte';
export { default as ViewSelector } from './ViewSelector.svelte';
export { default as ViewEditorModal } from './ViewEditorModal.svelte';

View file

@ -41,8 +41,8 @@ export function getTodoHelpContent(locale: string): HelpContent {
id: 'faq-kanban',
question: isDE ? 'Was ist die Kanban-Ansicht?' : 'What is the Kanban view?',
answer: isDE
? '<p>Die Kanban-Ansicht zeigt deine Aufgaben als Karten in Spalten an. Du kannst:</p><ul><li>Aufgaben per Drag & Drop zwischen Spalten verschieben</li><li>Den Fortschritt visuell verfolgen</li><li>Spalten nach Priorität oder Status organisieren</li></ul><p>Wechsle mit <kbd>Ctrl+2</kbd> zur Kanban-Ansicht.</p>'
: '<p>The Kanban view shows your tasks as cards in columns. You can:</p><ul><li>Drag and drop tasks between columns</li><li>Track progress visually</li><li>Organize columns by priority or status</li></ul><p>Switch to Kanban view with <kbd>Ctrl+2</kbd>.</p>',
? '<p>Die Board-Ansicht zeigt deine Aufgaben als Karten in Spalten an. Du kannst:</p><ul><li>Aufgaben per Drag & Drop zwischen Spalten verschieben</li><li>Den Fortschritt visuell verfolgen</li><li>Spalten nach Priorität oder Status organisieren</li></ul><p>Wechsle über die Tabs (Fokus / Übersicht / Matrix) zwischen den Ansichten.</p>'
: '<p>The board view shows your tasks as cards in columns. You can:</p><ul><li>Drag and drop tasks between columns</li><li>Track progress visually</li><li>Organize columns by priority or status</li></ul><p>Switch between views using the tabs (Fokus / Übersicht / Matrix).</p>',
category: 'features',
order: 3,
language: isDE ? 'de' : 'en',

View file

@ -95,7 +95,7 @@ export interface LocalBoardView extends BaseRecord {
groupBy: 'status' | 'priority' | 'project' | 'dueDate' | 'tag' | 'custom';
columns: ViewColumn[];
filter?: ViewFilter;
layout: 'kanban' | 'grid';
layout: 'kanban' | 'grid' | 'fokus';
order: number;
}

View file

@ -40,12 +40,6 @@ const todoOnboardingSteps: AppOnboardingStep[] = [
description: 'Alle unsortierten Aufgaben',
emoji: '📥',
},
{
id: 'kanban',
label: 'Kanban Board',
description: 'Spalten-basierte Aufgabenverwaltung',
emoji: '📊',
},
],
defaultValue: 'today',
},
@ -98,7 +92,7 @@ export const todoOnboarding = createAppOnboardingStore({
onComplete: async (preferences) => {
// Apply default view
const view = preferences.defaultView as string;
if (view === 'today' || view === 'inbox' || view === 'kanban') {
if (view === 'today' || view === 'inbox') {
todoSettings.set('defaultView', view);
}

View file

@ -9,7 +9,8 @@ import type { TaskPriority } from '@todo/shared';
// Settings types
export type TodoView = 'inbox' | 'today' | 'upcoming' | 'kanban' | 'completed';
export type KanbanCardSize = 'compact' | 'normal' | 'large';
export type PageMode = 'date' | 'priority' | 'custom';
export type LayoutMode = 'fokus' | 'uebersicht' | 'matrix';
export type PageMode = 'date' | 'priority' | 'custom'; // deprecated — will be replaced by BoardView
export type PageIcon =
| 'warning'
@ -76,7 +77,10 @@ export interface TodoAppSettings extends Record<string, unknown> {
pillNavCollapsed: boolean;
filterStripCollapsed: boolean;
// Page mode
// View layout
activeLayoutMode: LayoutMode;
// Page mode (deprecated — migrating to BoardView)
pageMode: PageMode;
pageWidth: PageWidth;
customPages: PageConfig[];
@ -123,7 +127,10 @@ const DEFAULT_SETTINGS: TodoAppSettings = {
pillNavCollapsed: true, // PillNav hidden by default, shown via FAB
filterStripCollapsed: false, // FilterStrip shown by default when PillNav is visible
// Page mode
// View layout
activeLayoutMode: 'fokus' as LayoutMode,
// Page mode (deprecated)
pageMode: 'priority' as PageMode,
pageWidth: 'medium' as PageWidth,
customPages: [] as PageConfig[],
@ -218,6 +225,9 @@ export const todoSettings = {
get filterStripCollapsed() {
return baseStore.settings.filterStripCollapsed;
},
get activeLayoutMode() {
return baseStore.settings.activeLayoutMode;
},
get pageMode() {
return baseStore.settings.pageMode;
},

View file

@ -229,31 +229,25 @@
isTagStripVisible = !isTagStripVisible;
}
// View routes for the tab group (pages that navigate)
const viewRoutes: Record<string, string> = {
liste: '/',
kanban: '/kanban',
};
// Determine active view tab from current path
let activeViewTab = $derived(
Object.entries(viewRoutes).find(([_, path]) => $page.url.pathname === path)?.[0] || 'liste'
);
// Tab group for view switching (Liste, Kanban) - grouped in one pill
// View mode switching (state-based, not route-based)
let viewTabGroup = $derived<PillNavElement>({
type: 'tabs' as const,
options: [
{ id: 'liste', icon: 'list', label: 'Liste', title: 'Listenansicht' },
{ id: 'kanban', icon: 'columns', label: 'Kanban', title: 'Kanban-Board' },
{ id: 'fokus', icon: 'list', label: 'Fokus', title: 'Fokus-Ansicht' },
{ id: 'uebersicht', icon: 'columns', label: 'Übersicht', title: 'Übersicht' },
{ id: 'matrix', icon: 'grid', label: 'Matrix', title: 'Eisenhower-Matrix' },
],
value: activeViewTab,
value: todoSettings.activeLayoutMode,
onChange: (id: string) => {
const route = viewRoutes[id];
if (route) goto(route);
todoSettings.set('activeLayoutMode', id as 'fokus' | 'uebersicht' | 'matrix');
// Navigate to homepage if not already there
if ($page.url.pathname !== '/') goto('/');
},
});
// Keep navRoutes for keyboard shortcuts (Ctrl+1-3)
const viewRoutes: Record<string, string> = { fokus: '/', uebersicht: '/', matrix: '/' };
// Handle edit mode toggle
function handleEditToggle() {
editMode = !editMode;
@ -384,8 +378,15 @@
await userSettings.load();
}
// Redirect to start page if on root and a custom start page is set
// Redirect /kanban to / with Übersicht mode
const currentPath = window.location.pathname;
if (currentPath === '/kanban') {
todoSettings.set('activeLayoutMode', 'uebersicht');
goto('/', { replaceState: true });
return;
}
// Redirect to start page if on root and a custom start page is set
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
goto(userSettings.startPage, { replaceState: true });
}
@ -508,7 +509,7 @@
{/if}
<!-- Global Quick Input Bar - only on list and kanban views -->
{#if $page.url.pathname === '/' || $page.url.pathname === '/kanban'}
{#if $page.url.pathname === '/' || $page.url.pathname === '/kanban' || $page.url.pathname === '/statistics'}
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
@ -575,7 +576,7 @@
</div>
<div
class="content-wrapper"
class:full-width={$page.url.pathname === '/kanban'}
class:full-width={todoSettings.activeLayoutMode !== 'fokus'}
class:immersive={todoSettings.immersiveModeEnabled}
>
{@render children()}

File diff suppressed because it is too large Load diff

View file

@ -1,479 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { TaskPriority } from '@todo/shared';
import type { LocalBoardView } from '$lib/data/local-store';
import { useAllBoardViews } from '$lib/data/task-queries';
import { ViewSelector, BoardViewRenderer, ViewEditorModal } from '$lib/components/board-views';
import { boardViewsStore } from '$lib/stores/board-views.svelte';
import TaskFilters from '$lib/components/TaskFilters.svelte';
// Live query for board views
const boardViews = useAllBoardViews();
// Active view — persisted in localStorage
const STORAGE_KEY = 'todo:activeViewId';
let activeViewId = $state<string | null>(null);
// Initialize from localStorage
onMount(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
activeViewId = stored;
}
});
// Auto-select first view when views load and nothing is selected
$effect(() => {
if (boardViews.value.length > 0 && !activeViewId) {
activeViewId = boardViews.value[0].id;
}
// If stored view no longer exists, fall back to first
if (
activeViewId &&
boardViews.value.length > 0 &&
!boardViews.value.find((v) => v.id === activeViewId)
) {
activeViewId = boardViews.value[0].id;
}
});
// Derive the active view object
let activeView = $derived(boardViews.value.find((v) => v.id === activeViewId) ?? null);
let boardTitle = $derived(activeView?.name ?? 'Board');
function handleSelectView(viewId: string) {
activeViewId = viewId;
localStorage.setItem(STORAGE_KEY, viewId);
}
// ─── Editor Modal ──────────────────────────────────────
let showEditor = $state(false);
let editingView = $state<LocalBoardView | null>(null);
function handleCreateView() {
editingView = null;
showEditor = true;
}
function handleEditView(view: LocalBoardView) {
editingView = view;
showEditor = true;
}
async function handleSaveView(data: Partial<LocalBoardView>) {
if (editingView) {
// Update existing view
await boardViewsStore.updateView(editingView.id, data);
} else {
// Create new view
const newView = await boardViewsStore.createView({
name: data.name ?? 'Neue View',
icon: data.icon ?? 'columns',
groupBy: data.groupBy ?? 'status',
layout: data.layout ?? 'kanban',
columns: data.columns ?? [],
order: boardViews.value.length,
});
// Auto-select the new view
if (newView?.id) {
handleSelectView(newView.id);
}
}
showEditor = false;
editingView = null;
}
async function handleDeleteView() {
if (!editingView) return;
await boardViewsStore.deleteView(editingView.id);
showEditor = false;
editingView = null;
}
// ─── View Reorder ──────────────────────────────────────
async function handleReorderViews(viewIds: string[]) {
await boardViewsStore.reorderViews(viewIds);
}
// ─── Filter state ──────────────────────────────────────
let filterPriorities = $state<TaskPriority[]>([]);
let filterProjectId = $state<string | null>(null);
let filterLabelIds = $state<string[]>([]);
let filterSearchQuery = $state('');
let showFilters = $state(false);
// Load filter from active view when it changes
let previousViewId = $state<string | null>(null);
$effect(() => {
if (activeView && activeView.id !== previousViewId) {
previousViewId = activeView.id;
if (activeView.filter) {
filterPriorities = (activeView.filter.priorities ?? []) as TaskPriority[];
filterProjectId = activeView.filter.projectId ?? null;
filterLabelIds = activeView.filter.tagIds ?? [];
} else {
filterPriorities = [];
filterProjectId = null;
filterLabelIds = [];
}
filterSearchQuery = '';
}
});
function clearFilters() {
filterPriorities = [];
filterProjectId = null;
filterLabelIds = [];
filterSearchQuery = '';
}
async function saveFiltersToView() {
if (!activeViewId) return;
const filter: { projectId?: string; tagIds?: string[]; priorities?: string[] } = {};
if (filterProjectId) filter.projectId = filterProjectId;
if (filterLabelIds.length > 0) filter.tagIds = filterLabelIds;
if (filterPriorities.length > 0) filter.priorities = filterPriorities;
const hasFilter = Object.keys(filter).length > 0;
await boardViewsStore.updateView(activeViewId, { filter: hasFilter ? filter : undefined });
}
let hasActiveFilters = $derived(
filterPriorities.length > 0 ||
filterProjectId !== null ||
filterLabelIds.length > 0 ||
filterSearchQuery.trim() !== ''
);
// Responsive state
let isMobile = $state(false);
function checkMobile() {
isMobile = window.innerWidth < 768;
}
onMount(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
});
onDestroy(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', checkMobile);
}
});
</script>
<svelte:head>
<title>{boardTitle} - Todo</title>
</svelte:head>
<div class="board-page">
<!-- View Selector - Top on Desktop -->
{#if !isMobile}
<ViewSelector
views={boardViews.value}
{activeViewId}
onSelect={handleSelectView}
onCreate={handleCreateView}
onEdit={handleEditView}
onReorder={handleReorderViews}
/>
{/if}
<!-- Header -->
<div class="mb-6 flex items-center justify-between px-4 sm:px-6 lg:px-8">
<h1 class="page-title">{boardTitle}</h1>
<button
type="button"
onclick={() => (showFilters = !showFilters)}
class="filter-button px-4 py-2 text-sm font-medium transition-all flex items-center gap-2 {showFilters ||
hasActiveFilters
? 'active'
: ''}"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" />
</svg>
Filter
{#if hasActiveFilters}
<span
class="ml-1 inline-flex items-center justify-center w-5 h-5 text-xs font-bold rounded-full bg-primary-foreground text-primary"
>
{filterPriorities.length +
(filterProjectId ? 1 : 0) +
filterLabelIds.length +
(filterSearchQuery ? 1 : 0)}
</span>
{/if}
</button>
</div>
<!-- Collapsible Filters -->
{#if showFilters}
<div class="mb-6 px-4 sm:px-6 lg:px-8 animate-in slide-in-from-top-2 duration-200">
<TaskFilters
variant="bar"
selectedPriorities={filterPriorities}
selectedProjectId={filterProjectId}
selectedLabelIds={filterLabelIds}
searchQuery={filterSearchQuery}
onPrioritiesChange={(priorities: TaskPriority[]) => (filterPriorities = priorities)}
onProjectChange={(projectId: string | null) => (filterProjectId = projectId)}
onLabelsChange={(labelIds: string[]) => (filterLabelIds = labelIds)}
onSearchChange={(query: string) => (filterSearchQuery = query)}
onClearFilters={clearFilters}
showSearch={true}
showLabels={true}
/>
{#if hasActiveFilters}
<div class="mt-2 flex items-center gap-2">
<button
type="button"
class="save-filter-btn"
onclick={saveFiltersToView}
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
Filter speichern
</button>
{#if activeView?.filter}
<button
type="button"
class="clear-saved-filter-btn"
onclick={async () => {
clearFilters();
if (activeViewId) {
await boardViewsStore.updateView(activeViewId, { filter: undefined });
}
}}
>
Gespeicherten Filter entfernen
</button>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Board Content -->
<div class="board-container" class:mobile-bottom-padding={isMobile}>
{#if activeView}
<BoardViewRenderer view={{
...activeView,
filter: hasActiveFilters ? {
projectId: filterProjectId ?? undefined,
tagIds: filterLabelIds.length > 0 ? filterLabelIds : undefined,
priorities: filterPriorities.length > 0 ? filterPriorities : undefined,
} : activeView.filter,
}} />
{:else if boardViews.value.length === 0}
<div class="empty-state">
<p class="text-muted-foreground">Board Views werden geladen...</p>
</div>
{/if}
</div>
<!-- View Selector - Bottom on Mobile -->
{#if isMobile}
<div class="mobile-selector">
<ViewSelector
views={boardViews.value}
{activeViewId}
onSelect={handleSelectView}
onCreate={handleCreateView}
onEdit={handleEditView}
/>
</div>
{/if}
</div>
<!-- View Editor Modal -->
<ViewEditorModal
open={showEditor}
view={editingView}
onSave={handleSaveView}
onDelete={handleDeleteView}
onClose={() => {
showEditor = false;
editingView = null;
}}
/>
<style>
.board-page {
display: flex;
flex-direction: column;
height: calc(100vh - 140px);
}
.page-title {
font-size: 1.875rem;
font-weight: 700;
color: var(--foreground);
margin: 0;
}
.board-container {
flex: 1;
min-height: 0;
overflow: hidden;
}
.board-container.mobile-bottom-padding {
padding-bottom: 70px;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
/* Mobile selector fixed at bottom */
.mobile-selector {
position: fixed;
bottom: 70px; /* Above PillNav */
left: 0;
right: 0;
z-index: 40;
background: linear-gradient(to top, var(--background) 0%, transparent 100%);
padding-bottom: 0.75rem;
}
/* Glass-Pill filter button */
.filter-button {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 9999px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
color: #6b7280;
}
:global(.dark) .filter-button {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #9ca3af;
}
.filter-button:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
color: #374151;
}
:global(.dark) .filter-button:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
color: #f3f4f6;
}
.filter-button.active {
background: #8b5cf6;
border-color: #8b5cf6;
color: white;
box-shadow:
0 4px 6px -1px rgba(139, 92, 246, 0.3),
0 2px 4px -1px rgba(139, 92, 246, 0.2);
}
.filter-button.active:hover {
background: #7c3aed;
border-color: #7c3aed;
color: white;
}
/* ─── Save Filter Button ───────────────────────────────── */
.save-filter-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: #8b5cf6;
background: rgba(139, 92, 246, 0.1);
border: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s;
}
.save-filter-btn:hover {
background: rgba(139, 92, 246, 0.2);
border-color: rgba(139, 92, 246, 0.3);
}
:global(.dark) .save-filter-btn {
color: #a78bfa;
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.25);
}
:global(.dark) .save-filter-btn:hover {
background: rgba(139, 92, 246, 0.25);
border-color: rgba(139, 92, 246, 0.35);
}
.clear-saved-filter-btn {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
background: transparent;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s;
}
.clear-saved-filter-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.2);
}
:global(.dark) .clear-saved-filter-btn {
color: #9ca3af;
border-color: rgba(255, 255, 255, 0.12);
}
:global(.dark) .clear-saved-filter-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
/* Animations */
.animate-in {
animation: animateIn 0.2s ease-out;
}
.slide-in-from-top-2 {
--tw-enter-translate-y: -0.5rem;
}
@keyframes animateIn {
from {
opacity: 0;
transform: translateY(var(--tw-enter-translate-y, 0));
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -37,7 +37,6 @@
{ value: 'inbox', label: 'Inbox' },
{ value: 'today', label: 'Heute' },
{ value: 'upcoming', label: 'Anstehend' },
{ value: 'kanban', label: 'Kanban' },
{ value: 'completed', label: 'Erledigt' },
];
@ -153,7 +152,6 @@
appId="todo"
navItems={[
{ href: '/', label: 'Aufgaben', icon: 'list' },
{ href: '/kanban', label: 'Kanban', icon: 'columns' },
{ href: '/statistics', label: 'Statistiken', icon: 'chart' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },

View file

@ -24,12 +24,6 @@ export default defineConfig({
description: 'Neue Aufgabe erstellen',
url: '/?action=new',
},
{
name: 'Kanban Board',
short_name: 'Kanban',
description: 'Kanban-Ansicht öffnen',
url: '/kanban',
},
{
name: 'Einstellungen',
short_name: 'Settings',