mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
9f6e463eae
commit
3f2e6a3ee5
14 changed files with 775 additions and 1869 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue