feat(todo): add comprehensive settings page with 20+ preferences

- Add global settings: confirmOnDelete, keyboardShortcutsEnabled
- Create Todo-specific settings store with localStorage persistence
- Add new shared-ui components: SettingsSelect, SettingsNumberInput, SettingsTimeInput
- Redesign settings page with 6 new sections:
  - Task behavior (priority, due time, auto-archive, quick-add project)
  - View & display (default view, compact mode, task counts, subtask progress)
  - Kanban board (card size, labels, WIP limit)
  - Notifications (reminders, daily digest, overdue alerts)
  - Productivity (focus mode, pomodoro, daily goal, streak)
  - Keyboard shortcuts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-09 13:30:02 +01:00
parent 00dadc97d1
commit 863dd621f5
8 changed files with 1570 additions and 2 deletions

View file

@ -0,0 +1,239 @@
/**
* Settings Store - Manages user preferences for the Todo app
* Uses Svelte 5 runes and localStorage for persistence
*/
import { browser } from '$app/environment';
import type { TaskPriority } from '@todo/shared';
// Settings types
export type TodoView = 'inbox' | 'today' | 'upcoming' | 'kanban' | 'completed';
export type KanbanCardSize = 'compact' | 'normal' | 'large';
export interface TodoAppSettings {
// Task Behavior
/** Default priority for new tasks */
defaultPriority: TaskPriority;
/** Default due time for tasks (HH:mm format, null = no default) */
defaultDueTime: string | null;
/** Auto-archive completed tasks after X days (null = disabled) */
autoArchiveCompletedDays: number | null;
/** Default project for quick add (null = inbox) */
quickAddProject: string | null;
// View & Display
/** Default view when opening the app */
defaultView: TodoView;
/** Show task counts as badges in navigation */
showTaskCounts: boolean;
/** Compact mode with reduced padding */
compactMode: boolean;
/** Show progress bar for subtasks */
showSubtaskProgress: boolean;
/** Group tasks by project in list views */
groupByProject: boolean;
// Kanban Board
/** Kanban card size */
kanbanCardSize: KanbanCardSize;
/** Show labels on kanban cards */
showLabelsOnCards: boolean;
/** Work-in-progress limit per column (null = unlimited) */
wipLimitPerColumn: number | null;
// Notifications & Reminders
/** Default reminder time in minutes before due (null = no default) */
defaultReminderMinutes: number | null;
/** Enable daily digest email/notification */
dailyDigestEnabled: boolean;
/** Notify about overdue tasks */
overdueNotifications: boolean;
// Productivity
/** Focus mode - show only current task */
focusMode: boolean;
/** Enable pomodoro timer */
pomodoroEnabled: boolean;
/** Daily task completion goal (null = no goal) */
dailyGoal: number | null;
/** Show productivity streak */
showStreak: boolean;
}
const DEFAULT_SETTINGS: TodoAppSettings = {
// Task Behavior
defaultPriority: 'medium',
defaultDueTime: '09:00',
autoArchiveCompletedDays: null,
quickAddProject: null,
// View & Display
defaultView: 'inbox',
showTaskCounts: true,
compactMode: false,
showSubtaskProgress: true,
groupByProject: false,
// Kanban Board
kanbanCardSize: 'normal',
showLabelsOnCards: true,
wipLimitPerColumn: null,
// Notifications & Reminders
defaultReminderMinutes: null,
dailyDigestEnabled: false,
overdueNotifications: true,
// Productivity
focusMode: false,
pomodoroEnabled: false,
dailyGoal: null,
showStreak: false,
};
const STORAGE_KEY = 'todo-settings';
// Load settings from localStorage
function loadSettings(): TodoAppSettings {
if (!browser) return DEFAULT_SETTINGS;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
// Merge with defaults to handle new settings added in updates
return { ...DEFAULT_SETTINGS, ...parsed };
}
} catch (e) {
console.error('Failed to load todo settings:', e);
}
return DEFAULT_SETTINGS;
}
// Save settings to localStorage
function saveSettings(settings: TodoAppSettings) {
if (!browser) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (e) {
console.error('Failed to save todo settings:', e);
}
}
// State
let settings = $state<TodoAppSettings>(loadSettings());
export const todoSettings = {
// Full settings object
get settings() {
return settings;
},
// Task Behavior
get defaultPriority() {
return settings.defaultPriority;
},
get defaultDueTime() {
return settings.defaultDueTime;
},
get autoArchiveCompletedDays() {
return settings.autoArchiveCompletedDays;
},
get quickAddProject() {
return settings.quickAddProject;
},
// View & Display
get defaultView() {
return settings.defaultView;
},
get showTaskCounts() {
return settings.showTaskCounts;
},
get compactMode() {
return settings.compactMode;
},
get showSubtaskProgress() {
return settings.showSubtaskProgress;
},
get groupByProject() {
return settings.groupByProject;
},
// Kanban Board
get kanbanCardSize() {
return settings.kanbanCardSize;
},
get showLabelsOnCards() {
return settings.showLabelsOnCards;
},
get wipLimitPerColumn() {
return settings.wipLimitPerColumn;
},
// Notifications & Reminders
get defaultReminderMinutes() {
return settings.defaultReminderMinutes;
},
get dailyDigestEnabled() {
return settings.dailyDigestEnabled;
},
get overdueNotifications() {
return settings.overdueNotifications;
},
// Productivity
get focusMode() {
return settings.focusMode;
},
get pomodoroEnabled() {
return settings.pomodoroEnabled;
},
get dailyGoal() {
return settings.dailyGoal;
},
get showStreak() {
return settings.showStreak;
},
/**
* Initialize settings from localStorage
*/
initialize() {
if (!browser) return;
settings = loadSettings();
},
/**
* Update a single setting
*/
set<K extends keyof TodoAppSettings>(key: K, value: TodoAppSettings[K]) {
settings = { ...settings, [key]: value };
saveSettings(settings);
},
/**
* Update multiple settings at once
*/
update(updates: Partial<TodoAppSettings>) {
settings = { ...settings, ...updates };
saveSettings(settings);
},
/**
* Reset all settings to defaults
*/
reset() {
settings = { ...DEFAULT_SETTINGS };
saveSettings(settings);
},
/**
* Get default settings (for reference)
*/
getDefaults() {
return DEFAULT_SETTINGS;
},
};

View file

@ -3,24 +3,71 @@
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { todoSettings, type TodoView, type KanbanCardSize } from '$lib/stores/settings.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
import type { TaskPriority } from '@todo/shared';
import {
SettingsPage,
SettingsSection,
SettingsCard,
SettingsRow,
SettingsToggle,
SettingsSelect,
SettingsNumberInput,
SettingsTimeInput,
SettingsDangerZone,
SettingsDangerButton,
GlobalSettingsSection,
} from '@manacore/shared-ui';
// Options for selects
const priorityOptions = [
{ value: 'low', label: 'Niedrig' },
{ value: 'medium', label: 'Mittel' },
{ value: 'high', label: 'Hoch' },
{ value: 'urgent', label: 'Dringend' },
];
const viewOptions = [
{ value: 'inbox', label: 'Inbox' },
{ value: 'today', label: 'Heute' },
{ value: 'upcoming', label: 'Anstehend' },
{ value: 'kanban', label: 'Kanban' },
{ value: 'completed', label: 'Erledigt' },
];
const cardSizeOptions = [
{ value: 'compact', label: 'Kompakt' },
{ value: 'normal', label: 'Normal' },
{ value: 'large', label: 'Groß' },
];
const reminderOptions = [
{ value: null, label: 'Keine' },
{ value: 5, label: '5 Minuten' },
{ value: 15, label: '15 Minuten' },
{ value: 30, label: '30 Minuten' },
{ value: 60, label: '1 Stunde' },
{ value: 1440, label: '1 Tag' },
];
// Project options for quick add (computed)
let projectOptions = $derived([
{ value: null, label: 'Inbox' },
...projectsStore.projects.map((p) => ({ value: p.id, label: p.name })),
]);
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Load user settings from server
await userSettings.load();
// Load user settings and projects from server
await Promise.all([userSettings.load(), projectsStore.fetchProjects()]);
// Initialize todo settings from localStorage
todoSettings.initialize();
});
async function handleLogout() {
@ -84,6 +131,516 @@
<!-- Global Settings Section (synced across all apps) -->
<GlobalSettingsSection {userSettings} appId="todo" />
<!-- Task Behavior Section -->
<SettingsSection title="Task-Verhalten">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
{/snippet}
<SettingsCard>
<SettingsSelect
label="Standard-Priorität"
description="Priorität für neue Tasks"
options={priorityOptions}
value={todoSettings.defaultPriority}
onchange={(v: string | number | null) =>
todoSettings.set('defaultPriority', v as TaskPriority)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"
/>
</svg>
{/snippet}
</SettingsSelect>
<SettingsTimeInput
label="Standard-Uhrzeit"
description="Standard-Uhrzeit für Fälligkeiten"
value={todoSettings.defaultDueTime}
onchange={(v: string | null) => todoSettings.set('defaultDueTime', v)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{/snippet}
</SettingsTimeInput>
<SettingsSelect
label="Standard-Projekt"
description="Projekt für Quick-Add"
options={projectOptions}
value={todoSettings.quickAddProject}
onchange={(v: string | number | null) =>
todoSettings.set('quickAddProject', v as string | null)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
{/snippet}
</SettingsSelect>
<SettingsNumberInput
label="Auto-Archivierung"
description="Erledigte Tasks nach X Tagen archivieren"
value={todoSettings.autoArchiveCompletedDays}
onchange={(v: number | null) => todoSettings.set('autoArchiveCompletedDays', v)}
min={1}
max={365}
placeholder="Aus"
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
/>
</svg>
{/snippet}
</SettingsNumberInput>
<SettingsToggle
label="Löschen bestätigen"
description="Bestätigung vor dem Löschen von Tasks"
isOn={userSettings.general?.confirmOnDelete ?? true}
onToggle={(v) => userSettings.updateGeneral({ confirmOnDelete: v })}
border={false}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{/snippet}
</SettingsToggle>
</SettingsCard>
</SettingsSection>
<!-- View & Display Section -->
<SettingsSection title="Ansicht & Darstellung">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
/>
</svg>
{/snippet}
<SettingsCard>
<SettingsSelect
label="Startansicht"
description="Ansicht beim Öffnen der App"
options={viewOptions}
value={todoSettings.defaultView}
onchange={(v: string | number | null) => todoSettings.set('defaultView', v as TodoView)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
{/snippet}
</SettingsSelect>
<SettingsToggle
label="Task-Zähler"
description="Badge-Zähler in Navigation anzeigen"
isOn={todoSettings.showTaskCounts}
onToggle={(v) => todoSettings.set('showTaskCounts', v)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
/>
</svg>
{/snippet}
</SettingsToggle>
<SettingsToggle
label="Kompakter Modus"
description="Reduziertes Padding für mehr Tasks"
isOn={todoSettings.compactMode}
onToggle={(v) => todoSettings.set('compactMode', v)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
{/snippet}
</SettingsToggle>
<SettingsToggle
label="Subtask-Fortschritt"
description="Fortschrittsbalken für Subtasks anzeigen"
isOn={todoSettings.showSubtaskProgress}
onToggle={(v) => todoSettings.set('showSubtaskProgress', v)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
{/snippet}
</SettingsToggle>
<SettingsToggle
label="Nach Projekt gruppieren"
description="Tasks nach Projekt gruppieren"
isOn={todoSettings.groupByProject}
onToggle={(v) => todoSettings.set('groupByProject', v)}
border={false}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
{/snippet}
</SettingsToggle>
</SettingsCard>
</SettingsSection>
<!-- Kanban Section -->
<SettingsSection title="Kanban-Board">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"
/>
</svg>
{/snippet}
<SettingsCard>
<SettingsSelect
label="Kartengröße"
description="Größe der Kanban-Karten"
options={cardSizeOptions}
value={todoSettings.kanbanCardSize}
onchange={(v: string | number | null) =>
todoSettings.set('kanbanCardSize', v as KanbanCardSize)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"
/>
</svg>
{/snippet}
</SettingsSelect>
<SettingsToggle
label="Labels auf Karten"
description="Labels auf Kanban-Karten anzeigen"
isOn={todoSettings.showLabelsOnCards}
onToggle={(v) => todoSettings.set('showLabelsOnCards', v)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
{/snippet}
</SettingsToggle>
<SettingsNumberInput
label="WIP-Limit"
description="Max. Tasks pro Spalte (Work in Progress)"
value={todoSettings.wipLimitPerColumn}
onchange={(v: number | null) => todoSettings.set('wipLimitPerColumn', v)}
min={1}
max={50}
placeholder="Unbegrenzt"
border={false}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
{/snippet}
</SettingsNumberInput>
</SettingsCard>
</SettingsSection>
<!-- Notifications Section -->
<SettingsSection title="Benachrichtigungen">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{/snippet}
<SettingsCard>
<SettingsSelect
label="Standard-Erinnerung"
description="Erinnerung vor Fälligkeit"
options={reminderOptions}
value={todoSettings.defaultReminderMinutes}
onchange={(v: string | number | null) =>
todoSettings.set('defaultReminderMinutes', v as number | null)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{/snippet}
</SettingsSelect>
<SettingsToggle
label="Tägliche Zusammenfassung"
description="Tägliche E-Mail mit offenen Tasks"
isOn={todoSettings.dailyDigestEnabled}
onToggle={(v) => todoSettings.set('dailyDigestEnabled', v)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
{/snippet}
</SettingsToggle>
<SettingsToggle
label="Überfällige Tasks"
description="Benachrichtigung bei überfälligen Tasks"
isOn={todoSettings.overdueNotifications}
onToggle={(v) => todoSettings.set('overdueNotifications', v)}
border={false}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
{/snippet}
</SettingsToggle>
</SettingsCard>
</SettingsSection>
<!-- Productivity Section -->
<SettingsSection title="Produktivität">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
{/snippet}
<SettingsCard>
<SettingsToggle
label="Fokus-Modus"
description="Ablenkungsfreie Ansicht für konzentriertes Arbeiten"
isOn={todoSettings.focusMode}
onToggle={(v) => todoSettings.set('focusMode', v)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{/snippet}
</SettingsToggle>
<SettingsToggle
label="Pomodoro-Timer"
description="Eingebauter Pomodoro-Timer aktivieren"
isOn={todoSettings.pomodoroEnabled}
onToggle={(v) => todoSettings.set('pomodoroEnabled', v)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{/snippet}
</SettingsToggle>
<SettingsNumberInput
label="Tägliches Ziel"
description="Anzahl Tasks, die du pro Tag erledigen möchtest"
value={todoSettings.dailyGoal}
onchange={(v: number | null) => todoSettings.set('dailyGoal', v)}
min={1}
max={50}
placeholder="Kein Ziel"
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
/>
</svg>
{/snippet}
</SettingsNumberInput>
<SettingsToggle
label="Streak anzeigen"
description="Produktivitäts-Streak in der Sidebar anzeigen"
isOn={todoSettings.showStreak}
onToggle={(v) => todoSettings.set('showStreak', v)}
border={false}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.879 16.121A3 3 0 1012.015 11L11 14H9c0 .768.293 1.536.879 2.121z"
/>
</svg>
{/snippet}
</SettingsToggle>
</SettingsCard>
</SettingsSection>
<!-- Keyboard Shortcuts Section -->
<SettingsSection title="Tastenkürzel">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 14l9-5-9-5-9 5 9 5zm0 0l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14zm-4 6v-7.5l4-2.222"
/>
</svg>
{/snippet}
<SettingsCard>
<SettingsToggle
label="Tastenkürzel aktivieren"
description="Schnellzugriff mit Tastatur"
isOn={userSettings.general?.keyboardShortcutsEnabled ?? true}
onToggle={(v) => userSettings.updateGeneral({ keyboardShortcutsEnabled: v })}
border={false}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg>
{/snippet}
</SettingsToggle>
</SettingsCard>
</SettingsSection>
<!-- About Section -->
<SettingsSection title="Über">
{#snippet icon()}

View file

@ -244,6 +244,10 @@ export interface GeneralSettings {
weekStartsOn: WeekStartDay;
/** Master toggle for all app sounds */
soundsEnabled: boolean;
/** Show confirmation dialog before deleting items */
confirmOnDelete: boolean;
/** Enable keyboard shortcuts globally */
keyboardShortcutsEnabled: boolean;
}
/**
@ -290,6 +294,8 @@ export const DEFAULT_GENERAL_SETTINGS: GeneralSettings = {
startPages: {}, // Empty = use app defaults
weekStartsOn: 'monday',
soundsEnabled: true,
confirmOnDelete: true,
keyboardShortcutsEnabled: true,
};
/**

View file

@ -55,6 +55,9 @@ export {
SettingsCard,
SettingsRow,
SettingsToggle,
SettingsSelect,
SettingsNumberInput,
SettingsTimeInput,
SettingsDangerZone,
SettingsDangerButton,
GlobalSettingsSection,

View file

@ -0,0 +1,241 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Row label */
label: string;
/** Optional description */
description?: string;
/** Optional icon (Snippet for flexibility) */
icon?: Snippet;
/** Current value (null = empty/disabled) */
value: number | null;
/** Change handler */
onchange: (value: number | null) => void;
/** Minimum value */
min?: number;
/** Maximum value */
max?: number;
/** Step increment */
step?: number;
/** Placeholder text when empty */
placeholder?: string;
/** Show border at bottom */
border?: boolean;
/** Disabled state */
disabled?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
label,
description,
icon,
value,
onchange,
min,
max,
step = 1,
placeholder = '',
border = true,
disabled = false,
class: className = '',
}: Props = $props();
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
const rawValue = target.value.trim();
if (rawValue === '') {
onchange(null);
return;
}
const numValue = Number(rawValue);
if (!isNaN(numValue)) {
// Clamp to min/max if specified
let clampedValue = numValue;
if (min !== undefined && clampedValue < min) clampedValue = min;
if (max !== undefined && clampedValue > max) clampedValue = max;
onchange(clampedValue);
}
}
</script>
<div
class="settings-number {border ? 'settings-number--border' : ''} {disabled
? 'settings-number--disabled'
: ''} {className}"
>
<div class="settings-number__content">
{#if icon}
<span class="settings-number__icon">
{@render icon()}
</span>
{/if}
<div class="settings-number__text">
<span class="settings-number__label">{label}</span>
{#if description}
<span class="settings-number__description">{description}</span>
{/if}
</div>
</div>
<input
type="number"
class="settings-number__input"
value={value ?? ''}
oninput={handleInput}
{min}
{max}
{step}
{placeholder}
{disabled}
aria-label={label}
/>
</div>
<style>
.settings-number {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
}
.settings-number--border {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .settings-number--border {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.settings-number--border:last-child {
border-bottom: none;
}
.settings-number--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.settings-number__content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.settings-number__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: rgba(0, 0, 0, 0.04);
color: hsl(var(--primary));
}
:global(.dark) .settings-number__icon {
background: rgba(255, 255, 255, 0.08);
}
.settings-number__icon :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.settings-number__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.settings-number__label {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .settings-number__label {
color: #f3f4f6;
}
.settings-number__description {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.4;
}
:global(.dark) .settings-number__description {
color: #9ca3af;
}
/* Number Input - Glass style */
.settings-number__input {
width: 5rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
text-align: center;
background-color: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
transition: all 0.2s ease;
}
:global(.dark) .settings-number__input {
color: #f3f4f6;
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
}
.settings-number__input:hover:not(:disabled) {
border-color: rgba(0, 0, 0, 0.2);
background-color: rgba(0, 0, 0, 0.06);
}
:global(.dark) .settings-number__input:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.25);
background-color: rgba(255, 255, 255, 0.12);
}
.settings-number__input:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.4);
outline-offset: 2px;
border-color: hsl(var(--primary));
}
.settings-number__input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.settings-number__input::placeholder {
color: #9ca3af;
font-weight: 400;
}
:global(.dark) .settings-number__input::placeholder {
color: #6b7280;
}
/* Hide spinner buttons */
.settings-number__input::-webkit-outer-spin-button,
.settings-number__input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.settings-number__input[type='number'] {
-moz-appearance: textfield;
}
</style>

View file

@ -0,0 +1,232 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface SelectOption {
value: string | number | null;
label: string;
}
interface Props {
/** Row label */
label: string;
/** Optional description */
description?: string;
/** Optional icon (Snippet for flexibility) */
icon?: Snippet;
/** Available options */
options: SelectOption[];
/** Current selected value */
value: string | number | null;
/** Change handler */
onchange: (value: string | number | null) => void;
/** Show border at bottom */
border?: boolean;
/** Disabled state */
disabled?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
label,
description,
icon,
options,
value,
onchange,
border = true,
disabled = false,
class: className = '',
}: Props = $props();
function handleChange(event: Event) {
const target = event.target as HTMLSelectElement;
const rawValue = target.value;
// Handle null values (stored as empty string in select)
if (rawValue === '') {
onchange(null);
return;
}
// Try to parse as number if the original options had numbers
const numValue = Number(rawValue);
if (!isNaN(numValue) && options.some((o) => o.value === numValue)) {
onchange(numValue);
} else {
onchange(rawValue);
}
}
</script>
<div
class="settings-select {border ? 'settings-select--border' : ''} {disabled
? 'settings-select--disabled'
: ''} {className}"
>
<div class="settings-select__content">
{#if icon}
<span class="settings-select__icon">
{@render icon()}
</span>
{/if}
<div class="settings-select__text">
<span class="settings-select__label">{label}</span>
{#if description}
<span class="settings-select__description">{description}</span>
{/if}
</div>
</div>
<select
class="settings-select__dropdown"
value={value ?? ''}
onchange={handleChange}
{disabled}
aria-label={label}
>
{#each options as option}
<option value={option.value ?? ''}>{option.label}</option>
{/each}
</select>
</div>
<style>
.settings-select {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
}
.settings-select--border {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .settings-select--border {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.settings-select--border:last-child {
border-bottom: none;
}
.settings-select--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.settings-select__content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.settings-select__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: rgba(0, 0, 0, 0.04);
color: hsl(var(--primary));
}
:global(.dark) .settings-select__icon {
background: rgba(255, 255, 255, 0.08);
}
.settings-select__icon :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.settings-select__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.settings-select__label {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .settings-select__label {
color: #f3f4f6;
}
.settings-select__description {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.4;
}
:global(.dark) .settings-select__description {
color: #9ca3af;
}
/* Select Dropdown - Glass style */
.settings-select__dropdown {
min-width: 8rem;
padding: 0.5rem 2rem 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
background-color: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 1rem;
transition: all 0.2s ease;
}
:global(.dark) .settings-select__dropdown {
color: #f3f4f6;
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
}
.settings-select__dropdown:hover:not(:disabled) {
border-color: rgba(0, 0, 0, 0.2);
background-color: rgba(0, 0, 0, 0.06);
}
:global(.dark) .settings-select__dropdown:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.25);
background-color: rgba(255, 255, 255, 0.12);
}
.settings-select__dropdown:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.4);
outline-offset: 2px;
border-color: hsl(var(--primary));
}
.settings-select__dropdown:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.settings-select__dropdown option {
background-color: white;
color: #374151;
}
:global(.dark) .settings-select__dropdown option {
background-color: #1f2937;
color: #f3f4f6;
}
</style>

View file

@ -0,0 +1,287 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Row label */
label: string;
/** Optional description */
description?: string;
/** Optional icon (Snippet for flexibility) */
icon?: Snippet;
/** Current value (HH:mm format, null = not set) */
value: string | null;
/** Change handler */
onchange: (value: string | null) => void;
/** Placeholder text when empty */
placeholder?: string;
/** Show border at bottom */
border?: boolean;
/** Disabled state */
disabled?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
label,
description,
icon,
value,
onchange,
placeholder = '--:--',
border = true,
disabled = false,
class: className = '',
}: Props = $props();
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
const rawValue = target.value.trim();
if (rawValue === '') {
onchange(null);
return;
}
// Validate HH:mm format
if (/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/.test(rawValue)) {
onchange(rawValue);
}
}
function handleClear() {
onchange(null);
}
</script>
<div
class="settings-time {border ? 'settings-time--border' : ''} {disabled
? 'settings-time--disabled'
: ''} {className}"
>
<div class="settings-time__content">
{#if icon}
<span class="settings-time__icon">
{@render icon()}
</span>
{/if}
<div class="settings-time__text">
<span class="settings-time__label">{label}</span>
{#if description}
<span class="settings-time__description">{description}</span>
{/if}
</div>
</div>
<div class="settings-time__input-wrapper">
<input
type="time"
class="settings-time__input"
value={value ?? ''}
oninput={handleInput}
{placeholder}
{disabled}
aria-label={label}
/>
{#if value}
<button
type="button"
class="settings-time__clear"
onclick={handleClear}
aria-label="Clear time"
{disabled}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
{/if}
</div>
</div>
<style>
.settings-time {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
}
.settings-time--border {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .settings-time--border {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.settings-time--border:last-child {
border-bottom: none;
}
.settings-time--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.settings-time__content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.settings-time__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: rgba(0, 0, 0, 0.04);
color: hsl(var(--primary));
}
:global(.dark) .settings-time__icon {
background: rgba(255, 255, 255, 0.08);
}
.settings-time__icon :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.settings-time__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.settings-time__label {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .settings-time__label {
color: #f3f4f6;
}
.settings-time__description {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.4;
}
:global(.dark) .settings-time__description {
color: #9ca3af;
}
/* Input wrapper */
.settings-time__input-wrapper {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Time Input - Glass style */
.settings-time__input {
width: 6rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
text-align: center;
background-color: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
transition: all 0.2s ease;
}
:global(.dark) .settings-time__input {
color: #f3f4f6;
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
}
.settings-time__input:hover:not(:disabled) {
border-color: rgba(0, 0, 0, 0.2);
background-color: rgba(0, 0, 0, 0.06);
}
:global(.dark) .settings-time__input:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.25);
background-color: rgba(255, 255, 255, 0.12);
}
.settings-time__input:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.4);
outline-offset: 2px;
border-color: hsl(var(--primary));
}
.settings-time__input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* Clear button */
.settings-time__clear {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
color: #6b7280;
background: transparent;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s ease;
}
.settings-time__clear:hover:not(:disabled) {
color: #ef4444;
background-color: rgba(239, 68, 68, 0.1);
}
:global(.dark) .settings-time__clear {
color: #9ca3af;
}
:global(.dark) .settings-time__clear:hover:not(:disabled) {
color: #ef4444;
background-color: rgba(239, 68, 68, 0.2);
}
.settings-time__clear:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Style the time input appearance */
.settings-time__input::-webkit-calendar-picker-indicator {
filter: invert(0.5);
cursor: pointer;
}
:global(.dark) .settings-time__input::-webkit-calendar-picker-indicator {
filter: invert(0.7);
}
</style>

View file

@ -4,6 +4,9 @@ export { default as SettingsSection } from './SettingsSection.svelte';
export { default as SettingsCard } from './SettingsCard.svelte';
export { default as SettingsRow } from './SettingsRow.svelte';
export { default as SettingsToggle } from './SettingsToggle.svelte';
export { default as SettingsSelect } from './SettingsSelect.svelte';
export { default as SettingsNumberInput } from './SettingsNumberInput.svelte';
export { default as SettingsTimeInput } from './SettingsTimeInput.svelte';
export { default as SettingsDangerZone } from './SettingsDangerZone.svelte';
export { default as SettingsDangerButton } from './SettingsDangerButton.svelte';
export { default as GlobalSettingsSection } from './GlobalSettingsSection.svelte';