mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 05:21:10 +02:00
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:
parent
00dadc97d1
commit
863dd621f5
8 changed files with 1570 additions and 2 deletions
239
apps/todo/apps/web/src/lib/stores/settings.svelte.ts
Normal file
239
apps/todo/apps/web/src/lib/stores/settings.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ export {
|
|||
SettingsCard,
|
||||
SettingsRow,
|
||||
SettingsToggle,
|
||||
SettingsSelect,
|
||||
SettingsNumberInput,
|
||||
SettingsTimeInput,
|
||||
SettingsDangerZone,
|
||||
SettingsDangerButton,
|
||||
GlobalSettingsSection,
|
||||
|
|
|
|||
241
packages/shared-ui/src/settings/SettingsNumberInput.svelte
Normal file
241
packages/shared-ui/src/settings/SettingsNumberInput.svelte
Normal 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>
|
||||
232
packages/shared-ui/src/settings/SettingsSelect.svelte
Normal file
232
packages/shared-ui/src/settings/SettingsSelect.svelte
Normal 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>
|
||||
287
packages/shared-ui/src/settings/SettingsTimeInput.svelte
Normal file
287
packages/shared-ui/src/settings/SettingsTimeInput.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue