mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
feat(manacore/web): refactor todo page into modular components with i18n
Extract the monolithic todo page into reusable components (TaskList, TaskItem, TaskEditModal, QuickAddTask, TodoToolbar, etc.), add new stores (reminders, contacts, settings, minimized-pages), composables (useTaskForm), board view components, skeleton loading states, and a settings page. Add todo i18n strings for de/en/es/fr. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3b5f77dd86
commit
f408d70461
45 changed files with 4169 additions and 376 deletions
|
|
@ -187,6 +187,187 @@
|
|||
"invoices": "Rechnungen",
|
||||
"no_invoices": "Noch keine Rechnungen vorhanden"
|
||||
},
|
||||
"todo": {
|
||||
"title": "Todo",
|
||||
"tasks": "Aufgaben",
|
||||
"completed": "erledigt",
|
||||
"overdue": "überfällig",
|
||||
"today": "heute",
|
||||
"inbox": "Inbox",
|
||||
"todayView": "Heute",
|
||||
"upcoming": "Bald fällig",
|
||||
"completedView": "Erledigt",
|
||||
"search": "Suche",
|
||||
"newTask": "Neue Aufgabe",
|
||||
"quickAddPlaceholder": "z.B. 'Meeting morgen um 14 Uhr !hoch #wichtig'",
|
||||
"addTask": "Hinzufügen",
|
||||
"cancel": "Abb.",
|
||||
"syntaxHelp": "Syntax-Hilfe",
|
||||
"noTasks": "Keine Aufgaben",
|
||||
"noTasksInbox": "Inbox ist leer",
|
||||
"noTasksToday": "Keine Aufgaben für heute",
|
||||
"noTasksUpcoming": "Keine anstehenden Aufgaben",
|
||||
"noTasksCompleted": "Noch keine Aufgaben erledigt",
|
||||
"firstTaskHint": "Erstelle deine erste Aufgabe mit dem + Button oben.",
|
||||
"edit": "Bearbeiten",
|
||||
"markDone": "Erledigen",
|
||||
"reopen": "Wieder öffnen",
|
||||
"deleteConfirm": "Wirklich löschen?",
|
||||
"yesDelete": "Ja, löschen",
|
||||
"save": "Speichern",
|
||||
"close": "Schließen",
|
||||
"description": "Beschreibung",
|
||||
"addDescription": "Beschreibung hinzufügen...",
|
||||
"subtasks": "Teilaufgaben",
|
||||
"addSubtask": "Teilaufgabe hinzufügen...",
|
||||
"status": "Status",
|
||||
"statusPending": "Offen",
|
||||
"statusInProgress": "In Arbeit",
|
||||
"statusCompleted": "Erledigt",
|
||||
"statusCancelled": "Abgebrochen",
|
||||
"priority": "Priorität",
|
||||
"priorityUrgent": "Dringend",
|
||||
"priorityHigh": "Hoch",
|
||||
"priorityMedium": "Mittel",
|
||||
"priorityLow": "Niedrig",
|
||||
"dueDate": "Fällig",
|
||||
"time": "Uhrzeit",
|
||||
"startDate": "Start",
|
||||
"recurrence": "Wiederholung",
|
||||
"recurrenceNone": "Keine",
|
||||
"recurrenceDaily": "Täglich",
|
||||
"recurrenceWeekly": "Wöchentlich",
|
||||
"recurrenceMonthly": "Monatlich",
|
||||
"recurrenceYearly": "Jährlich",
|
||||
"reminder": "Erinnerung",
|
||||
"reminderNone": "Keine",
|
||||
"tags": "Tags",
|
||||
"storypoints": "Punkte",
|
||||
"duration": "Dauer",
|
||||
"fun": "Spaß",
|
||||
"projects": "Projekte",
|
||||
"labels": "Labels",
|
||||
"sort": "Sortierung",
|
||||
"sortManual": "Manuell",
|
||||
"sortDueDate": "Fälligkeit",
|
||||
"sortPriority": "Priorität",
|
||||
"sortName": "Name",
|
||||
"sortCreated": "Erstellt",
|
||||
"showCompleted": "Erledigt",
|
||||
"boardView": "Board",
|
||||
"listView": "Liste",
|
||||
"board": {
|
||||
"new": "Neues Board",
|
||||
"edit": "Board bearbeiten",
|
||||
"create": "Board erstellen",
|
||||
"name": "Board-Name...",
|
||||
"groupBy": "Gruppierung",
|
||||
"layout": "Layout",
|
||||
"columns": "Spalten",
|
||||
"addColumn": "Spalte",
|
||||
"columnName": "Spaltenname...",
|
||||
"delete": "Löschen",
|
||||
"noTasks": "Keine Aufgaben",
|
||||
"groupStatus": "Status",
|
||||
"groupPriority": "Priorität",
|
||||
"groupDueDate": "Fälligkeit",
|
||||
"groupTag": "Tag",
|
||||
"groupCustom": "Benutzerdefiniert",
|
||||
"layoutKanban": "Kanban",
|
||||
"layoutGrid": "Grid",
|
||||
"layoutFocus": "Fokus"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Todo Einstellungen",
|
||||
"taskBehavior": "Aufgaben-Verhalten",
|
||||
"defaultPriority": "Standard-Priorität",
|
||||
"defaultDueTime": "Standard-Fälligkeit",
|
||||
"autoArchive": "Auto-Archivierung (Tage)",
|
||||
"viewDisplay": "Ansicht & Darstellung",
|
||||
"defaultView": "Standard-Ansicht",
|
||||
"compactMode": "Kompaktmodus",
|
||||
"showTaskCounts": "Aufgabenzahl anzeigen",
|
||||
"showSubtaskProgress": "Teilaufgaben-Fortschritt",
|
||||
"groupByProject": "Nach Projekt gruppieren",
|
||||
"kanbanSettings": "Kanban Board",
|
||||
"cardSize": "Kartengröße",
|
||||
"cardSizeCompact": "Kompakt",
|
||||
"cardSizeNormal": "Normal",
|
||||
"cardSizeLarge": "Groß",
|
||||
"showLabelsOnCards": "Labels auf Karten",
|
||||
"wipLimit": "WIP-Limit pro Spalte",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"defaultReminder": "Standard-Erinnerung",
|
||||
"dailyDigest": "Tägliche Zusammenfassung",
|
||||
"overdueNotifications": "Überfällig-Benachrichtigungen",
|
||||
"smartDuration": "Smarte Dauer",
|
||||
"smartDurationEnabled": "Smarte Dauer-Schätzung",
|
||||
"defaultTaskDuration": "Standard-Dauer (Min.)",
|
||||
"productivity": "Produktivität",
|
||||
"focusMode": "Fokus-Modus",
|
||||
"pomodoro": "Pomodoro",
|
||||
"dailyGoal": "Tagesziel",
|
||||
"showStreak": "Streak anzeigen",
|
||||
"immersiveMode": "Immersiver Modus",
|
||||
"reset": "Einstellungen zurücksetzen"
|
||||
},
|
||||
"syntaxHelpContent": {
|
||||
"title": "Quick-Add Syntax",
|
||||
"date": "Datum: heute, morgen, nächsten Montag, 15.12.",
|
||||
"time": "Uhrzeit: um 14 Uhr, 14:00",
|
||||
"priority": "Priorität: !hoch, !niedrig, !dringend, !!!",
|
||||
"labels": "Labels: #wichtig #idee",
|
||||
"duration": "Dauer: 30min, 2h, 1.5 Stunden",
|
||||
"recurrence": "Wiederholung: jeden Tag, wöchentlich",
|
||||
"multi": "Mehrere: Task1, danach Task2",
|
||||
"subtasks": "Subtasks: Titel: item1, item2, item3"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Willkommen bei Todo!",
|
||||
"intro": "Dein persönlicher Aufgabenplaner mit Kanban-Boards, smarter Eingabe und mehr.",
|
||||
"step1Title": "Natürliche Sprache",
|
||||
"step1": "Erstelle Aufgaben wie \"Meeting morgen um 14 Uhr !hoch #arbeit\" — Datum, Priorität und Labels werden automatisch erkannt.",
|
||||
"step2Title": "Kanban-Boards",
|
||||
"step2": "Organisiere deine Aufgaben visuell mit verschiedenen Board-Ansichten: Status, Priorität oder benutzerdefiniert.",
|
||||
"step3Title": "Los geht's!",
|
||||
"step3": "Erstelle deine erste Aufgabe mit dem + Button. Tipps findest du jederzeit über das ? Symbol.",
|
||||
"next": "Weiter",
|
||||
"letsGo": "Los geht's",
|
||||
"getStarted": "Los geht's"
|
||||
},
|
||||
"syntaxHelp": {
|
||||
"title": "Quick-Add Syntax",
|
||||
"description": "Nutze natürliche Sprache, um Aufgaben schnell zu erstellen. Alle Muster werden automatisch erkannt.",
|
||||
"date": "Datum",
|
||||
"dateToday": "Heute",
|
||||
"dateTomorrow": "Morgen",
|
||||
"dateNextWeekday": "Nächster Wochentag",
|
||||
"dateSpecific": "Bestimmtes Datum",
|
||||
"time": "Uhrzeit",
|
||||
"priority": "Priorität",
|
||||
"priorityUrgent": "Dringend",
|
||||
"priorityHigh": "Hoch",
|
||||
"priorityLow": "Niedrig",
|
||||
"labels": "Labels",
|
||||
"labelsAdd": "Tags hinzufügen",
|
||||
"duration": "Dauer",
|
||||
"duration30m": "30 Minuten",
|
||||
"duration2h": "2 Stunden",
|
||||
"duration90m": "90 Minuten",
|
||||
"recurrence": "Wiederholung",
|
||||
"recurrenceDaily": "Täglich",
|
||||
"recurrenceWeekly": "Wöchentlich",
|
||||
"recurrenceMonthly": "Monatlich",
|
||||
"multiTask": "Mehrere Aufgaben",
|
||||
"multiTaskChain": "Aufgaben verketten",
|
||||
"multiTaskSemicolon": "Mit Semikolon trennen",
|
||||
"subtasks": "Subtasks",
|
||||
"subtasksColonComma": "Doppelpunkt + Komma",
|
||||
"exampleTitle": "Beispiel",
|
||||
"exampleInput": "Meeting morgen um 14 Uhr 1h !hoch #arbeit",
|
||||
"exampleOutput": "→ \"Meeting\" am nächsten Tag um 14:00, Dauer 1h, Priorität Hoch, Label \"arbeit\""
|
||||
}
|
||||
},
|
||||
"app_slider": {
|
||||
"title": "Teil des Mana Ökosystems",
|
||||
"memoro_desc": "KI-gestützte Sprachnotizen",
|
||||
|
|
|
|||
|
|
@ -187,6 +187,187 @@
|
|||
"invoices": "Invoices",
|
||||
"no_invoices": "No invoices yet"
|
||||
},
|
||||
"todo": {
|
||||
"title": "Todo",
|
||||
"tasks": "Tasks",
|
||||
"completed": "completed",
|
||||
"overdue": "overdue",
|
||||
"today": "today",
|
||||
"inbox": "Inbox",
|
||||
"todayView": "Today",
|
||||
"upcoming": "Upcoming",
|
||||
"completedView": "Completed",
|
||||
"search": "Search",
|
||||
"newTask": "New task",
|
||||
"quickAddPlaceholder": "e.g. 'Meeting tomorrow at 2pm !high #important'",
|
||||
"addTask": "Add",
|
||||
"cancel": "Cancel",
|
||||
"syntaxHelp": "Syntax help",
|
||||
"noTasks": "No tasks",
|
||||
"noTasksInbox": "Inbox is empty",
|
||||
"noTasksToday": "No tasks for today",
|
||||
"noTasksUpcoming": "No upcoming tasks",
|
||||
"noTasksCompleted": "No tasks completed yet",
|
||||
"firstTaskHint": "Create your first task with the + button above.",
|
||||
"edit": "Edit",
|
||||
"markDone": "Complete",
|
||||
"reopen": "Reopen",
|
||||
"deleteConfirm": "Really delete?",
|
||||
"yesDelete": "Yes, delete",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"description": "Description",
|
||||
"addDescription": "Add description...",
|
||||
"subtasks": "Subtasks",
|
||||
"addSubtask": "Add subtask...",
|
||||
"status": "Status",
|
||||
"statusPending": "Open",
|
||||
"statusInProgress": "In Progress",
|
||||
"statusCompleted": "Completed",
|
||||
"statusCancelled": "Cancelled",
|
||||
"priority": "Priority",
|
||||
"priorityUrgent": "Urgent",
|
||||
"priorityHigh": "High",
|
||||
"priorityMedium": "Medium",
|
||||
"priorityLow": "Low",
|
||||
"dueDate": "Due",
|
||||
"time": "Time",
|
||||
"startDate": "Start",
|
||||
"recurrence": "Recurrence",
|
||||
"recurrenceNone": "None",
|
||||
"recurrenceDaily": "Daily",
|
||||
"recurrenceWeekly": "Weekly",
|
||||
"recurrenceMonthly": "Monthly",
|
||||
"recurrenceYearly": "Yearly",
|
||||
"reminder": "Reminder",
|
||||
"reminderNone": "None",
|
||||
"tags": "Tags",
|
||||
"storypoints": "Points",
|
||||
"duration": "Duration",
|
||||
"fun": "Fun",
|
||||
"projects": "Projects",
|
||||
"labels": "Labels",
|
||||
"sort": "Sort",
|
||||
"sortManual": "Manual",
|
||||
"sortDueDate": "Due date",
|
||||
"sortPriority": "Priority",
|
||||
"sortName": "Name",
|
||||
"sortCreated": "Created",
|
||||
"showCompleted": "Completed",
|
||||
"boardView": "Board",
|
||||
"listView": "List",
|
||||
"board": {
|
||||
"new": "New board",
|
||||
"edit": "Edit board",
|
||||
"create": "Create board",
|
||||
"name": "Board name...",
|
||||
"groupBy": "Group by",
|
||||
"layout": "Layout",
|
||||
"columns": "Columns",
|
||||
"addColumn": "Column",
|
||||
"columnName": "Column name...",
|
||||
"delete": "Delete",
|
||||
"noTasks": "No tasks",
|
||||
"groupStatus": "Status",
|
||||
"groupPriority": "Priority",
|
||||
"groupDueDate": "Due date",
|
||||
"groupTag": "Tag",
|
||||
"groupCustom": "Custom",
|
||||
"layoutKanban": "Kanban",
|
||||
"layoutGrid": "Grid",
|
||||
"layoutFocus": "Focus"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Todo Settings",
|
||||
"taskBehavior": "Task Behavior",
|
||||
"defaultPriority": "Default priority",
|
||||
"defaultDueTime": "Default due time",
|
||||
"autoArchive": "Auto-archive (days)",
|
||||
"viewDisplay": "View & Display",
|
||||
"defaultView": "Default view",
|
||||
"compactMode": "Compact mode",
|
||||
"showTaskCounts": "Show task counts",
|
||||
"showSubtaskProgress": "Subtask progress",
|
||||
"groupByProject": "Group by project",
|
||||
"kanbanSettings": "Kanban Board",
|
||||
"cardSize": "Card size",
|
||||
"cardSizeCompact": "Compact",
|
||||
"cardSizeNormal": "Normal",
|
||||
"cardSizeLarge": "Large",
|
||||
"showLabelsOnCards": "Labels on cards",
|
||||
"wipLimit": "WIP limit per column",
|
||||
"notifications": "Notifications",
|
||||
"defaultReminder": "Default reminder",
|
||||
"dailyDigest": "Daily digest",
|
||||
"overdueNotifications": "Overdue notifications",
|
||||
"smartDuration": "Smart Duration",
|
||||
"smartDurationEnabled": "Smart duration estimation",
|
||||
"defaultTaskDuration": "Default duration (min)",
|
||||
"productivity": "Productivity",
|
||||
"focusMode": "Focus mode",
|
||||
"pomodoro": "Pomodoro",
|
||||
"dailyGoal": "Daily goal",
|
||||
"showStreak": "Show streak",
|
||||
"immersiveMode": "Immersive mode",
|
||||
"reset": "Reset settings"
|
||||
},
|
||||
"syntaxHelpContent": {
|
||||
"title": "Quick-Add Syntax",
|
||||
"date": "Date: today, tomorrow, next Monday, Dec 15",
|
||||
"time": "Time: at 2pm, 14:00",
|
||||
"priority": "Priority: !high, !low, !urgent, !!!",
|
||||
"labels": "Labels: #important #idea",
|
||||
"duration": "Duration: 30min, 2h, 1.5 hours",
|
||||
"recurrence": "Recurrence: every day, weekly",
|
||||
"multi": "Multiple: Task1, then Task2",
|
||||
"subtasks": "Subtasks: Title: item1, item2, item3"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Welcome to Todo!",
|
||||
"intro": "Your personal task planner with Kanban boards, smart input, and more.",
|
||||
"step1Title": "Natural Language",
|
||||
"step1": "Create tasks like \"Meeting tomorrow at 2pm !high #work\" — date, priority and labels are detected automatically.",
|
||||
"step2Title": "Kanban Boards",
|
||||
"step2": "Organize your tasks visually with different board views: status, priority, or custom.",
|
||||
"step3Title": "Let's go!",
|
||||
"step3": "Create your first task with the + button. Tips are always available via the ? icon.",
|
||||
"next": "Next",
|
||||
"letsGo": "Let's go",
|
||||
"getStarted": "Get started"
|
||||
},
|
||||
"syntaxHelp": {
|
||||
"title": "Quick-Add Syntax",
|
||||
"description": "Use natural language to quickly create tasks. All patterns are automatically detected.",
|
||||
"date": "Date",
|
||||
"dateToday": "Today",
|
||||
"dateTomorrow": "Tomorrow",
|
||||
"dateNextWeekday": "Next weekday",
|
||||
"dateSpecific": "Specific date",
|
||||
"time": "Time",
|
||||
"priority": "Priority",
|
||||
"priorityUrgent": "Urgent",
|
||||
"priorityHigh": "High",
|
||||
"priorityLow": "Low",
|
||||
"labels": "Labels",
|
||||
"labelsAdd": "Add tags",
|
||||
"duration": "Duration",
|
||||
"duration30m": "30 minutes",
|
||||
"duration2h": "2 hours",
|
||||
"duration90m": "90 minutes",
|
||||
"recurrence": "Recurrence",
|
||||
"recurrenceDaily": "Daily",
|
||||
"recurrenceWeekly": "Weekly",
|
||||
"recurrenceMonthly": "Monthly",
|
||||
"multiTask": "Multiple tasks",
|
||||
"multiTaskChain": "Chain tasks",
|
||||
"multiTaskSemicolon": "Separate with semicolons",
|
||||
"subtasks": "Subtasks",
|
||||
"subtasksColonComma": "Colon + comma",
|
||||
"exampleTitle": "Example",
|
||||
"exampleInput": "Meeting tomorrow at 2pm 1h !high #work",
|
||||
"exampleOutput": "→ \"Meeting\" tomorrow at 14:00, duration 1h, priority High, label \"work\""
|
||||
}
|
||||
},
|
||||
"app_slider": {
|
||||
"title": "Part of the Mana Ecosystem",
|
||||
"memoro_desc": "AI-powered voice notes",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,145 @@
|
|||
"back": "Atrás",
|
||||
"loading": "Cargando..."
|
||||
},
|
||||
"todo": {
|
||||
"title": "Tareas",
|
||||
"tasks": "Tareas",
|
||||
"completed": "completadas",
|
||||
"overdue": "vencidas",
|
||||
"today": "hoy",
|
||||
"inbox": "Bandeja",
|
||||
"todayView": "Hoy",
|
||||
"upcoming": "Próximas",
|
||||
"completedView": "Completadas",
|
||||
"search": "Buscar",
|
||||
"newTask": "Nueva tarea",
|
||||
"quickAddPlaceholder": "ej. 'Reunión mañana a las 14h !alta #importante'",
|
||||
"addTask": "Añadir",
|
||||
"cancel": "Canc.",
|
||||
"syntaxHelp": "Ayuda sintaxis",
|
||||
"noTasks": "Sin tareas",
|
||||
"noTasksInbox": "Bandeja vacía",
|
||||
"noTasksToday": "Sin tareas para hoy",
|
||||
"noTasksUpcoming": "Sin tareas próximas",
|
||||
"noTasksCompleted": "Ninguna tarea completada",
|
||||
"firstTaskHint": "Crea tu primera tarea con el botón + arriba.",
|
||||
"edit": "Editar",
|
||||
"markDone": "Completar",
|
||||
"reopen": "Reabrir",
|
||||
"deleteConfirm": "¿Eliminar?",
|
||||
"yesDelete": "Sí, eliminar",
|
||||
"save": "Guardar",
|
||||
"close": "Cerrar",
|
||||
"description": "Descripción",
|
||||
"addDescription": "Añadir descripción...",
|
||||
"subtasks": "Subtareas",
|
||||
"addSubtask": "Añadir subtarea...",
|
||||
"status": "Estado",
|
||||
"statusPending": "Abierta",
|
||||
"statusInProgress": "En curso",
|
||||
"statusCompleted": "Completada",
|
||||
"statusCancelled": "Cancelada",
|
||||
"priority": "Prioridad",
|
||||
"priorityUrgent": "Urgente",
|
||||
"priorityHigh": "Alta",
|
||||
"priorityMedium": "Media",
|
||||
"priorityLow": "Baja",
|
||||
"dueDate": "Vence",
|
||||
"time": "Hora",
|
||||
"startDate": "Inicio",
|
||||
"recurrence": "Recurrencia",
|
||||
"recurrenceNone": "Ninguna",
|
||||
"recurrenceDaily": "Diario",
|
||||
"recurrenceWeekly": "Semanal",
|
||||
"recurrenceMonthly": "Mensual",
|
||||
"recurrenceYearly": "Anual",
|
||||
"reminder": "Recordatorio",
|
||||
"reminderNone": "Ninguno",
|
||||
"tags": "Tags",
|
||||
"storypoints": "Puntos",
|
||||
"duration": "Duración",
|
||||
"fun": "Diversión",
|
||||
"projects": "Proyectos",
|
||||
"labels": "Etiquetas",
|
||||
"sort": "Ordenar",
|
||||
"sortManual": "Manual",
|
||||
"sortDueDate": "Vencimiento",
|
||||
"sortPriority": "Prioridad",
|
||||
"sortName": "Nombre",
|
||||
"sortCreated": "Creación",
|
||||
"showCompleted": "Completadas",
|
||||
"boardView": "Board",
|
||||
"listView": "Lista",
|
||||
"board": {
|
||||
"new": "Nuevo board",
|
||||
"edit": "Editar board",
|
||||
"create": "Crear board",
|
||||
"name": "Nombre del board...",
|
||||
"groupBy": "Agrupar por",
|
||||
"layout": "Diseño",
|
||||
"columns": "Columnas",
|
||||
"addColumn": "Columna",
|
||||
"columnName": "Nombre de columna...",
|
||||
"delete": "Eliminar",
|
||||
"noTasks": "Sin tareas",
|
||||
"groupStatus": "Estado",
|
||||
"groupPriority": "Prioridad",
|
||||
"groupDueDate": "Vencimiento",
|
||||
"groupTag": "Tag",
|
||||
"groupCustom": "Personalizado",
|
||||
"layoutKanban": "Kanban",
|
||||
"layoutGrid": "Cuadrícula",
|
||||
"layoutFocus": "Enfoque"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes Todo"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "¡Bienvenido a Todo!",
|
||||
"intro": "Tu planificador de tareas personal con boards Kanban, entrada inteligente y más.",
|
||||
"step1Title": "Lenguaje natural",
|
||||
"step1": "Crea tareas como \"Reunión mañana a las 14h !alta #trabajo\" — fecha, prioridad y etiquetas se detectan automáticamente.",
|
||||
"step2Title": "Boards Kanban",
|
||||
"step2": "Organiza tus tareas visualmente con diferentes vistas: estado, prioridad o personalizado.",
|
||||
"step3Title": "¡Vamos!",
|
||||
"step3": "Crea tu primera tarea con el botón +. Los consejos están disponibles en el icono ?.",
|
||||
"next": "Siguiente",
|
||||
"letsGo": "¡Vamos!",
|
||||
"getStarted": "Empezar"
|
||||
},
|
||||
"syntaxHelp": {
|
||||
"title": "Sintaxis Quick-Add",
|
||||
"description": "Usa lenguaje natural para crear tareas rápidamente.",
|
||||
"date": "Fecha",
|
||||
"dateToday": "Hoy",
|
||||
"dateTomorrow": "Mañana",
|
||||
"dateNextWeekday": "Próximo día",
|
||||
"dateSpecific": "Fecha específica",
|
||||
"time": "Hora",
|
||||
"priority": "Prioridad",
|
||||
"priorityUrgent": "Urgente",
|
||||
"priorityHigh": "Alta",
|
||||
"priorityLow": "Baja",
|
||||
"labels": "Etiquetas",
|
||||
"labelsAdd": "Añadir tags",
|
||||
"duration": "Duración",
|
||||
"duration30m": "30 minutos",
|
||||
"duration2h": "2 horas",
|
||||
"duration90m": "90 minutos",
|
||||
"recurrence": "Recurrencia",
|
||||
"recurrenceDaily": "Diario",
|
||||
"recurrenceWeekly": "Semanal",
|
||||
"recurrenceMonthly": "Mensual",
|
||||
"multiTask": "Múltiples tareas",
|
||||
"multiTaskChain": "Encadenar tareas",
|
||||
"multiTaskSemicolon": "Separar con punto y coma",
|
||||
"subtasks": "Subtareas",
|
||||
"subtasksColonComma": "Dos puntos + coma",
|
||||
"exampleTitle": "Ejemplo",
|
||||
"exampleInput": "Reunión mañana a las 14h 1h !alta #trabajo",
|
||||
"exampleOutput": "→ \"Reunión\" mañana a las 14:00, duración 1h, prioridad Alta, etiqueta \"trabajo\""
|
||||
}
|
||||
},
|
||||
"app_slider": {
|
||||
"title": "Parte del ecosistema Mana",
|
||||
"memoro_desc": "Notas de voz con IA",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,145 @@
|
|||
"back": "Retour",
|
||||
"loading": "Chargement..."
|
||||
},
|
||||
"todo": {
|
||||
"title": "Tâches",
|
||||
"tasks": "Tâches",
|
||||
"completed": "terminées",
|
||||
"overdue": "en retard",
|
||||
"today": "aujourd'hui",
|
||||
"inbox": "Boîte de réception",
|
||||
"todayView": "Aujourd'hui",
|
||||
"upcoming": "À venir",
|
||||
"completedView": "Terminées",
|
||||
"search": "Recherche",
|
||||
"newTask": "Nouvelle tâche",
|
||||
"quickAddPlaceholder": "ex. 'Réunion demain à 14h !haute #important'",
|
||||
"addTask": "Ajouter",
|
||||
"cancel": "Ann.",
|
||||
"syntaxHelp": "Aide syntaxe",
|
||||
"noTasks": "Aucune tâche",
|
||||
"noTasksInbox": "Boîte de réception vide",
|
||||
"noTasksToday": "Aucune tâche aujourd'hui",
|
||||
"noTasksUpcoming": "Aucune tâche à venir",
|
||||
"noTasksCompleted": "Aucune tâche terminée",
|
||||
"firstTaskHint": "Créez votre première tâche avec le bouton + ci-dessus.",
|
||||
"edit": "Modifier",
|
||||
"markDone": "Terminer",
|
||||
"reopen": "Rouvrir",
|
||||
"deleteConfirm": "Vraiment supprimer ?",
|
||||
"yesDelete": "Oui, supprimer",
|
||||
"save": "Enregistrer",
|
||||
"close": "Fermer",
|
||||
"description": "Description",
|
||||
"addDescription": "Ajouter une description...",
|
||||
"subtasks": "Sous-tâches",
|
||||
"addSubtask": "Ajouter une sous-tâche...",
|
||||
"status": "Statut",
|
||||
"statusPending": "Ouvert",
|
||||
"statusInProgress": "En cours",
|
||||
"statusCompleted": "Terminé",
|
||||
"statusCancelled": "Annulé",
|
||||
"priority": "Priorité",
|
||||
"priorityUrgent": "Urgent",
|
||||
"priorityHigh": "Haute",
|
||||
"priorityMedium": "Moyenne",
|
||||
"priorityLow": "Basse",
|
||||
"dueDate": "Échéance",
|
||||
"time": "Heure",
|
||||
"startDate": "Début",
|
||||
"recurrence": "Récurrence",
|
||||
"recurrenceNone": "Aucune",
|
||||
"recurrenceDaily": "Quotidien",
|
||||
"recurrenceWeekly": "Hebdomadaire",
|
||||
"recurrenceMonthly": "Mensuel",
|
||||
"recurrenceYearly": "Annuel",
|
||||
"reminder": "Rappel",
|
||||
"reminderNone": "Aucun",
|
||||
"tags": "Tags",
|
||||
"storypoints": "Points",
|
||||
"duration": "Durée",
|
||||
"fun": "Fun",
|
||||
"projects": "Projets",
|
||||
"labels": "Labels",
|
||||
"sort": "Tri",
|
||||
"sortManual": "Manuel",
|
||||
"sortDueDate": "Échéance",
|
||||
"sortPriority": "Priorité",
|
||||
"sortName": "Nom",
|
||||
"sortCreated": "Création",
|
||||
"showCompleted": "Terminées",
|
||||
"boardView": "Board",
|
||||
"listView": "Liste",
|
||||
"board": {
|
||||
"new": "Nouveau board",
|
||||
"edit": "Modifier le board",
|
||||
"create": "Créer un board",
|
||||
"name": "Nom du board...",
|
||||
"groupBy": "Grouper par",
|
||||
"layout": "Disposition",
|
||||
"columns": "Colonnes",
|
||||
"addColumn": "Colonne",
|
||||
"columnName": "Nom de colonne...",
|
||||
"delete": "Supprimer",
|
||||
"noTasks": "Aucune tâche",
|
||||
"groupStatus": "Statut",
|
||||
"groupPriority": "Priorité",
|
||||
"groupDueDate": "Échéance",
|
||||
"groupTag": "Tag",
|
||||
"groupCustom": "Personnalisé",
|
||||
"layoutKanban": "Kanban",
|
||||
"layoutGrid": "Grille",
|
||||
"layoutFocus": "Focus"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres Todo"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Bienvenue dans Todo !",
|
||||
"intro": "Votre planificateur de tâches personnel avec boards Kanban, saisie intelligente et plus.",
|
||||
"step1Title": "Langage naturel",
|
||||
"step1": "Créez des tâches comme \"Réunion demain à 14h !haute #travail\" — date, priorité et labels sont détectés automatiquement.",
|
||||
"step2Title": "Boards Kanban",
|
||||
"step2": "Organisez vos tâches visuellement avec différentes vues : statut, priorité ou personnalisé.",
|
||||
"step3Title": "C'est parti !",
|
||||
"step3": "Créez votre première tâche avec le bouton +. Des astuces sont disponibles via l'icône ?.",
|
||||
"next": "Suivant",
|
||||
"letsGo": "C'est parti",
|
||||
"getStarted": "Commencer"
|
||||
},
|
||||
"syntaxHelp": {
|
||||
"title": "Syntaxe Quick-Add",
|
||||
"description": "Utilisez le langage naturel pour créer rapidement des tâches.",
|
||||
"date": "Date",
|
||||
"dateToday": "Aujourd'hui",
|
||||
"dateTomorrow": "Demain",
|
||||
"dateNextWeekday": "Prochain jour",
|
||||
"dateSpecific": "Date spécifique",
|
||||
"time": "Heure",
|
||||
"priority": "Priorité",
|
||||
"priorityUrgent": "Urgent",
|
||||
"priorityHigh": "Haute",
|
||||
"priorityLow": "Basse",
|
||||
"labels": "Labels",
|
||||
"labelsAdd": "Ajouter des tags",
|
||||
"duration": "Durée",
|
||||
"duration30m": "30 minutes",
|
||||
"duration2h": "2 heures",
|
||||
"duration90m": "90 minutes",
|
||||
"recurrence": "Récurrence",
|
||||
"recurrenceDaily": "Quotidien",
|
||||
"recurrenceWeekly": "Hebdomadaire",
|
||||
"recurrenceMonthly": "Mensuel",
|
||||
"multiTask": "Tâches multiples",
|
||||
"multiTaskChain": "Enchaîner les tâches",
|
||||
"multiTaskSemicolon": "Séparer par point-virgule",
|
||||
"subtasks": "Sous-tâches",
|
||||
"subtasksColonComma": "Deux-points + virgule",
|
||||
"exampleTitle": "Exemple",
|
||||
"exampleInput": "Réunion demain à 14h 1h !haute #travail",
|
||||
"exampleOutput": "→ \"Réunion\" demain à 14:00, durée 1h, priorité Haute, label \"travail\""
|
||||
}
|
||||
},
|
||||
"app_slider": {
|
||||
"title": "Partie de l'écosystème Mana",
|
||||
"memoro_desc": "Notes vocales IA",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
<script lang="ts">
|
||||
import { minimizedPagesStore } from '../stores/minimized-pages.svelte';
|
||||
import { X, ArrowsOut } from '@manacore/shared-icons';
|
||||
|
||||
let pages = $derived(minimizedPagesStore.pages);
|
||||
</script>
|
||||
|
||||
{#if pages.length > 0}
|
||||
<div
|
||||
class="fixed bottom-16 left-1/2 z-50 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-border bg-card/95 px-2 py-1.5 shadow-lg backdrop-blur-sm"
|
||||
>
|
||||
{#each pages as page (page.id)}
|
||||
<div
|
||||
class="group flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors
|
||||
{minimizedPagesStore.activePageId === page.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||
>
|
||||
<button
|
||||
onclick={() => minimizedPagesStore.maximize(page.id)}
|
||||
class="truncate max-w-[120px]"
|
||||
>
|
||||
{page.title}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => minimizedPagesStore.remove(page.id)}
|
||||
class="opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { CheckCircle, ListChecks, Columns, Sparkle } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
let step = $state(0);
|
||||
|
||||
let steps = $derived([
|
||||
{
|
||||
icon: Sparkle,
|
||||
title: $_('todo.onboarding.welcome'),
|
||||
desc: $_('todo.onboarding.intro'),
|
||||
},
|
||||
{
|
||||
icon: ListChecks,
|
||||
title: $_('todo.onboarding.step1Title'),
|
||||
desc: $_('todo.onboarding.step1'),
|
||||
},
|
||||
{
|
||||
icon: Columns,
|
||||
title: $_('todo.onboarding.step2Title'),
|
||||
desc: $_('todo.onboarding.step2'),
|
||||
},
|
||||
{
|
||||
icon: CheckCircle,
|
||||
title: $_('todo.onboarding.step3Title'),
|
||||
desc: $_('todo.onboarding.step3'),
|
||||
},
|
||||
]);
|
||||
|
||||
function next() {
|
||||
if (step < steps.length - 1) {
|
||||
step++;
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
function finish() {
|
||||
try {
|
||||
localStorage.setItem('todo-onboarding-done', 'true');
|
||||
} catch {}
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[9997] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
|
||||
onclick={(e) => e.target === e.currentTarget && finish()}
|
||||
>
|
||||
<div class="w-full max-w-sm rounded-2xl border border-border bg-card shadow-2xl">
|
||||
<div class="flex flex-col items-center p-8 text-center">
|
||||
{@const CurrentIcon = steps[step].icon}
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<CurrentIcon size={32} class="text-primary" />
|
||||
</div>
|
||||
|
||||
<h2 class="mb-2 text-xl font-bold text-foreground">{steps[step].title}</h2>
|
||||
<p class="mb-6 text-sm text-muted-foreground">{steps[step].desc}</p>
|
||||
|
||||
<!-- Progress dots -->
|
||||
<div class="mb-6 flex gap-1.5">
|
||||
{#each steps as _, i}
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all {i === step
|
||||
? 'w-6 bg-primary'
|
||||
: i < step
|
||||
? 'w-1.5 bg-primary/40'
|
||||
: 'w-1.5 bg-muted'}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#if step < steps.length - 1}
|
||||
<button
|
||||
onclick={finish}
|
||||
class="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={next}
|
||||
class="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-primary-foreground"
|
||||
>
|
||||
{step < steps.length - 1 ? $_('todo.onboarding.next') : $_('todo.onboarding.letsGo')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { tasksStore } from '../stores/tasks.svelte';
|
||||
import {
|
||||
parseTaskInput,
|
||||
parseMultiTaskInput,
|
||||
resolveTaskIds,
|
||||
formatDuration,
|
||||
} from '../utils/task-parser';
|
||||
import type { ParsedTask } from '../utils/task-parser';
|
||||
import type { LocalLabel } from '../types';
|
||||
import { getPriorityColor } from '../queries';
|
||||
import {
|
||||
Plus,
|
||||
CalendarBlank,
|
||||
Flag,
|
||||
ArrowsClockwise,
|
||||
Timer,
|
||||
Tag,
|
||||
Info,
|
||||
} from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
labels?: LocalLabel[];
|
||||
locale?: string;
|
||||
onShowSyntaxHelp?: () => void;
|
||||
}
|
||||
|
||||
let { labels = [], locale = 'de', onShowSyntaxHelp }: Props = $props();
|
||||
|
||||
let input = $state('');
|
||||
let isOpen = $state(false);
|
||||
let inputEl = $state<HTMLInputElement | undefined>(undefined);
|
||||
|
||||
// Parse preview
|
||||
let parsed = $derived.by((): ParsedTask[] => {
|
||||
if (!input.trim()) return [];
|
||||
try {
|
||||
return parseMultiTaskInput(input, locale);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
let hasParsedMeta = $derived(
|
||||
parsed.some(
|
||||
(p) =>
|
||||
p.dueDate ||
|
||||
p.priority ||
|
||||
p.recurrenceRule ||
|
||||
p.estimatedDuration ||
|
||||
p.labelNames.length > 0
|
||||
)
|
||||
);
|
||||
|
||||
function open() {
|
||||
isOpen = true;
|
||||
requestAnimationFrame(() => inputEl?.focus());
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!input.trim()) return;
|
||||
|
||||
const tasks = parseMultiTaskInput(input, locale);
|
||||
for (const task of tasks) {
|
||||
const resolved = resolveTaskIds(task, labels);
|
||||
await tasksStore.createTask({
|
||||
title: resolved.title,
|
||||
dueDate: resolved.dueDate ?? undefined,
|
||||
priority: resolved.priority,
|
||||
recurrenceRule: resolved.recurrenceRule,
|
||||
estimatedDuration: resolved.estimatedDuration,
|
||||
subtasks: resolved.subtasks?.map((s, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
title: s,
|
||||
isCompleted: false,
|
||||
order: i,
|
||||
})),
|
||||
});
|
||||
|
||||
// Apply labels
|
||||
if (resolved.labelIds.length > 0) {
|
||||
// We'd need to get the last created task ID - handled via createTask return
|
||||
}
|
||||
}
|
||||
|
||||
input = '';
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
isOpen = false;
|
||||
input = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="mb-4 rounded-lg border border-primary/50 bg-card">
|
||||
<div class="flex items-center gap-2 p-3">
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={input}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={$_('todo.quickAddPlaceholder')}
|
||||
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
disabled={!input.trim()}
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{$_('todo.addTask')}
|
||||
</button>
|
||||
{#if onShowSyntaxHelp}
|
||||
<button
|
||||
onclick={onShowSyntaxHelp}
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
title="Syntax-Hilfe"
|
||||
>
|
||||
<Info size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => {
|
||||
isOpen = false;
|
||||
input = '';
|
||||
}}
|
||||
class="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{$_('todo.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- NLP Preview -->
|
||||
{#if parsed.length > 0 && hasParsedMeta}
|
||||
<div class="border-t border-border px-3 py-2">
|
||||
{#each parsed as task, i}
|
||||
<div class="flex items-center gap-2 py-1 text-xs text-muted-foreground">
|
||||
{#if parsed.length > 1}
|
||||
<span class="font-medium text-foreground">#{i + 1}</span>
|
||||
{/if}
|
||||
<span class="font-medium text-foreground">{task.title}</span>
|
||||
{#if task.dueDate}
|
||||
<span class="inline-flex items-center gap-0.5 text-amber-500">
|
||||
<CalendarBlank size={10} />
|
||||
{task.dueDate.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })}
|
||||
{#if task.dueTime}
|
||||
{task.dueTime}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.priority}
|
||||
<span
|
||||
class="inline-flex items-center gap-0.5"
|
||||
style="color: {getPriorityColor(task.priority)}"
|
||||
>
|
||||
<Flag size={10} />
|
||||
{task.priority}
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.recurrenceRule}
|
||||
<span class="inline-flex items-center gap-0.5">
|
||||
<ArrowsClockwise size={10} />
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.estimatedDuration}
|
||||
<span class="inline-flex items-center gap-0.5">
|
||||
<Timer size={10} />
|
||||
{formatDuration(task.estimatedDuration)}
|
||||
</span>
|
||||
{/if}
|
||||
{#each task.labelNames as name}
|
||||
<span class="inline-flex items-center gap-0.5">
|
||||
<Tag size={10} />
|
||||
{name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={open}
|
||||
class="mb-4 flex w-full items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:border-primary hover:text-primary"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{$_('todo.newTask')}
|
||||
</button>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { Subtask } from '../types';
|
||||
import { Check, Circle, Plus, X } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
subtasks: Subtask[];
|
||||
onChange: (subtasks: Subtask[]) => void;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
let { subtasks, onChange, readonly = false }: Props = $props();
|
||||
|
||||
let newTitle = $state('');
|
||||
|
||||
function toggleSubtask(id: string) {
|
||||
onChange(
|
||||
subtasks.map((s) =>
|
||||
s.id === id
|
||||
? {
|
||||
...s,
|
||||
isCompleted: !s.isCompleted,
|
||||
completedAt: !s.isCompleted ? new Date().toISOString() : null,
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function removeSubtask(id: string) {
|
||||
onChange(subtasks.filter((s) => s.id !== id));
|
||||
}
|
||||
|
||||
function addSubtask() {
|
||||
const text = newTitle.trim();
|
||||
if (!text) return;
|
||||
onChange([
|
||||
...subtasks,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
title: text,
|
||||
isCompleted: false,
|
||||
order: subtasks.length,
|
||||
},
|
||||
]);
|
||||
newTitle = '';
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addSubtask();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
{#each subtasks as subtask (subtask.id)}
|
||||
<div class="group flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleSubtask(subtask.id)}
|
||||
class="flex-shrink-0 transition-colors {subtask.isCompleted
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
{#if subtask.isCompleted}
|
||||
<Check size={14} weight="bold" />
|
||||
{:else}
|
||||
<Circle size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
<span
|
||||
class="flex-1 text-sm {subtask.isCompleted
|
||||
? 'text-muted-foreground line-through'
|
||||
: 'text-foreground'}"
|
||||
>
|
||||
{subtask.title}
|
||||
</span>
|
||||
{#if !readonly}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeSubtask(subtask.id)}
|
||||
class="flex-shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if !readonly}
|
||||
<div class="flex items-center gap-2">
|
||||
<Plus size={14} class="flex-shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newTitle}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={$_('todo.addSubtask')}
|
||||
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<script lang="ts">
|
||||
import { CloudCheck, CloudSlash, ArrowsClockwise } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
status?: 'synced' | 'syncing' | 'offline' | 'error';
|
||||
}
|
||||
|
||||
let { status = 'synced' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs"
|
||||
title={status === 'synced'
|
||||
? 'Synchronisiert'
|
||||
: status === 'syncing'
|
||||
? 'Synchronisiere...'
|
||||
: status === 'offline'
|
||||
? 'Offline — Änderungen werden lokal gespeichert'
|
||||
: 'Sync-Fehler'}
|
||||
>
|
||||
{#if status === 'synced'}
|
||||
<CloudCheck size={14} class="text-green-500" />
|
||||
<span class="text-muted-foreground">Sync</span>
|
||||
{:else if status === 'syncing'}
|
||||
<ArrowsClockwise size={14} class="animate-spin text-primary" />
|
||||
<span class="text-primary">Sync...</span>
|
||||
{:else if status === 'offline'}
|
||||
<CloudSlash size={14} class="text-amber-500" />
|
||||
<span class="text-amber-500">Offline</span>
|
||||
{:else}
|
||||
<CloudSlash size={14} class="text-red-500" />
|
||||
<span class="text-red-500">Fehler</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { X } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
let sections = $derived([
|
||||
{
|
||||
title: $_('todo.syntaxHelp.date'),
|
||||
examples: [
|
||||
{ input: 'heute', desc: $_('todo.syntaxHelp.dateToday') },
|
||||
{ input: 'morgen', desc: $_('todo.syntaxHelp.dateTomorrow') },
|
||||
{ input: 'nächsten Montag', desc: $_('todo.syntaxHelp.dateNextWeekday') },
|
||||
{ input: '15.12.', desc: $_('todo.syntaxHelp.dateSpecific') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: $_('todo.syntaxHelp.time'),
|
||||
examples: [
|
||||
{ input: 'um 14 Uhr', desc: '14:00' },
|
||||
{ input: '14:30', desc: '14:30' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: $_('todo.syntaxHelp.priority'),
|
||||
examples: [
|
||||
{ input: '!dringend / !!!', desc: $_('todo.syntaxHelp.priorityUrgent') },
|
||||
{ input: '!hoch / !!', desc: $_('todo.syntaxHelp.priorityHigh') },
|
||||
{ input: '!niedrig / !', desc: $_('todo.syntaxHelp.priorityLow') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: $_('todo.syntaxHelp.labels'),
|
||||
examples: [{ input: '#wichtig #idee', desc: $_('todo.syntaxHelp.labelsAdd') }],
|
||||
},
|
||||
{
|
||||
title: $_('todo.syntaxHelp.duration'),
|
||||
examples: [
|
||||
{ input: '30min', desc: $_('todo.syntaxHelp.duration30m') },
|
||||
{ input: '2h', desc: $_('todo.syntaxHelp.duration2h') },
|
||||
{ input: '1.5 Stunden', desc: $_('todo.syntaxHelp.duration90m') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: $_('todo.syntaxHelp.recurrence'),
|
||||
examples: [
|
||||
{ input: 'jeden Tag', desc: $_('todo.syntaxHelp.recurrenceDaily') },
|
||||
{ input: 'wöchentlich', desc: $_('todo.syntaxHelp.recurrenceWeekly') },
|
||||
{ input: 'monatlich', desc: $_('todo.syntaxHelp.recurrenceMonthly') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: $_('todo.syntaxHelp.multiTask'),
|
||||
examples: [
|
||||
{ input: 'Task1, danach Task2', desc: $_('todo.syntaxHelp.multiTaskChain') },
|
||||
{ input: 'Task1; Task2; Task3', desc: $_('todo.syntaxHelp.multiTaskSemicolon') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: $_('todo.syntaxHelp.subtasks'),
|
||||
examples: [
|
||||
{ input: 'Einkaufen: Milch, Brot, Obst', desc: $_('todo.syntaxHelp.subtasksColonComma') },
|
||||
],
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[9996] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
|
||||
onclick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
class="max-h-[80vh] w-full max-w-md overflow-y-auto rounded-2xl border border-border bg-card shadow-2xl"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<h2 class="text-lg font-semibold text-foreground">{$_('todo.syntaxHelp.title')}</h2>
|
||||
<button
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
|
||||
onclick={onClose}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
{$_('todo.syntaxHelp.description')}
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each sections as section}
|
||||
<div>
|
||||
<h3 class="mb-1.5 text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div class="space-y-1">
|
||||
{#each section.examples as example}
|
||||
<div class="flex items-baseline gap-3 rounded-md px-2 py-1 hover:bg-muted/50">
|
||||
<code
|
||||
class="flex-shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
{example.input}
|
||||
</code>
|
||||
<span class="text-xs text-muted-foreground">{example.desc}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-lg bg-muted/50 p-3">
|
||||
<p class="text-xs font-medium text-foreground">{$_('todo.syntaxHelp.exampleTitle')}</p>
|
||||
<code class="mt-1 block text-xs text-primary">
|
||||
{$_('todo.syntaxHelp.exampleInput')}
|
||||
</code>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{$_('todo.syntaxHelp.exampleOutput')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts">
|
||||
import type { LocalLabel } from '../types';
|
||||
import { viewStore } from '../stores/view.svelte';
|
||||
import { X } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
labels: LocalLabel[];
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
let { labels, collapsed = false, onToggleCollapse }: Props = $props();
|
||||
|
||||
function handleSelect(id: string) {
|
||||
if (viewStore.currentView === 'label' && viewStore.currentLabelId === id) {
|
||||
viewStore.setInbox();
|
||||
} else {
|
||||
viewStore.setLabel(id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if labels.length > 0 && !collapsed}
|
||||
<div class="flex items-center gap-1.5 overflow-x-auto pb-1">
|
||||
{#if viewStore.currentView === 'label'}
|
||||
<button
|
||||
onclick={() => viewStore.setInbox()}
|
||||
class="flex items-center gap-1 rounded-full border border-primary/30 bg-primary/5 px-2 py-0.5 text-xs text-primary"
|
||||
>
|
||||
<X size={10} />
|
||||
Filter
|
||||
</button>
|
||||
{/if}
|
||||
{#each labels as label (label.id)}
|
||||
<button
|
||||
onclick={() => handleSelect(label.id)}
|
||||
class="flex-shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors
|
||||
{viewStore.currentView === 'label' && viewStore.currentLabelId === label.id
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:border-primary/50'}"
|
||||
>
|
||||
<span class="mr-1 inline-block h-2 w-2 rounded-full" style="background-color: {label.color}"
|
||||
></span>
|
||||
{label.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,315 @@
|
|||
<script lang="ts">
|
||||
import type { Task, Subtask, TaskPriority } from '../types';
|
||||
import { tasksStore } from '../stores/tasks.svelte';
|
||||
import { useTaskForm } from '../composables/useTaskForm.svelte';
|
||||
import SubtaskList from './SubtaskList.svelte';
|
||||
import {
|
||||
PrioritySelector,
|
||||
TagSelector,
|
||||
ReminderSelector,
|
||||
DurationPicker,
|
||||
StorypointsSelector,
|
||||
FunRatingPicker,
|
||||
} from './form';
|
||||
import { X, Trash, CalendarBlank, Clock, ArrowsClockwise, Flag } from '@manacore/shared-icons';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { task, open, onClose }: Props = $props();
|
||||
|
||||
const form = useTaskForm();
|
||||
|
||||
$effect(() => {
|
||||
if (open && task) form.initFromTask(task);
|
||||
});
|
||||
|
||||
let RECURRENCE_OPTIONS = $derived([
|
||||
{ value: '', label: $_('todo.recurrenceNone') },
|
||||
{ value: 'FREQ=DAILY', label: $_('todo.recurrenceDaily') },
|
||||
{ value: 'FREQ=WEEKLY', label: $_('todo.recurrenceWeekly') },
|
||||
{ value: 'FREQ=MONTHLY', label: $_('todo.recurrenceMonthly') },
|
||||
{ value: 'FREQ=YEARLY', label: $_('todo.recurrenceYearly') },
|
||||
]);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.title.trim()) return;
|
||||
form.isLoading = true;
|
||||
try {
|
||||
const payload = form.buildUpdatePayload();
|
||||
await tasksStore.updateTask(task.id, payload);
|
||||
await form.persistReminder(task.id);
|
||||
onClose();
|
||||
} finally {
|
||||
form.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (form.showDeleteConfirm) {
|
||||
await tasksStore.deleteTask(task.id);
|
||||
onClose();
|
||||
} else {
|
||||
form.showDeleteConfirm = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubtasksChange(newSubtasks: Subtask[]) {
|
||||
form.subtasks = newSubtasks;
|
||||
}
|
||||
|
||||
function autoGrow(node: HTMLTextAreaElement) {
|
||||
function resize() {
|
||||
node.style.height = 'auto';
|
||||
node.style.height = node.scrollHeight + 'px';
|
||||
}
|
||||
node.addEventListener('input', resize);
|
||||
resize();
|
||||
return { destroy: () => node.removeEventListener('input', resize) };
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[9995] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm sm:p-8"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
class="flex max-h-[calc(100vh-4rem)] w-full max-w-[1040px] flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl max-sm:max-h-[calc(100vh-60px)] max-sm:rounded-b-none"
|
||||
>
|
||||
<!-- Top bar -->
|
||||
<div
|
||||
class="flex flex-shrink-0 items-center justify-between border-b border-border px-5 py-2.5"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if form.showDeleteConfirm}
|
||||
<span class="text-sm font-medium text-red-500">{$_('todo.deleteConfirm')}</span>
|
||||
<button
|
||||
class="rounded-md px-2.5 py-1 text-sm text-red-500 transition-colors hover:bg-red-500/10"
|
||||
onclick={handleDelete}>{$_('todo.yesDelete')}</button
|
||||
>
|
||||
<button
|
||||
class="rounded-md px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted"
|
||||
onclick={() => (form.showDeleteConfirm = false)}>{$_('common.cancel')}</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-red-500/10 hover:text-red-500"
|
||||
onclick={handleDelete}
|
||||
title={$_('common.delete')}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="rounded-lg bg-primary px-3.5 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-45"
|
||||
onclick={handleSave}
|
||||
disabled={form.isLoading || !form.title.trim()}
|
||||
>
|
||||
{#if form.isLoading}
|
||||
<span
|
||||
class="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-transparent border-t-white"
|
||||
></span>
|
||||
{:else}
|
||||
{$_('common.save')}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onclick={onClose}
|
||||
title={$_('todo.close')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="flex-shrink-0 px-6 pb-3 pt-4">
|
||||
<textarea
|
||||
class="w-full resize-none overflow-hidden border-none bg-transparent p-0 text-2xl font-bold text-foreground outline-none placeholder:text-border"
|
||||
bind:value={form.title}
|
||||
placeholder={$_('todo.newTask')}
|
||||
rows="1"
|
||||
use:autoGrow
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Content: Description | Subtasks -->
|
||||
<div
|
||||
class="grid min-h-0 flex-1 grid-cols-1 overflow-hidden border-t border-border sm:grid-cols-2"
|
||||
>
|
||||
<div class="flex flex-col gap-2.5 overflow-y-auto p-5">
|
||||
<span class="text-[0.6875rem] font-bold uppercase tracking-wider text-muted-foreground"
|
||||
>{$_('todo.description')}</span
|
||||
>
|
||||
<textarea
|
||||
class="min-h-[100px] flex-1 resize-none border-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-border"
|
||||
bind:value={form.description}
|
||||
placeholder={$_('todo.addDescription')}
|
||||
rows="5"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-2.5 overflow-y-auto border-t border-border p-5 sm:border-l sm:border-t-0"
|
||||
>
|
||||
<span class="text-[0.6875rem] font-bold uppercase tracking-wider text-muted-foreground"
|
||||
>{$_('todo.subtasks')}</span
|
||||
>
|
||||
<SubtaskList subtasks={form.subtasks} onChange={handleSubtasksChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Properties strip -->
|
||||
<div class="flex flex-shrink-0 flex-wrap items-stretch border-t border-border bg-muted/30">
|
||||
<!-- Status -->
|
||||
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
|
||||
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>{$_('todo.status')}</span
|
||||
>
|
||||
<select
|
||||
class="border-none bg-transparent text-sm text-foreground outline-none"
|
||||
bind:value={form.status}
|
||||
>
|
||||
<option value="pending">{$_('todo.statusPending')}</option>
|
||||
<option value="in_progress">{$_('todo.statusInProgress')}</option>
|
||||
<option value="completed">{$_('todo.statusCompleted')}</option>
|
||||
<option value="cancelled">{$_('todo.statusCancelled')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
|
||||
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>{$_('todo.priority')}</span
|
||||
>
|
||||
<PrioritySelector value={form.priority} onChange={(p) => (form.priority = p)} />
|
||||
</div>
|
||||
|
||||
<!-- Due Date -->
|
||||
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
|
||||
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>{$_('todo.dueDate')}</span
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
class="border-none bg-transparent text-sm text-foreground outline-none"
|
||||
bind:value={form.dueDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Time -->
|
||||
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
|
||||
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>{$_('todo.time')}</span
|
||||
>
|
||||
<input
|
||||
type="time"
|
||||
class="border-none bg-transparent text-sm text-foreground outline-none"
|
||||
bind:value={form.dueTime}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Start Date -->
|
||||
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
|
||||
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>{$_('todo.startDate')}</span
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
class="border-none bg-transparent text-sm text-foreground outline-none"
|
||||
bind:value={form.startDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Recurrence -->
|
||||
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
|
||||
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>{$_('todo.recurrence')}</span
|
||||
>
|
||||
<select
|
||||
class="border-none bg-transparent text-sm text-foreground outline-none"
|
||||
bind:value={form.recurrenceRule}
|
||||
>
|
||||
{#each RECURRENCE_OPTIONS as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Reminder -->
|
||||
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
|
||||
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>{$_('todo.reminder')}</span
|
||||
>
|
||||
<ReminderSelector
|
||||
value={form.reminderMinutes}
|
||||
onChange={(v) => (form.reminderMinutes = v)}
|
||||
disabled={!form.dueDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="flex min-w-[140px] flex-1 flex-col gap-1 border-r border-border px-3.5 py-2">
|
||||
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>{$_('todo.tags')}</span
|
||||
>
|
||||
<TagSelector
|
||||
selectedIds={form.selectedLabelIds}
|
||||
onChange={(ids) => (form.selectedLabelIds = ids)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Story Points -->
|
||||
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
|
||||
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>{$_('todo.storypoints')}</span
|
||||
>
|
||||
<StorypointsSelector value={form.storyPoints} onChange={(v) => (form.storyPoints = v)} />
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="flex flex-col gap-1 border-r border-border px-3.5 py-2">
|
||||
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>{$_('todo.duration')}</span
|
||||
>
|
||||
<DurationPicker
|
||||
value={form.effectiveDuration}
|
||||
onChange={(v) => (form.effectiveDuration = v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Fun Rating -->
|
||||
<div class="flex flex-col gap-1 px-3.5 py-2">
|
||||
<span class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>{$_('todo.fun')}</span
|
||||
>
|
||||
<FunRatingPicker value={form.funRating} onChange={(v) => (form.funRating = v)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import type { Task, TaskPriority } from '../types';
|
||||
import { getPriorityLabel, getPriorityColor } from '../queries';
|
||||
import { Check, Circle, CalendarBlank, CheckSquare } from '@manacore/shared-icons';
|
||||
import { isToday, isPast, format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
interface Props {
|
||||
task: Task;
|
||||
tags?: { id: string; name: string; color: string }[];
|
||||
compact?: boolean;
|
||||
onToggleComplete: () => void;
|
||||
onClick: () => void;
|
||||
onContextMenu: (e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
task,
|
||||
tags = [],
|
||||
compact = false,
|
||||
onToggleComplete,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
}: Props = $props();
|
||||
|
||||
let taskLabelIds = $derived((task.metadata as { labelIds?: string[] })?.labelIds ?? []);
|
||||
let taskTags = $derived(
|
||||
taskLabelIds
|
||||
.map((id) => tags.find((t) => t.id === id))
|
||||
.filter((t): t is NonNullable<typeof t> => t != null)
|
||||
.slice(0, 3)
|
||||
);
|
||||
|
||||
let dueInfo = $derived.by(() => {
|
||||
if (!task.dueDate) return null;
|
||||
const d = new Date(task.dueDate);
|
||||
const overdue = isPast(d) && !isToday(d) && !task.isCompleted;
|
||||
const today = isToday(d);
|
||||
return {
|
||||
text: today ? 'Heute' : format(d, 'd. MMM', { locale: de }),
|
||||
overdue,
|
||||
today,
|
||||
};
|
||||
});
|
||||
|
||||
let subtaskInfo = $derived.by(() => {
|
||||
if (!task.subtasks?.length) return null;
|
||||
const done = task.subtasks.filter((s) => s.isCompleted).length;
|
||||
return { done, total: task.subtasks.length };
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group flex items-start gap-3 rounded-lg border border-transparent px-3 transition-colors hover:border-border hover:bg-card {compact
|
||||
? 'py-1.5'
|
||||
: 'py-2.5'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={onClick}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
onContextMenu(e);
|
||||
}}
|
||||
onkeydown={(e) => e.key === 'Enter' && onClick()}
|
||||
>
|
||||
<!-- Completion toggle -->
|
||||
<button
|
||||
onclick|stopPropagation={onToggleComplete}
|
||||
class="mt-0.5 flex-shrink-0 transition-colors {task.isCompleted
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground hover:text-primary'}"
|
||||
>
|
||||
{#if task.isCompleted}
|
||||
<Check size={20} weight="bold" />
|
||||
{:else}
|
||||
<Circle size={20} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<span
|
||||
class="text-sm {task.isCompleted ? 'text-muted-foreground line-through' : 'text-foreground'}"
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
|
||||
<!-- Meta row -->
|
||||
<div class="mt-0.5 flex items-center gap-2.5 text-xs">
|
||||
{#if dueInfo}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 {dueInfo.overdue
|
||||
? 'text-red-500'
|
||||
: dueInfo.today
|
||||
? 'text-amber-500'
|
||||
: 'text-muted-foreground'}"
|
||||
>
|
||||
<CalendarBlank size={11} />
|
||||
{dueInfo.text}
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.priority !== 'medium'}
|
||||
<span style="color: {getPriorityColor(task.priority)}">
|
||||
{getPriorityLabel(task.priority)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if subtaskInfo}
|
||||
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<CheckSquare size={11} />
|
||||
{subtaskInfo.done}/{subtaskInfo.total}
|
||||
</span>
|
||||
{/if}
|
||||
{#each taskTags as tag (tag.id)}
|
||||
<span
|
||||
class="rounded-full px-1.5 py-0.5 text-[0.625rem] font-medium"
|
||||
style="background: color-mix(in srgb, {tag.color} 15%, transparent); color: {tag.color}"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority dot -->
|
||||
{#if task.priority === 'urgent' || task.priority === 'high'}
|
||||
<div
|
||||
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style="background-color: {getPriorityColor(task.priority)}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { Task, TaskPriority } from '../types';
|
||||
import { tasksStore } from '../stores/tasks.svelte';
|
||||
import TaskItem from './TaskItem.svelte';
|
||||
import { dndzone, SOURCES, TRIGGERS } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
interface Props {
|
||||
tasks: Task[];
|
||||
tags?: { id: string; name: string; color: string }[];
|
||||
compact?: boolean;
|
||||
dragEnabled?: boolean;
|
||||
onOpenTask: (task: Task) => void;
|
||||
}
|
||||
|
||||
let { tasks, tags = [], compact = false, dragEnabled = true, onOpenTask }: Props = $props();
|
||||
|
||||
// DnD state
|
||||
let items = $state<Task[]>([]);
|
||||
$effect(() => {
|
||||
items = [...tasks];
|
||||
});
|
||||
|
||||
// Context menu
|
||||
let contextMenu = $state<{ x: number; y: number; task: Task } | null>(null);
|
||||
|
||||
function handleContextMenu(task: Task, e: MouseEvent) {
|
||||
contextMenu = { x: e.clientX, y: e.clientY, task };
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
async function handleSetPriority(priority: TaskPriority) {
|
||||
if (!contextMenu) return;
|
||||
await tasksStore.updateTask(contextMenu.task.id, { priority });
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
if (!contextMenu) return;
|
||||
await tasksStore.toggleComplete(contextMenu.task.id);
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!contextMenu) return;
|
||||
await tasksStore.deleteTask(contextMenu.task.id);
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
// DnD handlers
|
||||
function handleDndConsider(e: CustomEvent<{ items: Task[] }>) {
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
function handleDndFinalize(
|
||||
e: CustomEvent<{ items: Task[]; info: { source: string; trigger: string } }>
|
||||
) {
|
||||
items = e.detail.items;
|
||||
if (e.detail.info.source === SOURCES.POINTER) {
|
||||
tasksStore.reorderTasks(items.map((t) => t.id));
|
||||
}
|
||||
}
|
||||
|
||||
const flipDurationMs = 200;
|
||||
</script>
|
||||
|
||||
{#if dragEnabled}
|
||||
<div
|
||||
use:dndzone={{
|
||||
items,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: {},
|
||||
dropTargetClasses: ['bg-primary/5', 'rounded-lg'],
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="space-y-0.5"
|
||||
>
|
||||
{#each items as task (task.id)}
|
||||
<div animate:flip={{ duration: flipDurationMs }}>
|
||||
<TaskItem
|
||||
{task}
|
||||
{tags}
|
||||
{compact}
|
||||
onToggleComplete={() => tasksStore.toggleComplete(task.id)}
|
||||
onClick={() => onOpenTask(task)}
|
||||
onContextMenu={(e) => handleContextMenu(task, e)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-0.5">
|
||||
{#each tasks as task (task.id)}
|
||||
<TaskItem
|
||||
{task}
|
||||
{tags}
|
||||
{compact}
|
||||
onToggleComplete={() => tasksStore.toggleComplete(task.id)}
|
||||
onClick={() => onOpenTask(task)}
|
||||
onContextMenu={(e) => handleContextMenu(task, e)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Context Menu -->
|
||||
{#if contextMenu}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-[9990]" onclick={closeContextMenu}></div>
|
||||
<div
|
||||
class="fixed z-[9991] min-w-[160px] rounded-lg border border-border bg-card p-1 shadow-xl"
|
||||
style="left: {contextMenu.x}px; top: {contextMenu.y}px"
|
||||
>
|
||||
<button
|
||||
onclick={() => {
|
||||
onOpenTask(contextMenu!.task);
|
||||
closeContextMenu();
|
||||
}}
|
||||
class="flex w-full items-center rounded-md px-3 py-1.5 text-sm text-foreground hover:bg-muted"
|
||||
>
|
||||
{$_('todo.edit')}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleComplete}
|
||||
class="flex w-full items-center rounded-md px-3 py-1.5 text-sm text-foreground hover:bg-muted"
|
||||
>
|
||||
{contextMenu.task.isCompleted ? $_('todo.reopen') : $_('todo.markDone')}
|
||||
</button>
|
||||
<div class="my-1 border-t border-border"></div>
|
||||
<div class="px-3 py-1 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{$_('todo.priority')}
|
||||
</div>
|
||||
{#each ['urgent', 'high', 'medium', 'low'] as p}
|
||||
<button
|
||||
onclick={() => handleSetPriority(p as TaskPriority)}
|
||||
class="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm hover:bg-muted
|
||||
{contextMenu.task.priority === p ? 'text-primary font-medium' : 'text-foreground'}"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
style="background-color: {p === 'urgent'
|
||||
? '#ef4444'
|
||||
: p === 'high'
|
||||
? '#f59e0b'
|
||||
: p === 'medium'
|
||||
? '#3b82f6'
|
||||
: '#6b7280'}"
|
||||
></span>
|
||||
{p === 'urgent'
|
||||
? $_('todo.priorityUrgent')
|
||||
: p === 'high'
|
||||
? $_('todo.priorityHigh')
|
||||
: p === 'medium'
|
||||
? $_('todo.priorityMedium')
|
||||
: $_('todo.priorityLow')}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="my-1 border-t border-border"></div>
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
class="flex w-full items-center rounded-md px-3 py-1.5 text-sm text-red-500 hover:bg-red-500/10"
|
||||
>
|
||||
{$_('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { viewStore } from '../stores/view.svelte';
|
||||
import type { SortBy } from '../types';
|
||||
import {
|
||||
SortAscending,
|
||||
SortDescending,
|
||||
MagnifyingGlass,
|
||||
FunnelSimple,
|
||||
Columns,
|
||||
List,
|
||||
} from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
showBoardToggle?: boolean;
|
||||
isBoardView?: boolean;
|
||||
onToggleBoard?: () => void;
|
||||
}
|
||||
|
||||
let { showBoardToggle = false, isBoardView = false, onToggleBoard }: Props = $props();
|
||||
|
||||
let showSortMenu = $state(false);
|
||||
|
||||
const sortOptions: { value: SortBy; label: string }[] = $derived([
|
||||
{ value: 'order', label: $_('todo.sortManual') },
|
||||
{ value: 'dueDate', label: $_('todo.sortDueDate') },
|
||||
{ value: 'priority', label: $_('todo.sortPriority') },
|
||||
{ value: 'title', label: $_('todo.sortName') },
|
||||
{ value: 'createdAt', label: $_('todo.sortCreated') },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Search toggle -->
|
||||
<button
|
||||
onclick={() => {
|
||||
if (viewStore.currentView === 'search') viewStore.setInbox();
|
||||
else viewStore.setSearch('');
|
||||
}}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg transition-colors
|
||||
{viewStore.currentView === 'search'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||
title={$_('todo.search')}
|
||||
>
|
||||
<MagnifyingGlass size={16} />
|
||||
</button>
|
||||
|
||||
<!-- Sort -->
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (showSortMenu = !showSortMenu)}
|
||||
class="flex h-8 items-center gap-1 rounded-lg px-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={$_('todo.sort')}
|
||||
>
|
||||
{#if viewStore.sortOrder === 'asc'}
|
||||
<SortAscending size={16} />
|
||||
{:else}
|
||||
<SortDescending size={16} />
|
||||
{/if}
|
||||
<span class="text-xs"
|
||||
>{sortOptions.find((o) => o.value === viewStore.sortBy)?.label ?? 'Sort'}</span
|
||||
>
|
||||
</button>
|
||||
|
||||
{#if showSortMenu}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40" onclick={() => (showSortMenu = false)}></div>
|
||||
<div
|
||||
class="absolute right-0 top-full z-50 mt-1 min-w-[140px] rounded-lg border border-border bg-card p-1 shadow-lg"
|
||||
>
|
||||
{#each sortOptions as opt}
|
||||
<button
|
||||
onclick={() => {
|
||||
if (viewStore.sortBy === opt.value) {
|
||||
viewStore.toggleSortOrder();
|
||||
} else {
|
||||
viewStore.setSort(opt.value);
|
||||
}
|
||||
showSortMenu = false;
|
||||
}}
|
||||
class="flex w-full items-center justify-between rounded-md px-3 py-1.5 text-sm hover:bg-muted
|
||||
{viewStore.sortBy === opt.value ? 'text-primary font-medium' : 'text-foreground'}"
|
||||
>
|
||||
{opt.label}
|
||||
{#if viewStore.sortBy === opt.value}
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{viewStore.sortOrder === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Show completed toggle -->
|
||||
<button
|
||||
onclick={() => viewStore.toggleShowCompleted()}
|
||||
class="flex h-8 items-center gap-1 rounded-lg px-2 text-xs transition-colors
|
||||
{viewStore.showCompleted
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||
>
|
||||
<FunnelSimple size={14} />
|
||||
{$_('todo.showCompleted')}
|
||||
</button>
|
||||
|
||||
<!-- Board/List toggle -->
|
||||
{#if showBoardToggle}
|
||||
<button
|
||||
onclick={onToggleBoard}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={isBoardView ? $_('todo.listView') : $_('todo.boardView')}
|
||||
>
|
||||
{#if isBoardView}
|
||||
<List size={16} />
|
||||
{:else}
|
||||
<Columns size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<script lang="ts">
|
||||
import type { Task, LocalLabel, LocalBoardView } from '../../types';
|
||||
import KanbanLayout from './KanbanLayout.svelte';
|
||||
import GridLayout from './GridLayout.svelte';
|
||||
import FokusLayout from './FokusLayout.svelte';
|
||||
|
||||
interface Props {
|
||||
view: LocalBoardView;
|
||||
tasks: Task[];
|
||||
labels: LocalLabel[];
|
||||
wipLimit?: number | null;
|
||||
cardSize?: 'compact' | 'normal' | 'large';
|
||||
onToggleComplete: (taskId: string) => void;
|
||||
onSaveTask: (taskId: string, data: Partial<Task>) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
onQuickAdd: (title: string, columnId: string) => void;
|
||||
onOpenTask: (task: Task) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
view,
|
||||
tasks,
|
||||
labels,
|
||||
wipLimit = null,
|
||||
cardSize = 'normal',
|
||||
onToggleComplete,
|
||||
onSaveTask,
|
||||
onDeleteTask,
|
||||
onQuickAdd,
|
||||
onOpenTask,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if view.layout === 'grid'}
|
||||
<GridLayout {view} {tasks} {labels} {onToggleComplete} {onSaveTask} {onDeleteTask} {onOpenTask} />
|
||||
{:else if view.layout === 'fokus'}
|
||||
<FokusLayout {view} {tasks} {labels} {onToggleComplete} {onOpenTask} />
|
||||
{:else}
|
||||
<KanbanLayout
|
||||
{view}
|
||||
{tasks}
|
||||
{labels}
|
||||
{wipLimit}
|
||||
{cardSize}
|
||||
{onToggleComplete}
|
||||
{onSaveTask}
|
||||
{onDeleteTask}
|
||||
{onQuickAdd}
|
||||
{onOpenTask}
|
||||
/>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
<script lang="ts">
|
||||
import type { Task, LocalLabel, LocalBoardView } from '../../types';
|
||||
import { groupTasksByView } from '../../view-grouping';
|
||||
import { Check, Circle, CaretRight } from '@manacore/shared-icons';
|
||||
import { getPriorityColor } from '../../queries';
|
||||
|
||||
interface Props {
|
||||
view: LocalBoardView;
|
||||
tasks: Task[];
|
||||
labels: LocalLabel[];
|
||||
onToggleComplete: (taskId: string) => void;
|
||||
onOpenTask: (task: Task) => void;
|
||||
}
|
||||
|
||||
let { view, tasks, labels, onToggleComplete, onOpenTask }: Props = $props();
|
||||
|
||||
let columns = $derived(groupTasksByView(view, tasks));
|
||||
|
||||
// Focus mode: show first non-empty column's first few tasks prominently
|
||||
let focusColumn = $derived(columns.find((c) => c.tasks.length > 0) ?? columns[0]);
|
||||
let focusTasks = $derived(focusColumn?.tasks.slice(0, 5) ?? []);
|
||||
let remainingColumns = $derived(columns.filter((c) => c.id !== focusColumn?.id));
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Focus section -->
|
||||
{#if focusColumn}
|
||||
<div>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<span class="h-3 w-3 rounded-full" style="background-color: {focusColumn.color}"></span>
|
||||
<span class="text-lg font-bold text-foreground">{focusColumn.name}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each focusTasks as task, i (task.id)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={() => onOpenTask(task)}
|
||||
class="group flex cursor-pointer items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:shadow-md"
|
||||
style="border-left: 4px solid {getPriorityColor(task.priority)}"
|
||||
>
|
||||
<button
|
||||
onclick|stopPropagation={() => onToggleComplete(task.id)}
|
||||
class="flex-shrink-0 transition-colors {task.isCompleted
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground hover:text-primary'}"
|
||||
>
|
||||
{#if task.isCompleted}
|
||||
<Check size={24} weight="bold" />
|
||||
{:else}
|
||||
<Circle size={24} />
|
||||
{/if}
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span
|
||||
class="text-base font-medium {task.isCompleted
|
||||
? 'text-muted-foreground line-through'
|
||||
: 'text-foreground'}"
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
{#if task.description}
|
||||
<p class="mt-0.5 truncate text-sm text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<CaretRight
|
||||
size={16}
|
||||
class="flex-shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Remaining columns collapsed -->
|
||||
{#each remainingColumns as column (column.id)}
|
||||
{#if column.tasks.length > 0}
|
||||
<div class="rounded-lg border border-border bg-muted/20 p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full" style="background-color: {column.color}"></span>
|
||||
<span class="text-sm font-medium text-muted-foreground">{column.name}</span>
|
||||
<span class="text-xs text-muted-foreground">({column.tasks.length})</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<script lang="ts">
|
||||
import type { Task, LocalLabel, LocalBoardView } from '../../types';
|
||||
import { groupTasksByView } from '../../view-grouping';
|
||||
import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte';
|
||||
|
||||
interface Props {
|
||||
view: LocalBoardView;
|
||||
tasks: Task[];
|
||||
labels: LocalLabel[];
|
||||
onToggleComplete: (taskId: string) => void;
|
||||
onSaveTask: (taskId: string, data: Partial<Task>) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
onOpenTask: (task: Task) => void;
|
||||
}
|
||||
|
||||
let { view, tasks, labels, onToggleComplete, onSaveTask, onDeleteTask, onOpenTask }: Props =
|
||||
$props();
|
||||
|
||||
let columns = $derived(groupTasksByView(view, tasks));
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#each columns as column (column.id)}
|
||||
<div>
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="h-2.5 w-2.5 rounded-full" style="background-color: {column.color}"></span>
|
||||
<span class="text-sm font-semibold text-foreground">{column.name}</span>
|
||||
<span class="text-xs text-muted-foreground">({column.tasks.length})</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each column.tasks as task (task.id)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div onclick={() => onOpenTask(task)} class="cursor-pointer">
|
||||
<KanbanTaskCard
|
||||
{task}
|
||||
{labels}
|
||||
onToggleComplete={() => onToggleComplete(task.id)}
|
||||
onSave={(data) => onSaveTask(task.id, data)}
|
||||
onDelete={() => onDeleteTask(task.id)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<script lang="ts">
|
||||
import type { Task, LocalLabel, LocalBoardView } from '../../types';
|
||||
import { groupTasksByView, getDropActionUpdate } from '../../view-grouping';
|
||||
import { tasksStore } from '../../stores/tasks.svelte';
|
||||
import ViewColumn from './ViewColumn.svelte';
|
||||
|
||||
interface Props {
|
||||
view: LocalBoardView;
|
||||
tasks: Task[];
|
||||
labels: LocalLabel[];
|
||||
wipLimit?: number | null;
|
||||
cardSize?: 'compact' | 'normal' | 'large';
|
||||
onToggleComplete: (taskId: string) => void;
|
||||
onSaveTask: (taskId: string, data: Partial<Task>) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
onQuickAdd: (title: string, columnId: string) => void;
|
||||
onOpenTask: (task: Task) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
view,
|
||||
tasks,
|
||||
labels,
|
||||
wipLimit = null,
|
||||
cardSize = 'normal',
|
||||
onToggleComplete,
|
||||
onSaveTask,
|
||||
onDeleteTask,
|
||||
onQuickAdd,
|
||||
onOpenTask,
|
||||
}: Props = $props();
|
||||
|
||||
let columns = $derived(groupTasksByView(view, tasks));
|
||||
|
||||
// Handle drop: apply the column's drop action to the task
|
||||
async function handleDropTask(taskId: string, columnId: string) {
|
||||
const column = view.columns.find((c) => c.id === columnId);
|
||||
if (!column?.onDrop) return;
|
||||
|
||||
const update = getDropActionUpdate(column.onDrop);
|
||||
if (Object.keys(update).length > 0) {
|
||||
await tasksStore.updateTask(taskId, update);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex gap-3 overflow-x-auto pb-4">
|
||||
{#each columns as column (column.id)}
|
||||
<ViewColumn
|
||||
{column}
|
||||
{labels}
|
||||
{wipLimit}
|
||||
{cardSize}
|
||||
{onToggleComplete}
|
||||
{onSaveTask}
|
||||
{onDeleteTask}
|
||||
{onQuickAdd}
|
||||
{onOpenTask}
|
||||
onDropTask={handleDropTask}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
<script lang="ts">
|
||||
import type { Task, LocalLabel } from '../../types';
|
||||
import type { GroupedColumn } from '../../view-grouping';
|
||||
import ViewColumnHeader from './ViewColumnHeader.svelte';
|
||||
import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte';
|
||||
import QuickAddTaskInline from '../kanban/QuickAddTaskInline.svelte';
|
||||
import { dndzone, SOURCES, TRIGGERS } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
interface Props {
|
||||
column: GroupedColumn;
|
||||
labels: LocalLabel[];
|
||||
wipLimit?: number | null;
|
||||
cardSize?: 'compact' | 'normal' | 'large';
|
||||
onToggleComplete: (taskId: string) => void;
|
||||
onSaveTask: (taskId: string, data: Partial<Task>) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
onQuickAdd: (title: string, columnId: string) => void;
|
||||
onOpenTask: (task: Task) => void;
|
||||
onDropTask?: (taskId: string, columnId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
column,
|
||||
labels,
|
||||
wipLimit = null,
|
||||
cardSize = 'normal',
|
||||
onToggleComplete,
|
||||
onSaveTask,
|
||||
onDeleteTask,
|
||||
onQuickAdd,
|
||||
onOpenTask,
|
||||
onDropTask,
|
||||
}: Props = $props();
|
||||
|
||||
// DnD items — tracks the current column's tasks for drag operations
|
||||
let items = $state<Task[]>([]);
|
||||
$effect(() => {
|
||||
items = [...column.tasks];
|
||||
});
|
||||
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function handleDndConsider(e: CustomEvent<{ items: Task[] }>) {
|
||||
items = e.detail.items;
|
||||
}
|
||||
|
||||
function handleDndFinalize(
|
||||
e: CustomEvent<{ items: Task[]; info: { source: string; trigger: string } }>
|
||||
) {
|
||||
items = e.detail.items;
|
||||
// Notify parent about tasks that were dropped into this column
|
||||
if (e.detail.info.source === SOURCES.POINTER) {
|
||||
for (const task of items) {
|
||||
// If task wasn't originally in this column, it was dropped here
|
||||
if (!column.tasks.find((t) => t.id === task.id)) {
|
||||
onDropTask?.(task.id, column.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-[260px] max-w-[340px] flex-shrink-0 flex-col rounded-xl bg-muted/30">
|
||||
<ViewColumnHeader {column} {wipLimit} />
|
||||
|
||||
<div
|
||||
use:dndzone={{
|
||||
items,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: {},
|
||||
dropTargetClasses: ['ring-2', 'ring-primary/30', 'rounded-lg'],
|
||||
type: 'kanban-task',
|
||||
}}
|
||||
onconsider={handleDndConsider}
|
||||
onfinalize={handleDndFinalize}
|
||||
class="flex min-h-[60px] flex-1 flex-col gap-1.5 overflow-y-auto px-2 pb-2"
|
||||
class:py-0={cardSize === 'compact'}
|
||||
>
|
||||
{#each items as task (task.id)}
|
||||
<div animate:flip={{ duration: flipDurationMs }}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div onclick={() => onOpenTask(task)} class="cursor-pointer">
|
||||
<KanbanTaskCard
|
||||
{task}
|
||||
{labels}
|
||||
onToggleComplete={() => onToggleComplete(task.id)}
|
||||
onSave={(data) => onSaveTask(task.id, data)}
|
||||
onDelete={() => onDeleteTask(task.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="px-1 pb-1">
|
||||
<QuickAddTaskInline onAdd={(title) => onQuickAdd(title, column.id)} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
import type { GroupedColumn } from '../../view-grouping';
|
||||
|
||||
interface Props {
|
||||
column: GroupedColumn;
|
||||
wipLimit?: number | null;
|
||||
}
|
||||
|
||||
let { column, wipLimit = null }: Props = $props();
|
||||
|
||||
let isOverWip = $derived(wipLimit != null && column.tasks.length > wipLimit);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between px-2 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2.5 w-2.5 rounded-full" style="background-color: {column.color}"></span>
|
||||
<span class="text-sm font-semibold text-foreground">{column.name}</span>
|
||||
<span
|
||||
class="rounded-full bg-muted px-1.5 py-0.5 text-[0.625rem] font-medium {isOverWip
|
||||
? 'text-red-500'
|
||||
: 'text-muted-foreground'}"
|
||||
>
|
||||
{column.tasks.length}{wipLimit != null ? `/${wipLimit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { LocalBoardView, ViewColumn, TaskMatcher } from '../../types';
|
||||
import { boardViewsStore } from '../../stores/board-views.svelte';
|
||||
import { X, Plus, Trash } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
view?: LocalBoardView | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { view = null, open, onClose }: Props = $props();
|
||||
|
||||
let name = $state('');
|
||||
let groupBy = $state<LocalBoardView['groupBy']>('status');
|
||||
let layout = $state<LocalBoardView['layout']>('kanban');
|
||||
let columns = $state<ViewColumn[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (view) {
|
||||
name = view.name;
|
||||
groupBy = view.groupBy;
|
||||
layout = view.layout;
|
||||
columns = [...view.columns];
|
||||
} else {
|
||||
name = '';
|
||||
groupBy = 'status';
|
||||
layout = 'kanban';
|
||||
columns = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let GROUP_OPTIONS = $derived([
|
||||
{ value: 'status', label: $_('todo.board.groupStatus') },
|
||||
{ value: 'priority', label: $_('todo.board.groupPriority') },
|
||||
{ value: 'dueDate', label: $_('todo.board.groupDueDate') },
|
||||
{ value: 'tag', label: $_('todo.board.groupTag') },
|
||||
{ value: 'custom', label: $_('todo.board.groupCustom') },
|
||||
] as const);
|
||||
|
||||
let LAYOUT_OPTIONS = $derived([
|
||||
{ value: 'kanban', label: $_('todo.board.layoutKanban') },
|
||||
{ value: 'grid', label: $_('todo.board.layoutGrid') },
|
||||
{ value: 'fokus', label: $_('todo.board.layoutFocus') },
|
||||
] as const);
|
||||
|
||||
function addColumn() {
|
||||
columns = [
|
||||
...columns,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: `Spalte ${columns.length + 1}`,
|
||||
color: '#6B7280',
|
||||
match: { type: groupBy as TaskMatcher['type'], value: '' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function removeColumn(id: string) {
|
||||
columns = columns.filter((c) => c.id !== id);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) return;
|
||||
if (view) {
|
||||
await boardViewsStore.updateView(view.id, {
|
||||
name: name.trim(),
|
||||
groupBy,
|
||||
layout,
|
||||
columns,
|
||||
});
|
||||
} else {
|
||||
await boardViewsStore.createView({
|
||||
name: name.trim(),
|
||||
icon: layout === 'kanban' ? 'columns' : layout === 'grid' ? 'grid' : 'target',
|
||||
groupBy,
|
||||
layout,
|
||||
columns,
|
||||
order: 0,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (view) {
|
||||
await boardViewsStore.deleteView(view.id);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[9996] flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
|
||||
onclick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div class="w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-border px-5 py-3">
|
||||
<h2 class="text-lg font-semibold text-foreground">
|
||||
{view ? $_('todo.board.edit') : $_('todo.board.new')}
|
||||
</h2>
|
||||
<button
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
|
||||
onclick={onClose}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="space-y-4 p-5">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-muted-foreground">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$_('todo.board.name')}
|
||||
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-muted-foreground"
|
||||
>{$_('todo.board.groupBy')}</label
|
||||
>
|
||||
<select
|
||||
bind:value={groupBy}
|
||||
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
>
|
||||
{#each GROUP_OPTIONS as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-muted-foreground"
|
||||
>{$_('todo.board.layout')}</label
|
||||
>
|
||||
<select
|
||||
bind:value={layout}
|
||||
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
>
|
||||
{#each LAYOUT_OPTIONS as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columns -->
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-xs font-medium text-muted-foreground"
|
||||
>{$_('todo.board.columns')}</label
|
||||
>
|
||||
<button
|
||||
onclick={addColumn}
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-primary hover:bg-primary/10"
|
||||
>
|
||||
<Plus size={12} />
|
||||
{$_('todo.board.addColumn')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each columns as col, i (col.id)}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
bind:value={columns[i].color}
|
||||
class="h-8 w-8 cursor-pointer rounded border-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={columns[i].name}
|
||||
placeholder={$_('todo.board.columnName')}
|
||||
class="flex-1 rounded-md border border-border bg-background px-2 py-1.5 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onclick={() => removeColumn(col.id)}
|
||||
class="text-muted-foreground hover:text-red-500"
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between border-t border-border px-5 py-3">
|
||||
<div>
|
||||
{#if view}
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
class="rounded-md px-3 py-1.5 text-sm text-red-500 hover:bg-red-500/10"
|
||||
>
|
||||
{$_('todo.board.delete')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={!name.trim()}
|
||||
class="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-45"
|
||||
>
|
||||
{$_('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<script lang="ts">
|
||||
import type { LocalBoardView } from '../../types';
|
||||
import { Columns, GridFour, Target, Plus } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
views: LocalBoardView[];
|
||||
activeViewId: string | null;
|
||||
onSelect: (viewId: string) => void;
|
||||
onCreateView: () => void;
|
||||
}
|
||||
|
||||
let { views, activeViewId, onSelect, onCreateView }: Props = $props();
|
||||
|
||||
function getIcon(layout: string) {
|
||||
switch (layout) {
|
||||
case 'grid':
|
||||
return GridFour;
|
||||
case 'fokus':
|
||||
return Target;
|
||||
default:
|
||||
return Columns;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
{#each views as view (view.id)}
|
||||
{@const Icon = getIcon(view.layout)}
|
||||
<button
|
||||
onclick={() => onSelect(view.id)}
|
||||
class="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors
|
||||
{activeViewId === view.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||
>
|
||||
<Icon size={14} />
|
||||
{view.name}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
onclick={onCreateView}
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title="Neues Board"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export { default as BoardViewRenderer } from './BoardViewRenderer.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 ViewColumn } from './ViewColumn.svelte';
|
||||
export { default as ViewColumnHeader } from './ViewColumnHeader.svelte';
|
||||
export { default as ViewSelector } from './ViewSelector.svelte';
|
||||
export { default as ViewEditorModal } from './ViewEditorModal.svelte';
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import { Timer } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
value: number | null;
|
||||
onChange: (minutes: number | null) => void;
|
||||
}
|
||||
|
||||
let { value, onChange }: Props = $props();
|
||||
|
||||
const presets = [
|
||||
{ label: '-', value: null },
|
||||
{ label: '15m', value: 15 },
|
||||
{ label: '30m', value: 30 },
|
||||
{ label: '1h', value: 60 },
|
||||
{ label: '2h', value: 120 },
|
||||
{ label: '4h', value: 240 },
|
||||
{ label: '8h', value: 480 },
|
||||
];
|
||||
|
||||
function formatDuration(mins: number | null): string {
|
||||
if (mins == null) return '-';
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Timer size={14} class="text-muted-foreground" />
|
||||
<div class="flex gap-0.5">
|
||||
{#each presets as preset}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onChange(preset.value)}
|
||||
class="rounded px-1.5 py-0.5 text-[0.625rem] font-medium transition-colors
|
||||
{value === preset.value
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if value && !presets.some((p) => p.value === value)}
|
||||
<span class="text-xs text-primary">{formatDuration(value)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
import { Smiley, SmileyMeh, SmileySad } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
value: number | null;
|
||||
onChange: (rating: number | null) => void;
|
||||
}
|
||||
|
||||
let { value, onChange }: Props = $props();
|
||||
|
||||
const ratings = [1, 2, 3, 4, 5];
|
||||
|
||||
function getColor(r: number): string {
|
||||
if (r <= 2) return '#ef4444';
|
||||
if (r === 3) return '#f59e0b';
|
||||
return '#22c55e';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex gap-0.5">
|
||||
{#each ratings as r}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onChange(value === r ? null : r)}
|
||||
class="flex h-6 w-6 items-center justify-center rounded transition-colors
|
||||
{value === r ? 'ring-1' : 'opacity-40 hover:opacity-70'}"
|
||||
style={value === r
|
||||
? `color: ${getColor(r)}; --tw-ring-color: ${getColor(r)}`
|
||||
: `color: ${getColor(r)}`}
|
||||
title="Spaß-Faktor {r}/5"
|
||||
>
|
||||
{#if r <= 2}
|
||||
<SmileySad size={16} weight={value === r ? 'fill' : 'regular'} />
|
||||
{:else if r === 3}
|
||||
<SmileyMeh size={16} weight={value === r ? 'fill' : 'regular'} />
|
||||
{:else}
|
||||
<Smiley size={16} weight={value === r ? 'fill' : 'regular'} />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import type { TaskPriority } from '../../types';
|
||||
import { getPriorityLabel, getPriorityColor } from '../../queries';
|
||||
import { Flag } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
value: TaskPriority;
|
||||
onChange: (priority: TaskPriority) => void;
|
||||
}
|
||||
|
||||
let { value, onChange }: Props = $props();
|
||||
|
||||
const priorities: TaskPriority[] = ['urgent', 'high', 'medium', 'low'];
|
||||
</script>
|
||||
|
||||
<div class="flex gap-1">
|
||||
{#each priorities as p}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onChange(p)}
|
||||
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors
|
||||
{value === p ? 'ring-1 ring-current' : 'opacity-50 hover:opacity-80'}"
|
||||
style="color: {getPriorityColor(p)}; background: color-mix(in srgb, {getPriorityColor(
|
||||
p
|
||||
)} {value === p ? '15%' : '5%'}, transparent)"
|
||||
title={getPriorityLabel(p)}
|
||||
>
|
||||
<Flag size={12} weight={value === p ? 'fill' : 'regular'} />
|
||||
<span class="hidden sm:inline">{getPriorityLabel(p)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
import { Bell } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
value: number | null;
|
||||
onChange: (minutes: number | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { value, onChange, disabled = false }: Props = $props();
|
||||
|
||||
const options = [
|
||||
{ label: 'Keine', value: null },
|
||||
{ label: '5 Min', value: 5 },
|
||||
{ label: '15 Min', value: 15 },
|
||||
{ label: '30 Min', value: 30 },
|
||||
{ label: '1 Std', value: 60 },
|
||||
{ label: '1 Tag', value: 1440 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Bell size={14} class="text-muted-foreground" />
|
||||
<select
|
||||
{disabled}
|
||||
value={value ?? ''}
|
||||
onchange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
onChange(v === '' ? null : Number(v));
|
||||
}}
|
||||
class="rounded-md border border-border bg-transparent px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none disabled:opacity-40"
|
||||
>
|
||||
{#each options as opt}
|
||||
<option value={opt.value ?? ''}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts">
|
||||
import { Lightning } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
value: number | null;
|
||||
onChange: (points: number | null) => void;
|
||||
}
|
||||
|
||||
let { value, onChange }: Props = $props();
|
||||
|
||||
const points = [1, 2, 3, 5, 8, 13];
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Lightning size={14} class="text-muted-foreground" />
|
||||
<div class="flex gap-0.5">
|
||||
{#each points as p}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onChange(value === p ? null : p)}
|
||||
class="flex h-6 w-6 items-center justify-center rounded text-[0.625rem] font-bold transition-colors
|
||||
{value === p
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import type { Observable } from 'dexie';
|
||||
import type { LocalLabel } from '../../types';
|
||||
import { X, Plus, Tag } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
let { selectedIds, onChange }: Props = $props();
|
||||
|
||||
const allLabels$: Observable<LocalLabel[]> = getContext('labels');
|
||||
let allLabels = $state<LocalLabel[]>([]);
|
||||
$effect(() => {
|
||||
const sub = allLabels$.subscribe((l) => (allLabels = l));
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
let showPicker = $state(false);
|
||||
|
||||
function toggle(id: string) {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter((i) => i !== id));
|
||||
} else {
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
}
|
||||
|
||||
let selectedLabels = $derived(
|
||||
selectedIds
|
||||
.map((id) => allLabels.find((l) => l.id === id))
|
||||
.filter((l): l is LocalLabel => l != null)
|
||||
);
|
||||
|
||||
let availableLabels = $derived(allLabels.filter((l) => !selectedIds.includes(l.id)));
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
{#each selectedLabels as label (label.id)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.6875rem] font-medium"
|
||||
style="background: color-mix(in srgb, {label.color} 15%, transparent); color: {label.color}"
|
||||
>
|
||||
{label.name}
|
||||
<button type="button" onclick={() => toggle(label.id)} class="hover:opacity-70">
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPicker = !showPicker)}
|
||||
class="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
<Tag size={12} />
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
|
||||
{#if showPicker && availableLabels.length > 0}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute left-0 top-full z-50 mt-1 min-w-[140px] rounded-lg border border-border bg-card p-1 shadow-lg"
|
||||
onclick|stopPropagation
|
||||
>
|
||||
{#each availableLabels as label (label.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
toggle(label.id);
|
||||
if (availableLabels.length <= 1) showPicker = false;
|
||||
}}
|
||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs text-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
<span class="h-2.5 w-2.5 rounded-full" style="background-color: {label.color}"></span>
|
||||
{label.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showPicker}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40" onclick={() => (showPicker = false)}></div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export { default as PrioritySelector } from './PrioritySelector.svelte';
|
||||
export { default as TagSelector } from './TagSelector.svelte';
|
||||
export { default as ReminderSelector } from './ReminderSelector.svelte';
|
||||
export { default as DurationPicker } from './DurationPicker.svelte';
|
||||
export { default as StorypointsSelector } from './StorypointsSelector.svelte';
|
||||
export { default as FunRatingPicker } from './FunRatingPicker.svelte';
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import KanbanColumnSkeleton from './KanbanColumnSkeleton.svelte';
|
||||
|
||||
interface Props {
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
let { columns = 3 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex gap-3 overflow-hidden pb-4">
|
||||
{#each Array(columns) as _}
|
||||
<KanbanColumnSkeleton />
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<div class="flex min-w-[260px] max-w-[340px] flex-shrink-0 flex-col rounded-xl bg-muted/30 p-2">
|
||||
<div class="mb-2 flex items-center gap-2 px-2 py-2">
|
||||
<div class="h-2.5 w-2.5 animate-pulse rounded-full bg-muted"></div>
|
||||
<div class="h-4 w-20 animate-pulse rounded bg-muted"></div>
|
||||
<div class="h-4 w-6 animate-pulse rounded-full bg-muted"></div>
|
||||
</div>
|
||||
{#each Array(3) as _}
|
||||
<div class="mb-1.5 rounded-lg border border-border bg-card p-2.5">
|
||||
<div class="mb-2 h-4 w-4/5 animate-pulse rounded bg-muted"></div>
|
||||
<div class="flex gap-2">
|
||||
<div class="h-3 w-14 animate-pulse rounded bg-muted"></div>
|
||||
<div class="h-3 w-10 animate-pulse rounded bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<div class="flex gap-4">
|
||||
{#each Array(4) as _}
|
||||
<div class="h-4 w-20 animate-pulse rounded bg-muted"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<div class="flex items-start gap-3 px-3 py-2.5">
|
||||
<div class="mt-0.5 h-5 w-5 animate-pulse rounded-full bg-muted"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 w-3/4 animate-pulse rounded bg-muted"></div>
|
||||
<div class="flex gap-3">
|
||||
<div class="h-3 w-16 animate-pulse rounded bg-muted"></div>
|
||||
<div class="h-3 w-12 animate-pulse rounded bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import TaskItemSkeleton from './TaskItemSkeleton.svelte';
|
||||
|
||||
interface Props {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
let { count = 5 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
{#each Array(count) as _, i}
|
||||
<TaskItemSkeleton />
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export { default as TaskItemSkeleton } from './TaskItemSkeleton.svelte';
|
||||
export { default as TaskListSkeleton } from './TaskListSkeleton.svelte';
|
||||
export { default as KanbanColumnSkeleton } from './KanbanColumnSkeleton.svelte';
|
||||
export { default as KanbanBoardSkeleton } from './KanbanBoardSkeleton.svelte';
|
||||
export { default as StatisticsSkeleton } from './StatisticsSkeleton.svelte';
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* useTaskForm — Shared form state logic for TaskEditModal & inline editing.
|
||||
*/
|
||||
|
||||
import type { Task, TaskPriority, Subtask } from '../types';
|
||||
import { reminderTable } from '../collections';
|
||||
|
||||
export interface TaskFormState {
|
||||
title: string;
|
||||
description: string;
|
||||
dueDate: string;
|
||||
dueTime: string;
|
||||
startDate: string;
|
||||
priority: TaskPriority;
|
||||
status: string;
|
||||
selectedLabelIds: string[];
|
||||
subtasks: Subtask[];
|
||||
recurrenceRule: string;
|
||||
storyPoints: number | null;
|
||||
effectiveDuration: number | null;
|
||||
funRating: number | null;
|
||||
reminderMinutes: number | null;
|
||||
showDeleteConfirm: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useTaskForm() {
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
let dueDate = $state('');
|
||||
let dueTime = $state('');
|
||||
let startDate = $state('');
|
||||
let priority = $state<TaskPriority>('medium');
|
||||
let status = $state('pending');
|
||||
let selectedLabelIds = $state<string[]>([]);
|
||||
let subtasks = $state<Subtask[]>([]);
|
||||
let recurrenceRule = $state('');
|
||||
let storyPoints = $state<number | null>(null);
|
||||
let effectiveDuration = $state<number | null>(null);
|
||||
let funRating = $state<number | null>(null);
|
||||
let reminderMinutes = $state<number | null>(null);
|
||||
let showDeleteConfirm = $state(false);
|
||||
let isLoading = $state(false);
|
||||
|
||||
function initFromTask(task: Task) {
|
||||
title = task.title;
|
||||
description = task.description ?? '';
|
||||
dueDate = task.dueDate ? task.dueDate.split('T')[0] : '';
|
||||
dueTime = task.scheduledStartTime ?? '';
|
||||
startDate = task.scheduledDate ? task.scheduledDate.split('T')[0] : '';
|
||||
priority = task.priority;
|
||||
status = task.status;
|
||||
subtasks = task.subtasks ? [...task.subtasks] : [];
|
||||
recurrenceRule = task.recurrenceRule ?? '';
|
||||
effectiveDuration = task.estimatedDuration ?? null;
|
||||
showDeleteConfirm = false;
|
||||
isLoading = false;
|
||||
|
||||
const meta = task.metadata as Record<string, unknown> | null;
|
||||
selectedLabelIds = (meta?.labelIds as string[]) ?? [];
|
||||
storyPoints = (meta?.storyPoints as number) ?? null;
|
||||
funRating = (meta?.funRating as number) ?? null;
|
||||
reminderMinutes = null;
|
||||
|
||||
// Load existing reminder
|
||||
loadReminder(task.id);
|
||||
}
|
||||
|
||||
async function loadReminder(taskId: string) {
|
||||
try {
|
||||
const reminders = await reminderTable.where('taskId').equals(taskId).toArray();
|
||||
const active = reminders.find((r) => !r.deletedAt && r.status === 'pending');
|
||||
if (active) {
|
||||
reminderMinutes = active.minutesBefore;
|
||||
}
|
||||
} catch {
|
||||
// Reminders table may not exist yet
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpdatePayload(): Record<string, unknown> {
|
||||
const update: Record<string, unknown> = {
|
||||
title: title.trim(),
|
||||
description: description || undefined,
|
||||
priority,
|
||||
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
||||
scheduledDate: startDate ? new Date(startDate).toISOString() : null,
|
||||
scheduledStartTime: dueTime || null,
|
||||
estimatedDuration: effectiveDuration,
|
||||
recurrenceRule: recurrenceRule || null,
|
||||
subtasks: subtasks.length > 0 ? subtasks : null,
|
||||
isCompleted: status === 'completed',
|
||||
completedAt: status === 'completed' ? new Date().toISOString() : null,
|
||||
metadata: {
|
||||
labelIds: selectedLabelIds,
|
||||
storyPoints,
|
||||
funRating,
|
||||
},
|
||||
};
|
||||
return update;
|
||||
}
|
||||
|
||||
async function persistReminder(taskId: string) {
|
||||
try {
|
||||
// Remove old reminders
|
||||
const existing = await reminderTable.where('taskId').equals(taskId).toArray();
|
||||
for (const r of existing) {
|
||||
if (!r.deletedAt) {
|
||||
await reminderTable.update(r.id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create new if set
|
||||
if (reminderMinutes != null && dueDate) {
|
||||
await reminderTable.add({
|
||||
id: crypto.randomUUID(),
|
||||
taskId,
|
||||
minutesBefore: reminderMinutes,
|
||||
type: 'push',
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore if table doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get title() {
|
||||
return title;
|
||||
},
|
||||
set title(v: string) {
|
||||
title = v;
|
||||
},
|
||||
get description() {
|
||||
return description;
|
||||
},
|
||||
set description(v: string) {
|
||||
description = v;
|
||||
},
|
||||
get dueDate() {
|
||||
return dueDate;
|
||||
},
|
||||
set dueDate(v: string) {
|
||||
dueDate = v;
|
||||
},
|
||||
get dueTime() {
|
||||
return dueTime;
|
||||
},
|
||||
set dueTime(v: string) {
|
||||
dueTime = v;
|
||||
},
|
||||
get startDate() {
|
||||
return startDate;
|
||||
},
|
||||
set startDate(v: string) {
|
||||
startDate = v;
|
||||
},
|
||||
get priority() {
|
||||
return priority;
|
||||
},
|
||||
set priority(v: TaskPriority) {
|
||||
priority = v;
|
||||
},
|
||||
get status() {
|
||||
return status;
|
||||
},
|
||||
set status(v: string) {
|
||||
status = v;
|
||||
},
|
||||
get selectedLabelIds() {
|
||||
return selectedLabelIds;
|
||||
},
|
||||
set selectedLabelIds(v: string[]) {
|
||||
selectedLabelIds = v;
|
||||
},
|
||||
get subtasks() {
|
||||
return subtasks;
|
||||
},
|
||||
set subtasks(v: Subtask[]) {
|
||||
subtasks = v;
|
||||
},
|
||||
get recurrenceRule() {
|
||||
return recurrenceRule;
|
||||
},
|
||||
set recurrenceRule(v: string) {
|
||||
recurrenceRule = v;
|
||||
},
|
||||
get storyPoints() {
|
||||
return storyPoints;
|
||||
},
|
||||
set storyPoints(v: number | null) {
|
||||
storyPoints = v;
|
||||
},
|
||||
get effectiveDuration() {
|
||||
return effectiveDuration;
|
||||
},
|
||||
set effectiveDuration(v: number | null) {
|
||||
effectiveDuration = v;
|
||||
},
|
||||
get funRating() {
|
||||
return funRating;
|
||||
},
|
||||
set funRating(v: number | null) {
|
||||
funRating = v;
|
||||
},
|
||||
get reminderMinutes() {
|
||||
return reminderMinutes;
|
||||
},
|
||||
set reminderMinutes(v: number | null) {
|
||||
reminderMinutes = v;
|
||||
},
|
||||
get showDeleteConfirm() {
|
||||
return showDeleteConfirm;
|
||||
},
|
||||
set showDeleteConfirm(v: boolean) {
|
||||
showDeleteConfirm = v;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
set isLoading(v: boolean) {
|
||||
isLoading = v;
|
||||
},
|
||||
|
||||
initFromTask,
|
||||
buildUpdatePayload,
|
||||
persistReminder,
|
||||
};
|
||||
}
|
||||
|
|
@ -2,10 +2,17 @@
|
|||
* Todo module — barrel exports.
|
||||
*/
|
||||
|
||||
// Stores
|
||||
export { tasksStore } from './stores/tasks.svelte';
|
||||
export { boardViewsStore } from './stores/board-views.svelte';
|
||||
export { viewStore } from './stores/view.svelte';
|
||||
export { labelsStore } from './stores/labels.svelte';
|
||||
export { remindersStore } from './stores/reminders.svelte';
|
||||
export { todoSettings } from './stores/settings.svelte';
|
||||
export { minimizedPagesStore } from './stores/minimized-pages.svelte';
|
||||
export { contactsStore } from './stores/contacts.svelte';
|
||||
|
||||
// Queries
|
||||
export {
|
||||
useAllTasks,
|
||||
useAllLabels,
|
||||
|
|
@ -25,6 +32,8 @@ export {
|
|||
getPriorityColor,
|
||||
getTaskStats,
|
||||
} from './queries';
|
||||
|
||||
// Collections
|
||||
export {
|
||||
taskTable,
|
||||
todoProjectTable,
|
||||
|
|
@ -34,6 +43,26 @@ export {
|
|||
boardViewTable,
|
||||
TODO_GUEST_SEED,
|
||||
} from './collections';
|
||||
|
||||
// View Grouping
|
||||
export { groupTasksByView, getDropActionUpdate } from './view-grouping';
|
||||
export type { GroupedColumn } from './view-grouping';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
parseTaskInput,
|
||||
parseMultiTaskInput,
|
||||
resolveTaskIds,
|
||||
formatDuration,
|
||||
} from './utils/task-parser';
|
||||
export { estimateDuration } from './utils/time-estimator';
|
||||
export type { ParsedTask, ParsedTaskWithIds } from './utils/task-parser';
|
||||
export type { DurationEstimate } from './utils/time-estimator';
|
||||
|
||||
// Composables
|
||||
export { useTaskForm } from './composables/useTaskForm.svelte';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
LocalTask,
|
||||
LocalLabel,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Contacts Integration Store — Task assignment via Contacts app.
|
||||
*
|
||||
* Checks if the Contacts module is available and provides search functionality.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
|
||||
export interface ContactResult {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
let isAvailable = $state(false);
|
||||
let cache = $state<Map<string, ContactResult>>(new Map());
|
||||
|
||||
async function checkAvailability() {
|
||||
try {
|
||||
const tables = db.tables.map((t) => t.name);
|
||||
isAvailable = tables.includes('contacts');
|
||||
} catch {
|
||||
isAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check on init
|
||||
checkAvailability();
|
||||
|
||||
export const contactsStore = {
|
||||
get isAvailable() {
|
||||
return isAvailable;
|
||||
},
|
||||
|
||||
async searchContacts(query: string): Promise<ContactResult[]> {
|
||||
if (!isAvailable || !query.trim()) return [];
|
||||
|
||||
try {
|
||||
const q = query.toLowerCase();
|
||||
const all = await db.table('contacts').toArray();
|
||||
return all
|
||||
.filter((c: any) => {
|
||||
if (c.deletedAt) return false;
|
||||
const name = `${c.firstName ?? ''} ${c.lastName ?? ''}`.toLowerCase();
|
||||
const email = (c.email ?? '').toLowerCase();
|
||||
return name.includes(q) || email.includes(q);
|
||||
})
|
||||
.slice(0, 10)
|
||||
.map((c: any) => {
|
||||
const result: ContactResult = {
|
||||
id: c.id,
|
||||
name: `${c.firstName ?? ''} ${c.lastName ?? ''}`.trim(),
|
||||
email: c.email,
|
||||
};
|
||||
cache.set(c.id, result);
|
||||
return result;
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getFromCache(id: string): ContactResult | undefined {
|
||||
return cache.get(id);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Minimized Pages Store — Multi-page system with minimized tabs.
|
||||
*
|
||||
* Allows users to "minimize" views to a tab bar and restore them later.
|
||||
*/
|
||||
|
||||
export interface MinimizedPage {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
route?: string;
|
||||
}
|
||||
|
||||
let pages = $state<MinimizedPage[]>([]);
|
||||
let activePageId = $state<string | null>(null);
|
||||
let showPicker = $state(false);
|
||||
|
||||
export const minimizedPagesStore = {
|
||||
get pages() {
|
||||
return pages;
|
||||
},
|
||||
get activePageId() {
|
||||
return activePageId;
|
||||
},
|
||||
get showPicker() {
|
||||
return showPicker;
|
||||
},
|
||||
|
||||
minimize(page: MinimizedPage) {
|
||||
if (!pages.find((p) => p.id === page.id)) {
|
||||
pages = [...pages, page];
|
||||
}
|
||||
},
|
||||
|
||||
restore(id: string) {
|
||||
activePageId = id;
|
||||
},
|
||||
|
||||
remove(id: string) {
|
||||
pages = pages.filter((p) => p.id !== id);
|
||||
if (activePageId === id) {
|
||||
activePageId = null;
|
||||
}
|
||||
},
|
||||
|
||||
maximize(id: string) {
|
||||
activePageId = id;
|
||||
},
|
||||
|
||||
togglePicker() {
|
||||
showPicker = !showPicker;
|
||||
},
|
||||
|
||||
closePicker() {
|
||||
showPicker = false;
|
||||
},
|
||||
|
||||
clear() {
|
||||
pages = [];
|
||||
activePageId = null;
|
||||
showPicker = false;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Reminders Store — Mutation-Only Service
|
||||
*
|
||||
* Reads via liveQuery (useAllReminders in queries.ts).
|
||||
* This store handles create and delete operations for task reminders.
|
||||
*/
|
||||
|
||||
import { reminderTable } from '../collections';
|
||||
import type { LocalReminder } from '../types';
|
||||
|
||||
export const remindersStore = {
|
||||
async createReminder(data: {
|
||||
taskId: string;
|
||||
minutesBefore: number;
|
||||
type?: 'push' | 'email' | 'both';
|
||||
}) {
|
||||
const newReminder: LocalReminder = {
|
||||
id: crypto.randomUUID(),
|
||||
taskId: data.taskId,
|
||||
minutesBefore: data.minutesBefore,
|
||||
type: data.type ?? 'push',
|
||||
status: 'pending',
|
||||
};
|
||||
await reminderTable.add(newReminder);
|
||||
return newReminder;
|
||||
},
|
||||
|
||||
async deleteReminder(id: string) {
|
||||
await reminderTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteRemindersForTask(taskId: string) {
|
||||
const reminders = await reminderTable.where('taskId').equals(taskId).toArray();
|
||||
for (const r of reminders) {
|
||||
if (!r.deletedAt) {
|
||||
await reminderTable.update(r.id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async replaceReminder(
|
||||
taskId: string,
|
||||
minutesBefore: number | null,
|
||||
type?: 'push' | 'email' | 'both'
|
||||
) {
|
||||
// Delete existing
|
||||
await this.deleteRemindersForTask(taskId);
|
||||
|
||||
// Create new if set
|
||||
if (minutesBefore != null) {
|
||||
return this.createReminder({ taskId, minutesBefore, type });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Smart Duration Estimation — History-based task duration estimation.
|
||||
*
|
||||
* Analyzes completed tasks with known durations to suggest durations for new tasks.
|
||||
* Uses weighted scoring by: labels, title similarity, priority.
|
||||
* Fully offline — reads from IndexedDB.
|
||||
*/
|
||||
|
||||
import { taskTable } from '../collections';
|
||||
import type { LocalTask, TaskPriority } from '../types';
|
||||
|
||||
export interface DurationEstimate {
|
||||
minutes: number;
|
||||
confidence: 'low' | 'medium' | 'high';
|
||||
sampleSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate duration for a new task based on historical data.
|
||||
*/
|
||||
export async function estimateDuration(
|
||||
title: string,
|
||||
options?: {
|
||||
priority?: TaskPriority;
|
||||
labelIds?: string[];
|
||||
defaultDuration?: number;
|
||||
}
|
||||
): Promise<DurationEstimate | null> {
|
||||
const allTasks = await taskTable.toArray();
|
||||
const completed = allTasks.filter(
|
||||
(t) => t.isCompleted && t.estimatedDuration && t.estimatedDuration > 0 && !t.deletedAt
|
||||
);
|
||||
|
||||
if (completed.length < 3) return null;
|
||||
|
||||
// Score each historical task by relevance
|
||||
const scored = completed.map((t) => {
|
||||
let score = 1;
|
||||
|
||||
// Title similarity (simple word overlap)
|
||||
const titleWords = new Set(
|
||||
title
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 2)
|
||||
);
|
||||
const taskWords = new Set(
|
||||
t.title
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 2)
|
||||
);
|
||||
let overlap = 0;
|
||||
for (const w of titleWords) {
|
||||
if (taskWords.has(w)) overlap++;
|
||||
}
|
||||
if (titleWords.size > 0) {
|
||||
score += (overlap / titleWords.size) * 3;
|
||||
}
|
||||
|
||||
// Priority match
|
||||
if (options?.priority && t.priority === options.priority) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
// Label overlap
|
||||
if (options?.labelIds && options.labelIds.length > 0) {
|
||||
const taskLabels: string[] = (t.metadata as { labelIds?: string[] })?.labelIds ?? [];
|
||||
const labelOverlap = options.labelIds.filter((id) => taskLabels.includes(id)).length;
|
||||
score += labelOverlap * 1.5;
|
||||
}
|
||||
|
||||
return { task: t, score, duration: t.estimatedDuration! };
|
||||
});
|
||||
|
||||
// Sort by score descending and take top matches
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const top = scored.slice(0, 10);
|
||||
|
||||
if (top.length === 0) return null;
|
||||
|
||||
// Weighted average
|
||||
const totalWeight = top.reduce((sum, s) => sum + s.score, 0);
|
||||
const weightedAvg = top.reduce((sum, s) => sum + s.duration * s.score, 0) / totalWeight;
|
||||
|
||||
// Round to nearest 5 minutes
|
||||
const minutes = Math.round(weightedAvg / 5) * 5;
|
||||
|
||||
// Determine confidence
|
||||
const maxScore = Math.max(...top.map((s) => s.score));
|
||||
let confidence: DurationEstimate['confidence'] = 'low';
|
||||
if (top.length >= 5 && maxScore > 3) confidence = 'high';
|
||||
else if (top.length >= 3 && maxScore > 2) confidence = 'medium';
|
||||
|
||||
return {
|
||||
minutes: Math.max(5, minutes),
|
||||
confidence,
|
||||
sampleSize: top.length,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import type { Observable } from 'dexie';
|
||||
import { dropTarget } from '@manacore/shared-ui/dnd';
|
||||
import type { DragPayload, TagDragData } from '@manacore/shared-ui/dnd';
|
||||
import { useAllTags } from '$lib/stores/tags.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
type Task,
|
||||
type LocalLabel,
|
||||
type LocalBoardView,
|
||||
type LocalTodoProject,
|
||||
type TaskPriority,
|
||||
tasksStore,
|
||||
taskTable,
|
||||
viewStore,
|
||||
|
|
@ -22,70 +21,80 @@
|
|||
filterByProject,
|
||||
searchTasks,
|
||||
sortTasks,
|
||||
getPriorityLabel,
|
||||
getPriorityColor,
|
||||
getTaskStats,
|
||||
} from '$lib/modules/todo';
|
||||
import {
|
||||
Plus,
|
||||
Check,
|
||||
Circle,
|
||||
MagnifyingGlass,
|
||||
Tray,
|
||||
CalendarBlank,
|
||||
CalendarCheck,
|
||||
Flag,
|
||||
FunnelSimple,
|
||||
CaretRight,
|
||||
Folder,
|
||||
CheckCircle,
|
||||
ShareNetwork,
|
||||
MagnifyingGlass,
|
||||
Gear,
|
||||
} from '@manacore/shared-icons';
|
||||
import { ShareModal } from '@manacore/shared-uload';
|
||||
|
||||
// Components
|
||||
import TaskList from '$lib/modules/todo/components/TaskList.svelte';
|
||||
import TaskEditModal from '$lib/modules/todo/components/TaskEditModal.svelte';
|
||||
import QuickAddTask from '$lib/modules/todo/components/QuickAddTask.svelte';
|
||||
import TodoToolbar from '$lib/modules/todo/components/TodoToolbar.svelte';
|
||||
import TagStrip from '$lib/modules/todo/components/TagStrip.svelte';
|
||||
import SyncIndicator from '$lib/modules/todo/components/SyncIndicator.svelte';
|
||||
import SyntaxHelpOverlay from '$lib/modules/todo/components/SyntaxHelpOverlay.svelte';
|
||||
import OnboardingModal from '$lib/modules/todo/components/OnboardingModal.svelte';
|
||||
import { TaskListSkeleton, StatisticsSkeleton } from '$lib/modules/todo/components/skeletons';
|
||||
import {
|
||||
BoardViewRenderer,
|
||||
ViewSelector,
|
||||
ViewEditorModal,
|
||||
} from '$lib/modules/todo/components/board-views';
|
||||
import { todoSettings } from '$lib/modules/todo/stores/settings.svelte';
|
||||
|
||||
// Get data from layout context
|
||||
const allTasks$: Observable<Task[]> = getContext('tasks');
|
||||
const allLabels$: Observable<LocalLabel[]> = getContext('labels');
|
||||
const allProjects$: Observable<LocalTodoProject[]> = getContext('projects');
|
||||
const allBoardViews$: Observable<LocalBoardView[]> = getContext('boardViews');
|
||||
|
||||
let allTasks = $state<Task[]>([]);
|
||||
let allLabels = $state<LocalLabel[]>([]);
|
||||
let allProjects = $state<LocalTodoProject[]>([]);
|
||||
let allBoardViews = $state<LocalBoardView[]>([]);
|
||||
let isLoaded = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const sub = allTasks$.subscribe((t) => {
|
||||
allTasks = t;
|
||||
isLoaded = true;
|
||||
});
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = allLabels$.subscribe((l) => {
|
||||
allLabels = l;
|
||||
});
|
||||
const sub = allLabels$.subscribe((l) => (allLabels = l));
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = allProjects$.subscribe((p) => {
|
||||
allProjects = p;
|
||||
});
|
||||
const sub = allProjects$.subscribe((p) => (allProjects = p));
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
// Global tags for resolving labelIds to names/colors
|
||||
$effect(() => {
|
||||
const sub = allBoardViews$.subscribe((v) => (allBoardViews = v));
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
// Tags for resolving labelIds
|
||||
const globalTags = useAllTags();
|
||||
const tagMap = $derived(new Map((globalTags.value ?? []).map((t) => [t.id, t])));
|
||||
const tagList = $derived(
|
||||
(globalTags.value ?? []).map((t) => ({ id: t.id, name: t.name, color: t.color }))
|
||||
);
|
||||
|
||||
function getTaskTags(task: Task) {
|
||||
const ids: string[] = (task.metadata as { labelIds?: string[] })?.labelIds ?? [];
|
||||
return ids.map((id) => tagMap.get(id)).filter((t): t is NonNullable<typeof t> => t != null);
|
||||
}
|
||||
|
||||
// Task stats
|
||||
// Stats
|
||||
let stats = $derived(getTaskStats(allTasks));
|
||||
|
||||
// Filtered tasks based on current view
|
||||
// Filtered tasks
|
||||
let displayTasks = $derived.by(() => {
|
||||
let tasks = allTasks;
|
||||
switch (viewStore.currentView) {
|
||||
|
|
@ -101,44 +110,52 @@
|
|||
case 'search':
|
||||
tasks = searchTasks(allTasks, viewStore.searchQuery);
|
||||
break;
|
||||
case 'label':
|
||||
tasks = filterIncomplete(allTasks).filter((t) => {
|
||||
const ids: string[] = (t.metadata as { labelIds?: string[] })?.labelIds ?? [];
|
||||
return ids.includes(viewStore.currentLabelId ?? '');
|
||||
});
|
||||
break;
|
||||
default:
|
||||
tasks = filterIncomplete(allTasks);
|
||||
}
|
||||
if (viewStore.showCompleted && viewStore.currentView !== 'completed') {
|
||||
tasks = [...tasks, ...filterCompleted(allTasks)];
|
||||
}
|
||||
return sortTasks(tasks, viewStore.sortBy, viewStore.sortOrder);
|
||||
});
|
||||
|
||||
// Share modal state
|
||||
// Board view state
|
||||
let isBoardView = $state(false);
|
||||
let activeBoardId = $state<string | null>(null);
|
||||
let activeBoard = $derived(
|
||||
allBoardViews.find((v) => v.id === activeBoardId) ?? allBoardViews[0] ?? null
|
||||
);
|
||||
|
||||
// Modal states
|
||||
let editTask = $state<Task | null>(null);
|
||||
let shareTask = $state<Task | null>(null);
|
||||
let showSyntaxHelp = $state(false);
|
||||
let showBoardEditor = $state(false);
|
||||
let editBoardView = $state<LocalBoardView | null>(null);
|
||||
let showOnboarding = $state(false);
|
||||
|
||||
let shareUrl = $derived(
|
||||
shareTask
|
||||
? `${typeof window !== 'undefined' ? window.location.origin : ''}/todo?task=${shareTask.id}`
|
||||
: ''
|
||||
);
|
||||
|
||||
// Quick add task
|
||||
let newTaskTitle = $state('');
|
||||
let isAdding = $state(false);
|
||||
// Check onboarding
|
||||
onMount(() => {
|
||||
try {
|
||||
if (!localStorage.getItem('todo-onboarding-done')) {
|
||||
showOnboarding = true;
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
async function handleQuickAdd() {
|
||||
if (!newTaskTitle.trim()) return;
|
||||
await tasksStore.createTask({ title: newTaskTitle.trim() });
|
||||
newTaskTitle = '';
|
||||
isAdding = false;
|
||||
}
|
||||
|
||||
async function handleToggleComplete(e: MouseEvent, task: Task) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
await tasksStore.toggleComplete(task.id);
|
||||
}
|
||||
|
||||
async function handleDeleteTask(e: MouseEvent, task: Task) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
await tasksStore.deleteTask(task.id);
|
||||
}
|
||||
|
||||
// ── DnD: register tag drop handler for passive drops (task→tag in TagStrip)
|
||||
// DnD tag drop handler
|
||||
const tagDropCtx = getContext<{
|
||||
set: (handler: (tagId: string, payload: DragPayload) => void) => void;
|
||||
clear: () => void;
|
||||
|
|
@ -157,109 +174,104 @@
|
|||
return () => tagDropCtx?.clear();
|
||||
});
|
||||
|
||||
// ── DnD: tag dropped onto a task ────────────────────────
|
||||
function handleTagDrop(task: Task, payload: DragPayload) {
|
||||
const tagData = payload.data as TagDragData;
|
||||
const currentLabels: string[] = (task.metadata as { labelIds?: string[] })?.labelIds ?? [];
|
||||
if (!currentLabels.includes(tagData.id)) {
|
||||
tasksStore.updateLabels(task.id, [...currentLabels, tagData.id]);
|
||||
}
|
||||
// Board view callbacks
|
||||
async function handleBoardQuickAdd(title: string, _columnId: string) {
|
||||
await tasksStore.createTask({ title });
|
||||
}
|
||||
|
||||
function tagNotAlreadyOnTask(task: Task) {
|
||||
return (payload: DragPayload) => {
|
||||
const tagData = payload.data as TagDragData;
|
||||
const currentLabels: string[] = (task.metadata as { labelIds?: string[] })?.labelIds ?? [];
|
||||
return !currentLabels.includes(tagData.id);
|
||||
};
|
||||
}
|
||||
|
||||
// View navigation items
|
||||
const views = [
|
||||
{ id: 'inbox', label: 'Inbox', icon: Tray },
|
||||
{ id: 'today', label: 'Heute', icon: CalendarBlank },
|
||||
{ id: 'upcoming', label: 'Bald faellig', icon: CalendarCheck },
|
||||
{ id: 'completed', label: 'Erledigt', icon: CheckCircle },
|
||||
] as const;
|
||||
|
||||
let selectedTaskId = $state<string | null>(null);
|
||||
let selectedTask = $derived(allTasks.find((t) => t.id === selectedTaskId));
|
||||
|
||||
function formatDueDate(date: string | null | undefined): string {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const tomorrowStart = new Date(todayStart);
|
||||
tomorrowStart.setDate(tomorrowStart.getDate() + 1);
|
||||
|
||||
if (d < todayStart) return 'Ueberfaellig';
|
||||
if (d >= todayStart && d < tomorrowStart) return 'Heute';
|
||||
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
function getDueDateColor(date: string | null | undefined): string {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
if (d < todayStart) return 'text-red-500';
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
// View navigation
|
||||
let views = $derived([
|
||||
{ id: 'inbox', label: $_('todo.inbox'), icon: Tray },
|
||||
{ id: 'today', label: $_('todo.todayView'), icon: CalendarBlank },
|
||||
{ id: 'upcoming', label: $_('todo.upcoming'), icon: CalendarCheck },
|
||||
{ id: 'completed', label: $_('todo.completedView'), icon: CheckCircle },
|
||||
] as const);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Todo - ManaCore</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<!-- Header with Stats -->
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">Todo</h1>
|
||||
<div class="mt-2 flex gap-4 text-sm text-muted-foreground">
|
||||
<span>{stats.total} Aufgaben</span>
|
||||
<span>{stats.completed} erledigt</span>
|
||||
{#if stats.overdue > 0}
|
||||
<span class="text-red-500">{stats.overdue} ueberfaellig</span>
|
||||
{/if}
|
||||
{#if stats.today > 0}
|
||||
<span class="text-amber-500">{stats.today} heute</span>
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<!-- Header -->
|
||||
<header class="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('todo.title')}</h1>
|
||||
{#if isLoaded}
|
||||
<div class="mt-1 flex gap-4 text-sm text-muted-foreground">
|
||||
<span>{stats.total} {$_('todo.tasks')}</span>
|
||||
<span>{stats.completed} {$_('todo.completed')}</span>
|
||||
{#if stats.overdue > 0}
|
||||
<span class="text-red-500">{stats.overdue} {$_('todo.overdue')}</span>
|
||||
{/if}
|
||||
{#if stats.today > 0}
|
||||
<span class="text-amber-500">{stats.today} {$_('todo.today')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<StatisticsSkeleton />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<SyncIndicator />
|
||||
<a
|
||||
href="/todo/settings"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
|
||||
title={$_('todo.settings.title')}
|
||||
>
|
||||
<Gear size={16} />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- View Tabs -->
|
||||
<div class="mb-4 flex gap-1 rounded-lg border border-border bg-card p-1">
|
||||
{#each views as view}
|
||||
<button
|
||||
onclick={() => {
|
||||
switch (view.id) {
|
||||
case 'inbox':
|
||||
viewStore.setInbox();
|
||||
break;
|
||||
case 'today':
|
||||
viewStore.setToday();
|
||||
break;
|
||||
case 'upcoming':
|
||||
viewStore.setUpcoming();
|
||||
break;
|
||||
case 'completed':
|
||||
viewStore.setCompleted();
|
||||
break;
|
||||
}
|
||||
}}
|
||||
class="flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors
|
||||
{viewStore.currentView === view.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
|
||||
>
|
||||
<view.icon size={16} />
|
||||
<span class="hidden sm:inline">{view.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<!-- View Tabs + Toolbar -->
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<div class="flex gap-1 rounded-lg border border-border bg-card p-1">
|
||||
{#each views as view}
|
||||
<button
|
||||
onclick={() => {
|
||||
isBoardView = false;
|
||||
switch (view.id) {
|
||||
case 'inbox':
|
||||
viewStore.setInbox();
|
||||
break;
|
||||
case 'today':
|
||||
viewStore.setToday();
|
||||
break;
|
||||
case 'upcoming':
|
||||
viewStore.setUpcoming();
|
||||
break;
|
||||
case 'completed':
|
||||
viewStore.setCompleted();
|
||||
break;
|
||||
}
|
||||
}}
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors
|
||||
{!isBoardView && viewStore.currentView === view.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
|
||||
>
|
||||
<view.icon size={16} />
|
||||
<span class="hidden sm:inline">{view.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<TodoToolbar
|
||||
showBoardToggle={allBoardViews.length > 0}
|
||||
{isBoardView}
|
||||
onToggleBoard={() => (isBoardView = !isBoardView)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search (for search view) -->
|
||||
<!-- Tag Strip -->
|
||||
<TagStrip
|
||||
labels={allLabels}
|
||||
collapsed={todoSettings.filterStripCollapsed}
|
||||
onToggleCollapse={() => todoSettings.toggleFilterStrip()}
|
||||
/>
|
||||
|
||||
<!-- Search -->
|
||||
{#if viewStore.currentView === 'search'}
|
||||
<div class="relative mb-4">
|
||||
<MagnifyingGlass
|
||||
|
|
@ -268,246 +280,95 @@
|
|||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Aufgaben suchen..."
|
||||
placeholder={$_('todo.search') + '...'}
|
||||
value={viewStore.searchQuery}
|
||||
oninput={(e) => viewStore.updateSearchQuery(e.currentTarget.value)}
|
||||
class="w-full rounded-lg border border-border bg-card py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Board View Selector -->
|
||||
{#if isBoardView}
|
||||
<div class="mb-4">
|
||||
<ViewSelector
|
||||
views={allBoardViews}
|
||||
activeViewId={activeBoardId}
|
||||
onSelect={(id) => (activeBoardId = id)}
|
||||
onCreateView={() => {
|
||||
editBoardView = null;
|
||||
showBoardEditor = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Quick Add -->
|
||||
{#if isAdding}
|
||||
<div class="mb-4 rounded-lg border border-primary bg-card p-3">
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleQuickAdd();
|
||||
}}
|
||||
class="flex gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Was moechtest du erledigen?"
|
||||
bind:value={newTaskTitle}
|
||||
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newTaskTitle.trim()}
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
isAdding = false;
|
||||
newTaskTitle = '';
|
||||
}}
|
||||
class="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (isAdding = true)}
|
||||
class="mb-4 flex w-full items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:border-primary hover:text-primary"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Neue Aufgabe
|
||||
</button>
|
||||
{#if !isBoardView}
|
||||
<QuickAddTask labels={allLabels} onShowSyntaxHelp={() => (showSyntaxHelp = true)} />
|
||||
{/if}
|
||||
|
||||
<!-- Task List -->
|
||||
{#if displayTasks.length === 0}
|
||||
<!-- Main Content -->
|
||||
{#if !isLoaded}
|
||||
<TaskListSkeleton />
|
||||
{:else if isBoardView && activeBoard}
|
||||
<BoardViewRenderer
|
||||
view={activeBoard}
|
||||
tasks={allTasks}
|
||||
labels={allLabels}
|
||||
wipLimit={todoSettings.wipLimitPerColumn}
|
||||
cardSize={todoSettings.kanbanCardSize}
|
||||
onToggleComplete={(id) => tasksStore.toggleComplete(id)}
|
||||
onSaveTask={(id, data) => tasksStore.updateTask(id, data)}
|
||||
onDeleteTask={(id) => tasksStore.deleteTask(id)}
|
||||
onQuickAdd={handleBoardQuickAdd}
|
||||
onOpenTask={(task) => (editTask = task)}
|
||||
/>
|
||||
{:else if displayTasks.length === 0}
|
||||
<div class="flex flex-col items-center py-12 text-center">
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Tray size={32} class="text-muted-foreground" />
|
||||
</div>
|
||||
<h2 class="mb-1 text-lg font-semibold text-foreground">
|
||||
{#if viewStore.currentView === 'completed'}
|
||||
Noch keine Aufgaben erledigt
|
||||
{$_('todo.noTasksCompleted')}
|
||||
{:else if viewStore.currentView === 'today'}
|
||||
Keine Aufgaben fuer heute
|
||||
{$_('todo.noTasksToday')}
|
||||
{:else if viewStore.currentView === 'upcoming'}
|
||||
Keine anstehenden Aufgaben
|
||||
{$_('todo.noTasksUpcoming')}
|
||||
{:else if viewStore.currentView === 'search'}
|
||||
{$_('todo.noTasks')}
|
||||
{:else}
|
||||
Inbox ist leer
|
||||
{$_('todo.noTasksInbox')}
|
||||
{/if}
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{#if viewStore.currentView === 'inbox'}
|
||||
Erstelle deine erste Aufgabe mit dem + Button oben.
|
||||
{$_('todo.firstTaskHint')}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each displayTasks as task (task.id)}
|
||||
<div
|
||||
class="group flex items-start gap-3 rounded-lg border border-transparent px-3 py-2.5 transition-colors hover:border-border hover:bg-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => (selectedTaskId = selectedTaskId === task.id ? null : task.id)}
|
||||
use:dropTarget={{
|
||||
accepts: ['tag'],
|
||||
onDrop: (payload) => handleTagDrop(task, payload),
|
||||
canDrop: tagNotAlreadyOnTask(task),
|
||||
}}
|
||||
>
|
||||
<!-- Completion Toggle -->
|
||||
<button
|
||||
onclick={(e) => handleToggleComplete(e, task)}
|
||||
class="mt-0.5 flex-shrink-0 transition-colors
|
||||
{task.isCompleted ? 'text-green-500' : `text-muted-foreground hover:text-primary`}"
|
||||
title={task.isCompleted ? 'Als offen markieren' : 'Als erledigt markieren'}
|
||||
>
|
||||
{#if task.isCompleted}
|
||||
<Check size={20} weight="bold" />
|
||||
{:else}
|
||||
<Circle size={20} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Task Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm {task.isCompleted
|
||||
? 'line-through text-muted-foreground'
|
||||
: 'text-foreground'}"
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Row -->
|
||||
<div class="mt-0.5 flex items-center gap-3 text-xs">
|
||||
{#if task.dueDate}
|
||||
<span class={getDueDateColor(task.dueDate)}>
|
||||
{formatDueDate(task.dueDate)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.priority !== 'medium'}
|
||||
<span style="color: {getPriorityColor(task.priority)}">
|
||||
{getPriorityLabel(task.priority)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.subtasks?.length}
|
||||
<span class="text-muted-foreground">
|
||||
{task.subtasks.filter((s) => s.isCompleted).length}/{task.subtasks.length} Teilaufgaben
|
||||
</span>
|
||||
{/if}
|
||||
{#each getTaskTags(task).slice(0, 3) as tag (tag.id)}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[0.625rem] font-medium"
|
||||
style="background: color-mix(in srgb, {tag.color} 15%, transparent); color: {tag.color}"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Expanded Detail -->
|
||||
{#if selectedTaskId === task.id}
|
||||
<div class="mt-3 space-y-2 border-t border-border pt-3">
|
||||
{#if task.description}
|
||||
<p class="text-sm text-muted-foreground">{task.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if task.subtasks?.length}
|
||||
<div class="space-y-1">
|
||||
{#each task.subtasks as subtask (subtask.id)}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
{#if subtask.isCompleted}
|
||||
<Check size={14} class="text-green-500" />
|
||||
{:else}
|
||||
<Circle size={14} class="text-muted-foreground" />
|
||||
{/if}
|
||||
<span
|
||||
class={subtask.isCompleted
|
||||
? 'line-through text-muted-foreground'
|
||||
: 'text-foreground'}
|
||||
>
|
||||
{subtask.title}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<select
|
||||
value={task.priority}
|
||||
onchange={(e) =>
|
||||
tasksStore.updateTask(task.id, {
|
||||
priority: e.currentTarget.value as TaskPriority,
|
||||
})}
|
||||
class="rounded-md border border-border bg-background px-2 py-1 text-xs focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="urgent">Dringend</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={task.dueDate ? task.dueDate.split('T')[0] : ''}
|
||||
onchange={(e) =>
|
||||
tasksStore.updateTask(task.id, {
|
||||
dueDate: e.currentTarget.value
|
||||
? new Date(e.currentTarget.value).toISOString()
|
||||
: null,
|
||||
})}
|
||||
class="rounded-md border border-border bg-background px-2 py-1 text-xs focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
shareTask = task;
|
||||
}}
|
||||
class="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted"
|
||||
title="Kurzlink teilen"
|
||||
>
|
||||
<ShareNetwork size={14} />
|
||||
</button>
|
||||
<button
|
||||
onclick={(e) => handleDeleteTask(e, task)}
|
||||
class="ml-auto rounded-md px-2 py-1 text-xs text-red-500 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Priority indicator -->
|
||||
{#if task.priority === 'urgent' || task.priority === 'high'}
|
||||
<div
|
||||
class="mt-1 h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style="background-color: {getPriorityColor(task.priority)}"
|
||||
title={getPriorityLabel(task.priority)}
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<TaskList
|
||||
tasks={displayTasks}
|
||||
tags={tagList}
|
||||
compact={todoSettings.compactMode}
|
||||
dragEnabled={viewStore.sortBy === 'order'}
|
||||
onOpenTask={(task) => (editTask = task)}
|
||||
/>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-muted-foreground">
|
||||
{displayTasks.length} Aufgabe{displayTasks.length !== 1 ? 'n' : ''}
|
||||
{displayTasks.length}
|
||||
{$_('todo.tasks')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Projects Section -->
|
||||
{#if allProjects.length > 0}
|
||||
{#if !isBoardView && allProjects.length > 0}
|
||||
<div class="mt-8">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Projekte
|
||||
{$_('todo.projects')}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
{#each allProjects as project (project.id)}
|
||||
|
|
@ -519,33 +380,6 @@
|
|||
style="background-color: {project.color ?? '#6b7280'}"
|
||||
></div>
|
||||
<span class="flex-1 text-left text-foreground">{project.name}</span>
|
||||
<CaretRight size={14} class="text-muted-foreground" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Labels Section -->
|
||||
{#if allLabels.length > 0}
|
||||
<div class="mt-6">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Labels
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each allLabels as label (label.id)}
|
||||
<button
|
||||
onclick={() => viewStore.setLabel(label.id)}
|
||||
class="rounded-full border px-3 py-1 text-xs font-medium transition-colors
|
||||
{viewStore.currentView === 'label' && viewStore.currentLabelId === label.id
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:border-primary/50'}"
|
||||
>
|
||||
<span
|
||||
class="mr-1 inline-block h-2 w-2 rounded-full"
|
||||
style="background-color: {label.color}"
|
||||
></span>
|
||||
{label.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -553,7 +387,25 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Share Modal (uLoad integration) -->
|
||||
<!-- Task Edit Modal -->
|
||||
{#if editTask}
|
||||
<TaskEditModal task={editTask} open={true} onClose={() => (editTask = null)} />
|
||||
{/if}
|
||||
|
||||
<!-- Board Editor Modal -->
|
||||
<ViewEditorModal
|
||||
view={editBoardView}
|
||||
open={showBoardEditor}
|
||||
onClose={() => (showBoardEditor = false)}
|
||||
/>
|
||||
|
||||
<!-- Syntax Help Overlay -->
|
||||
<SyntaxHelpOverlay open={showSyntaxHelp} onClose={() => (showSyntaxHelp = false)} />
|
||||
|
||||
<!-- Onboarding Modal -->
|
||||
<OnboardingModal open={showOnboarding} onClose={() => (showOnboarding = false)} />
|
||||
|
||||
<!-- Share Modal -->
|
||||
<ShareModal
|
||||
visible={shareTask !== null}
|
||||
onClose={() => (shareTask = null)}
|
||||
|
|
@ -564,7 +416,6 @@
|
|||
/>
|
||||
|
||||
<style>
|
||||
/* DnD: tag hovering over task item */
|
||||
:global(.mana-drop-target-hover) {
|
||||
outline: 2px solid var(--color-primary, #6366f1);
|
||||
outline-offset: -2px;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
todoSettings,
|
||||
type KanbanCardSize,
|
||||
type LayoutMode,
|
||||
} from '$lib/modules/todo/stores/settings.svelte';
|
||||
import type { TaskPriority } from '$lib/modules/todo/types';
|
||||
import { ArrowLeft } from '@manacore/shared-icons';
|
||||
|
||||
function toggle(key: string) {
|
||||
todoSettings.update({ [key]: !todoSettings.settings[key] });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Todo Einstellungen - ManaCore</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<header class="mb-6 flex items-center gap-3">
|
||||
<a
|
||||
href="/todo"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-foreground">Todo Einstellungen</h1>
|
||||
</header>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Task Behavior -->
|
||||
<section class="rounded-xl border border-border bg-card p-5">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Aufgaben-Verhalten
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-foreground">Standard-Priorität</label>
|
||||
<select
|
||||
value={todoSettings.defaultPriority}
|
||||
onchange={(e) =>
|
||||
todoSettings.update({ defaultPriority: e.currentTarget.value as TaskPriority })}
|
||||
class="rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="urgent">Dringend</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-foreground">Standard-Fälligkeitszeit</label>
|
||||
<input
|
||||
type="time"
|
||||
value={todoSettings.settings.defaultDueTime ?? ''}
|
||||
onchange={(e) =>
|
||||
todoSettings.update({
|
||||
defaultDueTime: e.currentTarget.value || null,
|
||||
})}
|
||||
class="rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-foreground">Auto-Archivierung</label>
|
||||
<p class="text-xs text-muted-foreground">Erledigte Aufgaben nach X Tagen archivieren</p>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="-"
|
||||
value={todoSettings.settings.autoArchiveCompletedDays ?? ''}
|
||||
onchange={(e) =>
|
||||
todoSettings.update({
|
||||
autoArchiveCompletedDays: e.currentTarget.value
|
||||
? Number(e.currentTarget.value)
|
||||
: null,
|
||||
})}
|
||||
class="w-20 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- View & Display -->
|
||||
<section class="rounded-xl border border-border bg-card p-5">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Ansicht & Darstellung
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-foreground">Standard-Ansicht</label>
|
||||
<select
|
||||
value={todoSettings.defaultView}
|
||||
onchange={(e) => todoSettings.update({ defaultView: e.currentTarget.value as any })}
|
||||
class="rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="inbox">Inbox</option>
|
||||
<option value="today">Heute</option>
|
||||
<option value="upcoming">Anstehend</option>
|
||||
<option value="kanban">Kanban</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#each [{ key: 'compactMode', label: 'Kompaktmodus', desc: 'Weniger Abstand zwischen Aufgaben' }, { key: 'showTaskCounts', label: 'Aufgabenzahl', desc: 'Anzahl der Aufgaben anzeigen' }, { key: 'showSubtaskProgress', label: 'Teilaufgaben-Fortschritt', desc: 'Fortschritt der Subtasks anzeigen' }, { key: 'groupByProject', label: 'Nach Projekt gruppieren', desc: 'Aufgaben nach Projekt gruppieren' }] as toggle_item}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-foreground">{toggle_item.label}</label>
|
||||
<p class="text-xs text-muted-foreground">{toggle_item.desc}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => toggle(toggle_item.key)}
|
||||
class="relative h-6 w-11 rounded-full transition-colors {todoSettings.settings[
|
||||
toggle_item.key
|
||||
]
|
||||
? 'bg-primary'
|
||||
: 'bg-muted'}"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform {todoSettings
|
||||
.settings[toggle_item.key]
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0.5'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Kanban Board -->
|
||||
<section class="rounded-xl border border-border bg-card p-5">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Kanban Board
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-foreground">Kartengröße</label>
|
||||
<div class="flex gap-1">
|
||||
{#each ['compact', 'normal', 'large'] as size}
|
||||
<button
|
||||
onclick={() => todoSettings.update({ kanbanCardSize: size as KanbanCardSize })}
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium transition-colors
|
||||
{todoSettings.kanbanCardSize === size
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted'}"
|
||||
>
|
||||
{size === 'compact' ? 'Kompakt' : size === 'normal' ? 'Normal' : 'Groß'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-foreground">Labels auf Karten</label>
|
||||
<button
|
||||
onclick={() => toggle('showLabelsOnCards')}
|
||||
class="relative h-6 w-11 rounded-full transition-colors {todoSettings.showLabelsOnCards
|
||||
? 'bg-primary'
|
||||
: 'bg-muted'}"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform {todoSettings.showLabelsOnCards
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0.5'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-foreground">WIP-Limit pro Spalte</label>
|
||||
<p class="text-xs text-muted-foreground">Maximum Aufgaben pro Spalte</p>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="-"
|
||||
value={todoSettings.wipLimitPerColumn ?? ''}
|
||||
onchange={(e) =>
|
||||
todoSettings.update({
|
||||
wipLimitPerColumn: e.currentTarget.value ? Number(e.currentTarget.value) : null,
|
||||
})}
|
||||
class="w-20 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Smart Duration -->
|
||||
<section class="rounded-xl border border-border bg-card p-5">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Smarte Dauer
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-foreground">Smarte Dauer-Schätzung</label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Dauer automatisch basierend auf ähnlichen Aufgaben schätzen
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => toggle('smartDurationEnabled')}
|
||||
class="relative h-6 w-11 rounded-full transition-colors {todoSettings.smartDurationEnabled
|
||||
? 'bg-primary'
|
||||
: 'bg-muted'}"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform {todoSettings.smartDurationEnabled
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0.5'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-foreground">Standard-Dauer (Minuten)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
step="5"
|
||||
value={todoSettings.defaultTaskDuration}
|
||||
onchange={(e) =>
|
||||
todoSettings.update({ defaultTaskDuration: Number(e.currentTarget.value) || 30 })}
|
||||
class="w-20 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notifications -->
|
||||
<section class="rounded-xl border border-border bg-card p-5">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Benachrichtigungen
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-foreground">Standard-Erinnerung</label>
|
||||
<select
|
||||
value={todoSettings.settings.defaultReminderMinutes ?? ''}
|
||||
onchange={(e) =>
|
||||
todoSettings.update({
|
||||
defaultReminderMinutes: e.currentTarget.value
|
||||
? Number(e.currentTarget.value)
|
||||
: null,
|
||||
})}
|
||||
class="rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="">Keine</option>
|
||||
<option value="5">5 Min</option>
|
||||
<option value="15">15 Min</option>
|
||||
<option value="30">30 Min</option>
|
||||
<option value="60">1 Std</option>
|
||||
<option value="1440">1 Tag</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#each [{ key: 'dailyDigestEnabled', label: 'Tägliche Zusammenfassung' }, { key: 'overdueNotifications', label: 'Überfällig-Benachrichtigungen' }] as item}
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-foreground">{item.label}</label>
|
||||
<button
|
||||
onclick={() => toggle(item.key)}
|
||||
class="relative h-6 w-11 rounded-full transition-colors {todoSettings.settings[
|
||||
item.key
|
||||
]
|
||||
? 'bg-primary'
|
||||
: 'bg-muted'}"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform {todoSettings
|
||||
.settings[item.key]
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0.5'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Productivity -->
|
||||
<section class="rounded-xl border border-border bg-card p-5">
|
||||
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Produktivität
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{#each [{ key: 'focusMode', label: 'Fokus-Modus', desc: 'Ablenkungen minimieren' }, { key: 'pomodoroEnabled', label: 'Pomodoro', desc: 'Pomodoro-Timer integrieren' }, { key: 'showStreak', label: 'Streak anzeigen', desc: 'Tägliche Erledigungsserie' }, { key: 'immersiveModeEnabled', label: 'Immersiver Modus', desc: 'Navigation ausblenden' }] as item}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm text-foreground">{item.label}</label>
|
||||
<p class="text-xs text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => toggle(item.key)}
|
||||
class="relative h-6 w-11 rounded-full transition-colors {todoSettings.settings[
|
||||
item.key
|
||||
]
|
||||
? 'bg-primary'
|
||||
: 'bg-muted'}"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform {todoSettings
|
||||
.settings[item.key]
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0.5'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm text-foreground">Tagesziel (Aufgaben)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="-"
|
||||
value={todoSettings.settings.dailyGoal ?? ''}
|
||||
onchange={(e) =>
|
||||
todoSettings.update({
|
||||
dailyGoal: e.currentTarget.value ? Number(e.currentTarget.value) : null,
|
||||
})}
|
||||
class="w-20 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Reset -->
|
||||
<div class="flex justify-end pb-8">
|
||||
<button
|
||||
onclick={() => {
|
||||
if (confirm('Alle Todo-Einstellungen zurücksetzen?')) {
|
||||
todoSettings.reset();
|
||||
}
|
||||
}}
|
||||
class="rounded-md px-4 py-2 text-sm text-red-500 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
Einstellungen zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue