refactor(todo): rename Labels to Tags for consistency across apps

- Rename route /labels to /tags and /label/[id] to /tag/[id]
- Rename LabelSelector component to TagSelector
- Update all UI texts from "Labels" to "Tags"
- Update navigation items and references
- Align terminology with Calendar and Contacts apps

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-10 15:21:33 +01:00
parent 6b30a914f4
commit 9f13bba3d0
29 changed files with 1964 additions and 383 deletions

View file

@ -221,6 +221,7 @@ CLOCK_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/clock
# ============================================
TODO_BACKEND_PORT=3018
TODO_BACKEND_URL=http://localhost:3018
TODO_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/todo
# ============================================

View file

@ -0,0 +1,370 @@
/**
* Cross-App API Client for Todo Backend
* Allows Calendar app to fetch/manage todos from the Todo service
*/
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
const TODO_API_BASE = env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018';
// ============================================
// Types (mirrored from @todo/shared for cross-app use)
// ============================================
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
export interface Subtask {
id: string;
title: string;
isCompleted: boolean;
completedAt?: string | null;
order: number;
}
export interface Label {
id: string;
userId: string;
name: string;
color: string;
createdAt: string;
updatedAt: string;
}
export interface Project {
id: string;
userId: string;
name: string;
description?: string | null;
color: string;
icon?: string | null;
order: number;
isArchived: boolean;
isDefault: boolean;
createdAt: string;
updatedAt: string;
}
export interface TaskMetadata {
notes?: string;
attachments?: string[];
linkedCalendarEventId?: string | null;
storyPoints?: number | null;
effectiveDuration?: {
value: number;
unit: 'minutes' | 'hours' | 'days';
} | null;
funRating?: number | null;
}
export interface Task {
id: string;
projectId?: string | null;
userId: string;
parentTaskId?: string | null;
title: string;
description?: string | null;
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
priority: TaskPriority;
status: TaskStatus;
isCompleted: boolean;
completedAt?: string | null;
order: number;
columnId?: string | null;
columnOrder?: number;
recurrenceRule?: string | null;
recurrenceEndDate?: string | null;
lastOccurrence?: string | null;
subtasks?: Subtask[] | null;
metadata?: TaskMetadata | null;
labels?: Label[];
project?: Project | null;
createdAt: string;
updatedAt: string;
}
export interface CreateTaskInput {
title: string;
description?: string;
projectId?: string | null;
dueDate?: string | null;
dueTime?: string | null;
priority?: TaskPriority;
labelIds?: string[];
subtasks?: Omit<Subtask, 'id'>[];
recurrenceRule?: string | null;
metadata?: TaskMetadata;
}
export interface UpdateTaskInput {
title?: string;
description?: string | null;
projectId?: string | null;
dueDate?: string | null;
dueTime?: string | null;
priority?: TaskPriority;
status?: TaskStatus;
isCompleted?: boolean;
subtasks?: Subtask[] | null;
recurrenceRule?: string | null;
metadata?: TaskMetadata | null;
labelIds?: string[];
}
export interface TaskQuery {
projectId?: string;
labelId?: string;
priority?: TaskPriority;
status?: TaskStatus;
isCompleted?: boolean;
dueDateFrom?: string;
dueDateTo?: string;
search?: string;
sortBy?: 'dueDate' | 'priority' | 'createdAt' | 'order';
sortOrder?: 'asc' | 'desc';
limit?: number;
offset?: number;
}
// ============================================
// API Response Types
// ============================================
interface TasksResponse {
tasks: Task[];
}
interface TaskResponse {
task: Task;
}
interface ProjectsResponse {
projects: Project[];
}
interface LabelsResponse {
labels: Label[];
}
// ============================================
// API Client
// ============================================
type FetchOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
token?: string;
};
async function fetchTodoApi<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<{ data: T | null; error: Error | null }> {
const { method = 'GET', body, token } = options;
let authToken = token;
if (!authToken && browser) {
authToken = localStorage.getItem('@auth/appToken') || undefined;
}
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${TODO_API_BASE}/api/v1${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `Todo API error: ${response.status}`),
};
}
// Handle empty responses (204 No Content)
if (response.status === 204) {
return { data: null, error: null };
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Failed to connect to Todo service'),
};
}
}
// ============================================
// Helper Functions
// ============================================
function buildQueryString(query: TaskQuery): string {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
params.append(key, String(value));
}
});
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
// ============================================
// Task API Functions
// ============================================
export async function getTasks(
query: TaskQuery = {}
): Promise<{ data: Task[] | null; error: Error | null }> {
const queryString = buildQueryString(query);
const result = await fetchTodoApi<TasksResponse>(`/tasks${queryString}`);
return {
data: result.data?.tasks || null,
error: result.error,
};
}
export async function getTask(id: string): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}`);
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function createTask(
data: CreateTaskInput
): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>('/tasks', {
method: 'POST',
body: data,
});
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function updateTask(
id: string,
data: UpdateTaskInput
): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}`, {
method: 'PUT',
body: data,
});
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function deleteTask(id: string): Promise<{ error: Error | null }> {
const result = await fetchTodoApi(`/tasks/${id}`, {
method: 'DELETE',
});
return { error: result.error };
}
export async function completeTask(
id: string
): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}/complete`, {
method: 'POST',
});
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function uncompleteTask(
id: string
): Promise<{ data: Task | null; error: Error | null }> {
const result = await fetchTodoApi<TaskResponse>(`/tasks/${id}/uncomplete`, {
method: 'POST',
});
return {
data: result.data?.task || null,
error: result.error,
};
}
export async function getTodayTasks(): Promise<{ data: Task[] | null; error: Error | null }> {
const result = await fetchTodoApi<TasksResponse>('/tasks/today');
return {
data: result.data?.tasks || null,
error: result.error,
};
}
export async function getUpcomingTasks(): Promise<{ data: Task[] | null; error: Error | null }> {
const result = await fetchTodoApi<TasksResponse>('/tasks/upcoming');
return {
data: result.data?.tasks || null,
error: result.error,
};
}
// ============================================
// Project API Functions
// ============================================
export async function getProjects(): Promise<{ data: Project[] | null; error: Error | null }> {
const result = await fetchTodoApi<ProjectsResponse>('/projects');
return {
data: result.data?.projects || null,
error: result.error,
};
}
// ============================================
// Label API Functions
// ============================================
export async function getLabels(): Promise<{ data: Label[] | null; error: Error | null }> {
const result = await fetchTodoApi<LabelsResponse>('/labels');
return {
data: result.data?.labels || null,
error: result.error,
};
}
// ============================================
// Priority Colors Helper
// ============================================
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
urgent: 'hsl(var(--color-danger))',
high: 'hsl(var(--color-warning))',
medium: 'hsl(var(--color-accent))',
low: 'hsl(var(--color-success))',
};
export const PRIORITY_LABELS: Record<TaskPriority, string> = {
urgent: 'Dringend',
high: 'Wichtig',
medium: 'Normal',
low: 'Später',
};
export const PRIORITY_ORDER: Record<TaskPriority, number> = {
urgent: 0,
high: 1,
medium: 2,
low: 3,
};

View file

@ -0,0 +1,124 @@
<script lang="ts">
import type { TaskPriority } from '$lib/api/todos';
import { PRIORITY_COLORS, PRIORITY_LABELS } from '$lib/api/todos';
interface Props {
priority: TaskPriority;
variant?: 'dot' | 'badge' | 'pill';
size?: 'sm' | 'md';
showLabel?: boolean;
}
let { priority, variant = 'dot', size = 'md', showLabel = false }: Props = $props();
const color = $derived(PRIORITY_COLORS[priority]);
const label = $derived(PRIORITY_LABELS[priority]);
</script>
{#if variant === 'dot'}
<span
class="priority-dot"
class:size-sm={size === 'sm'}
style="--priority-color: {color};"
title={label}
aria-label="Priorität: {label}"
></span>
{:else if variant === 'badge'}
<span
class="priority-badge"
class:size-sm={size === 'sm'}
style="--priority-color: {color};"
title={label}
>
{#if showLabel}
{label}
{:else}
{priority.charAt(0).toUpperCase()}
{/if}
</span>
{:else if variant === 'pill'}
<span class="priority-pill" class:size-sm={size === 'sm'} style="--priority-color: {color};">
<span class="pill-dot"></span>
{#if showLabel}
<span class="pill-label">{label}</span>
{/if}
</span>
{/if}
<style>
/* Dot variant */
.priority-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--priority-color);
flex-shrink: 0;
}
.priority-dot.size-sm {
width: 6px;
height: 6px;
}
/* Badge variant */
.priority-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 4px;
background: var(--priority-color);
color: white;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.priority-badge.size-sm {
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 0.625rem;
}
/* Pill variant */
.priority-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 9999px;
background: color-mix(in srgb, var(--priority-color) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--priority-color) 30%, transparent);
}
.priority-pill.size-sm {
gap: 4px;
padding: 1px 6px;
}
.pill-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--priority-color);
}
.priority-pill.size-sm .pill-dot {
width: 5px;
height: 5px;
}
.pill-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--priority-color);
}
.priority-pill.size-sm .pill-label {
font-size: 0.6875rem;
}
</style>

View file

@ -0,0 +1,226 @@
<script lang="ts">
import { todosStore } from '$lib/stores/todos.svelte';
import { Plus, X } from 'lucide-svelte';
interface Props {
placeholder?: string;
onsubmit?: () => void;
oncancel?: () => void;
autofocus?: boolean;
showButton?: boolean;
}
let {
placeholder = 'Neue Aufgabe...',
onsubmit,
oncancel,
autofocus = false,
showButton = true,
}: Props = $props();
let title = $state('');
let isExpanded = $state(!showButton);
let isSubmitting = $state(false);
let inputRef: HTMLInputElement | undefined = $state();
function expand() {
isExpanded = true;
// Focus input after DOM update
setTimeout(() => inputRef?.focus(), 0);
}
function collapse() {
isExpanded = false;
title = '';
oncancel?.();
}
async function handleSubmit(e?: Event) {
e?.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle || isSubmitting) return;
isSubmitting = true;
const result = await todosStore.createTodo({
title: trimmedTitle,
priority: 'medium',
});
isSubmitting = false;
if (!result.error) {
title = '';
onsubmit?.();
// Keep input focused for quick successive adds
inputRef?.focus();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
} else if (e.key === 'Escape') {
collapse();
}
}
function handleBlur() {
// Only collapse if empty and showButton is true
if (showButton && !title.trim()) {
collapse();
}
}
</script>
{#if showButton && !isExpanded}
<button type="button" class="add-button" onclick={expand}>
<Plus size={16} />
<span>Aufgabe hinzufügen</span>
</button>
{:else}
<form class="quick-add-form" onsubmit={handleSubmit}>
<input
bind:this={inputRef}
bind:value={title}
type="text"
class="quick-add-input"
{placeholder}
disabled={isSubmitting}
onkeydown={handleKeydown}
onblur={handleBlur}
autofocus={autofocus || isExpanded}
/>
{#if showButton}
<button type="button" class="cancel-button" onclick={collapse} disabled={isSubmitting}>
<X size={14} />
</button>
{/if}
<button
type="submit"
class="submit-button"
disabled={!title.trim() || isSubmitting}
aria-label="Aufgabe erstellen"
>
{#if isSubmitting}
<span class="spinner"></span>
{:else}
<Plus size={14} />
{/if}
</button>
</form>
{/if}
<style>
.add-button {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
border: 1px dashed hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition: all 150ms ease;
}
.add-button:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.05);
}
.quick-add-form {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
transition: border-color 150ms ease;
}
.quick-add-form:focus-within {
border-color: hsl(var(--color-primary));
}
.quick-add-input {
flex: 1;
min-width: 0;
padding: 0.375rem 0.5rem;
border: none;
background: transparent;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
outline: none;
}
.quick-add-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.quick-add-input:disabled {
opacity: 0.5;
}
.cancel-button,
.submit-button {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
border: none;
cursor: pointer;
transition: all 150ms ease;
}
.cancel-button {
background: transparent;
color: hsl(var(--color-muted-foreground));
}
.cancel-button:hover:not(:disabled) {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.submit-button {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.submit-button:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.9);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid hsl(var(--color-primary-foreground) / 0.3);
border-top-color: hsl(var(--color-primary-foreground));
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,130 @@
<script lang="ts">
import { Check } from 'lucide-svelte';
interface Props {
checked: boolean;
loading?: boolean;
size?: 'sm' | 'md' | 'lg';
onchange?: (checked: boolean) => void;
}
let { checked, loading = false, size = 'md', onchange }: Props = $props();
const sizes = {
sm: { box: 14, icon: 10 },
md: { box: 18, icon: 12 },
lg: { box: 22, icon: 16 },
};
function handleClick(e: MouseEvent) {
e.stopPropagation();
if (!loading && onchange) {
onchange(!checked);
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
if (!loading && onchange) {
onchange(!checked);
}
}
}
</script>
<button
type="button"
class="todo-checkbox"
class:checked
class:loading
class:size-sm={size === 'sm'}
class:size-md={size === 'md'}
class:size-lg={size === 'lg'}
style="--box-size: {sizes[size].box}px; --icon-size: {sizes[size].icon}px;"
onclick={handleClick}
onkeydown={handleKeydown}
disabled={loading}
aria-checked={checked}
aria-label={checked ? 'Als unerledigt markieren' : 'Als erledigt markieren'}
role="checkbox"
>
{#if loading}
<span class="spinner"></span>
{:else if checked}
<Check size={sizes[size].icon} strokeWidth={3} />
{/if}
</button>
<style>
.todo-checkbox {
width: var(--box-size);
height: var(--box-size);
min-width: var(--box-size);
border-radius: 4px;
border: 2px solid hsl(var(--color-border));
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 150ms ease;
padding: 0;
}
.todo-checkbox:hover:not(:disabled) {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.todo-checkbox:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
.todo-checkbox.checked {
background: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.todo-checkbox.checked:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.8);
border-color: hsl(var(--color-primary) / 0.8);
}
.todo-checkbox:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.todo-checkbox.loading {
cursor: wait;
}
.spinner {
width: calc(var(--icon-size) - 2px);
height: calc(var(--icon-size) - 2px);
border: 2px solid hsl(var(--color-muted-foreground) / 0.3);
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Size variants */
.size-sm {
border-radius: 3px;
border-width: 1.5px;
}
.size-lg {
border-radius: 5px;
}
</style>

View file

@ -0,0 +1,287 @@
<script lang="ts">
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS } from '$lib/api/todos';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoCheckbox from './TodoCheckbox.svelte';
import PriorityBadge from './PriorityBadge.svelte';
import { format, parseISO, isToday, isTomorrow, isPast, startOfDay } from 'date-fns';
import { de } from 'date-fns/locale';
interface Props {
task: Task;
variant?: 'default' | 'compact' | 'minimal';
showProject?: boolean;
showDueDate?: boolean;
showPriority?: boolean;
onclick?: () => void;
}
let {
task,
variant = 'default',
showProject = true,
showDueDate = true,
showPriority = true,
onclick,
}: Props = $props();
let isToggling = $state(false);
const priorityColor = $derived(PRIORITY_COLORS[task.priority]);
const dueDateLabel = $derived.by(() => {
if (!task.dueDate) return null;
const date = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
if (isToday(date)) {
return task.dueTime ? `Heute, ${task.dueTime}` : 'Heute';
}
if (isTomorrow(date)) {
return task.dueTime ? `Morgen, ${task.dueTime}` : 'Morgen';
}
if (isPast(startOfDay(date)) && !task.isCompleted) {
return format(date, 'd. MMM', { locale: de });
}
return format(date, 'd. MMM', { locale: de });
});
const isOverdue = $derived.by(() => {
if (!task.dueDate || task.isCompleted) return false;
const date = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
return isPast(startOfDay(date)) && !isToday(date);
});
const subtaskProgress = $derived.by(() => {
if (!task.subtasks || task.subtasks.length === 0) return null;
const completed = task.subtasks.filter((s) => s.isCompleted).length;
return { completed, total: task.subtasks.length };
});
async function handleToggle(checked: boolean) {
isToggling = true;
await todosStore.toggleComplete(task.id);
isToggling = false;
}
function handleClick(e: MouseEvent) {
// Don't trigger onclick when clicking checkbox
if ((e.target as HTMLElement).closest('.todo-checkbox')) return;
onclick?.();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && onclick) {
onclick();
}
}
</script>
<div
class="todo-item"
class:completed={task.isCompleted}
class:overdue={isOverdue}
class:compact={variant === 'compact'}
class:minimal={variant === 'minimal'}
class:clickable={!!onclick}
style="--priority-color: {priorityColor};"
onclick={handleClick}
onkeydown={handleKeydown}
role={onclick ? 'button' : 'listitem'}
tabindex={onclick ? 0 : -1}
>
<TodoCheckbox
checked={task.isCompleted}
loading={isToggling}
size={variant === 'minimal' ? 'sm' : 'md'}
onchange={handleToggle}
/>
<div class="todo-content">
<div class="todo-main">
{#if showPriority && variant !== 'minimal'}
<PriorityBadge
priority={task.priority}
variant="dot"
size={variant === 'compact' ? 'sm' : 'md'}
/>
{/if}
<span class="todo-title">{task.title}</span>
{#if subtaskProgress && variant === 'default'}
<span class="subtask-count">
{subtaskProgress.completed}/{subtaskProgress.total}
</span>
{/if}
</div>
{#if variant !== 'minimal'}
<div class="todo-meta">
{#if showDueDate && dueDateLabel}
<span class="due-date" class:overdue={isOverdue}>
{dueDateLabel}
</span>
{/if}
{#if showProject && task.project}
<span class="project" style="--project-color: {task.project.color};">
{task.project.name}
</span>
{/if}
{#if task.labels && task.labels.length > 0 && variant === 'default'}
<div class="labels">
{#each task.labels.slice(0, 2) as label}
<span class="label" style="--label-color: {label.color};">
{label.name}
</span>
{/each}
{#if task.labels.length > 2}
<span class="label-more">+{task.labels.length - 2}</span>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
.todo-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: var(--radius-md);
background: hsl(var(--color-surface));
border-left: 3px solid var(--priority-color);
transition: all 150ms ease;
}
.todo-item.clickable {
cursor: pointer;
}
.todo-item.clickable:hover {
background: hsl(var(--color-muted) / 0.5);
transform: translateX(2px);
}
.todo-item.completed {
opacity: 0.6;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
color: hsl(var(--color-muted-foreground));
}
.todo-item.overdue {
background: hsl(var(--color-danger) / 0.05);
}
/* Compact variant */
.todo-item.compact {
padding: 0.5rem 0.625rem;
gap: 0.5rem;
border-left-width: 2px;
}
/* Minimal variant */
.todo-item.minimal {
padding: 0.375rem 0.5rem;
gap: 0.375rem;
border-left-width: 2px;
background: transparent;
}
.todo-item.minimal:hover {
background: hsl(var(--color-muted) / 0.3);
}
.todo-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.todo-main {
display: flex;
align-items: center;
gap: 0.5rem;
}
.todo-title {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compact .todo-title {
font-size: 0.8125rem;
}
.minimal .todo-title {
font-size: 0.75rem;
}
.subtask-count {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
background: hsl(var(--color-muted) / 0.5);
padding: 1px 6px;
border-radius: 9999px;
flex-shrink: 0;
}
.todo-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.due-date {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.due-date.overdue {
color: hsl(var(--color-danger));
font-weight: 500;
}
.project {
font-size: 0.6875rem;
color: var(--project-color);
background: color-mix(in srgb, var(--project-color) 15%, transparent);
padding: 1px 6px;
border-radius: 4px;
}
.labels {
display: flex;
align-items: center;
gap: 0.25rem;
}
.label {
font-size: 0.625rem;
color: var(--label-color);
background: color-mix(in srgb, var(--label-color) 15%, transparent);
padding: 1px 4px;
border-radius: 3px;
}
.label-more {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,405 @@
/**
* Todos Store - Manages todos from Todo-App using Svelte 5 runes
* Cross-app integration with Todo Backend
*/
import * as api from '$lib/api/todos';
import type {
Task,
TaskPriority,
CreateTaskInput,
UpdateTaskInput,
TaskQuery,
Project,
Label,
} from '$lib/api/todos';
import { PRIORITY_ORDER } from '$lib/api/todos';
import {
format,
parseISO,
isSameDay,
isToday,
isBefore,
startOfDay,
addDays,
isWithinInterval,
} from 'date-fns';
// Re-export types for convenience
export type { Task, TaskPriority, CreateTaskInput, UpdateTaskInput, Project, Label };
// State
let todos = $state<Task[]>([]);
let projects = $state<Project[]>([]);
let labels = $state<Label[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let loadedRange = $state<{ start: Date; end: Date } | null>(null);
let serviceAvailable = $state(true);
export const todosStore = {
// ========== Getters ==========
get todos() {
return todos ?? [];
},
get projects() {
return projects ?? [];
},
get labels() {
return labels ?? [];
},
get loading() {
return loading;
},
get error() {
return error;
},
get serviceAvailable() {
return serviceAvailable;
},
// ========== Derived Getters ==========
/**
* Get todos for a specific day
*/
getTodosForDay(date: Date): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
return currentTodos.filter((task) => {
if (!task.dueDate || task.isCompleted) return false;
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
return isSameDay(dueDate, date);
});
},
/**
* Get todos within a date range
*/
getTodosInRange(start: Date, end: Date): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
return currentTodos.filter((task) => {
if (!task.dueDate) return false;
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
return isWithinInterval(dueDate, { start, end });
});
},
/**
* Get today's uncompleted todos
*/
get todaysTodos(): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
return currentTodos
.filter((task) => {
if (task.isCompleted) return false;
if (!task.dueDate) return false;
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
return isToday(dueDate);
})
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
},
/**
* Get overdue todos (due before today, not completed)
*/
get overdueTodos(): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
const today = startOfDay(new Date());
return currentTodos
.filter((task) => {
if (task.isCompleted) return false;
if (!task.dueDate) return false;
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
return isBefore(startOfDay(dueDate), today);
})
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
},
/**
* Get upcoming todos (next 7 days, not including today)
*/
get upcomingTodos(): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
const tomorrow = startOfDay(addDays(new Date(), 1));
const weekFromNow = startOfDay(addDays(new Date(), 7));
return currentTodos
.filter((task) => {
if (task.isCompleted) return false;
if (!task.dueDate) return false;
const dueDate = typeof task.dueDate === 'string' ? parseISO(task.dueDate) : task.dueDate;
return isWithinInterval(startOfDay(dueDate), { start: tomorrow, end: weekFromNow });
})
.sort((a, b) => {
// First sort by date
const dateA = a.dueDate ? parseISO(a.dueDate as string) : new Date();
const dateB = b.dueDate ? parseISO(b.dueDate as string) : new Date();
const dateDiff = dateA.getTime() - dateB.getTime();
if (dateDiff !== 0) return dateDiff;
// Then by priority
return PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
});
},
/**
* Get todos without due date
*/
get unscheduledTodos(): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
return currentTodos
.filter((task) => !task.isCompleted && !task.dueDate)
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
},
/**
* Get completed todos
*/
get completedTodos(): Task[] {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return [];
return currentTodos.filter((task) => task.isCompleted);
},
/**
* Get combined sidebar todos (overdue + today, sorted by priority)
* Limited to show in sidebar
*/
getSidebarTodos(limit = 5): Task[] {
const overdue = this.overdueTodos;
const today = this.todaysTodos;
// Combine and sort: overdue first, then today, both by priority
const combined = [...overdue, ...today];
return combined.slice(0, limit);
},
/**
* Get total count of active todos (not completed)
*/
get activeTodosCount(): number {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return 0;
return currentTodos.filter((task) => !task.isCompleted).length;
},
// ========== API Methods ==========
/**
* Fetch todos for a date range
*/
async fetchTodos(startDate?: Date, endDate?: Date) {
loading = true;
error = null;
const query: TaskQuery = {
isCompleted: false,
};
if (startDate) {
query.dueDateFrom = format(startDate, 'yyyy-MM-dd');
}
if (endDate) {
query.dueDateTo = format(endDate, 'yyyy-MM-dd');
}
const result = await api.getTasks(query);
if (result.error) {
error = result.error.message;
serviceAvailable = false;
} else {
todos = result.data || [];
serviceAvailable = true;
if (startDate && endDate) {
loadedRange = { start: startDate, end: endDate };
}
}
loading = false;
return result;
},
/**
* Fetch today's todos (shortcut)
*/
async fetchTodayTodos() {
loading = true;
error = null;
const result = await api.getTodayTasks();
if (result.error) {
error = result.error.message;
serviceAvailable = false;
} else {
// Merge with existing todos (avoid duplicates)
const newTodos = result.data || [];
const existingIds = new Set(todos.map((t) => t.id));
const uniqueNew = newTodos.filter((t) => !existingIds.has(t.id));
todos = [...todos, ...uniqueNew];
serviceAvailable = true;
}
loading = false;
return result;
},
/**
* Fetch upcoming todos (shortcut)
*/
async fetchUpcomingTodos() {
loading = true;
error = null;
const result = await api.getUpcomingTasks();
if (result.error) {
error = result.error.message;
serviceAvailable = false;
} else {
// Merge with existing todos (avoid duplicates)
const newTodos = result.data || [];
const existingIds = new Set(todos.map((t) => t.id));
const uniqueNew = newTodos.filter((t) => !existingIds.has(t.id));
todos = [...todos, ...uniqueNew];
serviceAvailable = true;
}
loading = false;
return result;
},
/**
* Fetch projects
*/
async fetchProjects() {
const result = await api.getProjects();
if (!result.error && result.data) {
projects = result.data;
}
return result;
},
/**
* Fetch labels
*/
async fetchLabels() {
const result = await api.getLabels();
if (!result.error && result.data) {
labels = result.data;
}
return result;
},
/**
* Create a new todo
*/
async createTodo(data: CreateTaskInput) {
const result = await api.createTask(data);
if (result.data) {
todos = [...todos, result.data];
}
return result;
},
/**
* Update a todo
*/
async updateTodo(id: string, data: UpdateTaskInput) {
const result = await api.updateTask(id, data);
if (result.data) {
todos = todos.map((t) => (t.id === id ? result.data! : t));
}
return result;
},
/**
* Delete a todo
*/
async deleteTodo(id: string) {
const result = await api.deleteTask(id);
if (!result.error) {
todos = todos.filter((t) => t.id !== id);
}
return result;
},
/**
* Toggle todo completion
*/
async toggleComplete(id: string) {
const todo = todos.find((t) => t.id === id);
if (!todo) return { data: null, error: new Error('Todo not found') };
const result = todo.isCompleted ? await api.uncompleteTask(id) : await api.completeTask(id);
if (result.data) {
todos = todos.map((t) => (t.id === id ? result.data! : t));
}
return result;
},
/**
* Get todo by ID
*/
getById(id: string): Task | undefined {
const currentTodos = todos ?? [];
if (!Array.isArray(currentTodos)) return undefined;
return currentTodos.find((t) => t.id === id);
},
/**
* Get project by ID
*/
getProjectById(id: string): Project | undefined {
const currentProjects = projects ?? [];
if (!Array.isArray(currentProjects)) return undefined;
return currentProjects.find((p) => p.id === id);
},
/**
* Clear todos cache
*/
clear() {
todos = [];
loadedRange = null;
},
/**
* Check if Todo service is available
*/
async checkServiceHealth(): Promise<boolean> {
const result = await api.getTasks({ limit: 1 });
serviceAvailable = !result.error;
return serviceAvailable;
},
};

View file

@ -25,6 +25,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -178,8 +179,8 @@
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Calendar
const navItems: PillNavItem[] = [
// Base navigation items for Calendar
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Kalender', icon: 'calendar' },
{ href: '/agenda', label: 'Agenda', icon: 'list' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
@ -189,8 +190,13 @@
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation shortcuts (Ctrl+1-4)
const navRoutes = navItems.map((item) => item.href);
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('calendar', baseNavItems, userSettings.nav.hiddenNavItems)
);
// Navigation shortcuts (Ctrl+1-4) - use base items for consistent shortcuts
const navRoutes = baseNavItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;

View file

@ -12,6 +12,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -78,8 +79,8 @@
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// Navigation items for Chat (settings moved to user dropdown)
const navItems: PillNavItem[] = [
// Base navigation items for Chat (settings moved to user dropdown)
const baseNavItems: PillNavItem[] = [
{ href: '/chat', label: 'Chat', icon: 'home' },
{ href: '/templates', label: 'Templates', icon: 'document' },
{ href: '/spaces', label: 'Spaces', icon: 'building' },
@ -88,14 +89,19 @@
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('chat', baseNavItems, userSettings.nav.hiddenNavItems)
);
// User email for user dropdown
let userEmail = $derived(authStore.user?.email);
// Check if current page is a chat page (needs full-width layout)
let isChatPage = $derived($page.url.pathname.startsWith('/chat'));
// Navigation shortcuts (Ctrl+1-5)
const navRoutes = navItems.map((item) => item.href);
// Navigation shortcuts (Ctrl+1-5) - use base items for consistent shortcuts
const navRoutes = baseNavItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;

View file

@ -19,6 +19,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -161,8 +162,8 @@
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Clock
const navItems: PillNavItem[] = [
// Base navigation items for Clock
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Übersicht', icon: 'home' },
{ href: '/alarms', label: 'Wecker', icon: 'bell' },
{ href: '/timers', label: 'Timer', icon: 'timer' },
@ -174,8 +175,13 @@
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation shortcuts (Ctrl+1-9)
const navRoutes = navItems.map((item) => item.href);
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('clock', baseNavItems, userSettings.nav.hiddenNavItems)
);
// Navigation shortcuts (Ctrl+1-9) - use base items for consistent shortcuts
const navRoutes = baseNavItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;

View file

@ -20,6 +20,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -106,8 +107,8 @@
// User email for user dropdown (fallback to 'Menü' when not logged in)
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Contacts
const navItems: PillNavItem[] = [
// Base navigation items for Contacts
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Kontakte', icon: 'users' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
@ -118,8 +119,13 @@
{ href: '/help', label: 'Hilfe', icon: 'help-circle' },
];
// Navigation shortcuts (Ctrl+1-5)
const navRoutes = navItems.map((item) => item.href);
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('contacts', baseNavItems, userSettings.nav.hiddenNavItems)
);
// Navigation shortcuts (Ctrl+1-5) - use base items for consistent shortcuts
const navRoutes = baseNavItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;

View file

@ -18,6 +18,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
@ -33,13 +34,18 @@
// Get theme state
let isDark = $derived(theme.isDark);
// Navigation items for ManaDeck (Mana and Profile are in user dropdown)
const navItems: PillNavItem[] = [
// Base navigation items for ManaDeck (Mana and Profile are in user dropdown)
const baseNavItems: PillNavItem[] = [
{ href: '/decks', label: 'Decks', icon: 'archive' },
{ href: '/explore', label: 'Explore', icon: 'search' },
{ href: '/progress', label: 'Progress', icon: 'chart' },
];
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('manadeck', baseNavItems, userSettings.nav.hiddenNavItems)
);
// Get pinned themes from user settings (extended themes only)
let pinnedThemes = $derived<ThemeVariant[]>(
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>

View file

@ -12,6 +12,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
@ -93,8 +94,8 @@
}
});
// Navigation items (Mana is in user dropdown via manaHref)
const navItems: PillNavItem[] = [
// Base navigation items (Mana is in user dropdown via manaHref)
const baseNavItems: PillNavItem[] = [
{ href: '/app/gallery', label: 'Galerie', icon: 'home' },
{ href: '/app/board', label: 'Moodboards', icon: 'grid' },
{ href: '/app/explore', label: 'Entdecken', icon: 'search' },
@ -104,6 +105,11 @@
{ href: '/app/archive', label: 'Archiv', icon: 'archive' },
];
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('picture', baseNavItems, userSettings.nav.hiddenNavItems)
);
// View mode options for tab group
const viewModeOptions = [
{ id: 'single', icon: 'list', title: 'Liste (1)' },

View file

@ -39,7 +39,7 @@
<button
type="button"
onclick={() => (isOpen = !isOpen)}
class="section-header glass-pill w-full flex items-center gap-3 px-4 py-3 rounded-full cursor-pointer transition-all duration-200"
class="section-header w-full flex items-center gap-2 py-2 cursor-pointer transition-all duration-200"
>
<!-- Icon -->
<span class="icon-wrapper {iconColors[variant]}">
@ -77,36 +77,8 @@
</div>
<style>
/* Glass pill effect matching PillNavigation */
.glass-pill {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
position: relative;
z-index: 1;
}
:global(.dark) .glass-pill {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.glass-pill:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
:global(.dark) .glass-pill:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
.section-header:hover {
opacity: 0.7;
}
.icon-wrapper {

View file

@ -16,7 +16,7 @@
StorypointsSelector,
DurationPicker,
FunRatingPicker,
LabelSelector,
TagSelector,
} from './form';
interface Props {
@ -227,10 +227,10 @@
</select>
</div>
<!-- Labels -->
<!-- Tags -->
<div class="form-section">
<label class="form-label">Labels</label>
<LabelSelector
<label class="form-label">Tags</label>
<TagSelector
selectedIds={selectedLabelIds}
onChange={(ids) => (selectedLabelIds = ids)}
/>

View file

@ -7,12 +7,44 @@
interface Props {
task: Task;
showCompleted?: boolean;
animateComplete?: boolean;
onToggleComplete: () => void;
onDelete: () => void;
onEdit?: () => void;
}
let { task, showCompleted = false, onToggleComplete, onDelete, onEdit }: Props = $props();
let {
task,
showCompleted = false,
animateComplete = false,
onToggleComplete,
onDelete,
onEdit,
}: Props = $props();
// Animation state for completing
let isAnimatingComplete = $state(false);
// External animation trigger
$effect(() => {
if (animateComplete && !task.isCompleted) {
isAnimatingComplete = true;
}
});
function handleToggleClick() {
if (!task.isCompleted) {
// Animate before completing
isAnimatingComplete = true;
setTimeout(() => {
isAnimatingComplete = false;
onToggleComplete();
}, 500);
} else {
// Uncomplete immediately
onToggleComplete();
}
}
function handleContentClick() {
if (onEdit) {
@ -58,7 +90,11 @@
});
</script>
<div class="task-item group" class:completed={task.isCompleted}>
<div
class="task-item group"
class:completed={task.isCompleted}
class:completing={isAnimatingComplete}
>
<!-- Drag handle -->
<div class="drag-handle">
<svg class="drag-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -73,9 +109,20 @@
></div>
<!-- Checkbox -->
<button class="task-checkbox" class:checked={task.isCompleted} onclick={onToggleComplete}>
{#if task.isCompleted}
<svg class="check-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<button
class="task-checkbox"
class:checked={task.isCompleted}
class:animating={isAnimatingComplete}
onclick={handleToggleClick}
>
{#if task.isCompleted || isAnimatingComplete}
<svg
class="check-icon"
class:animate-check={isAnimatingComplete}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
{/if}
@ -183,6 +230,17 @@
opacity: 0.6;
}
/* Completing animation */
.task-item.completing {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.3);
}
:global(.dark) .task-item.completing {
background: rgba(34, 197, 94, 0.2);
border-color: rgba(34, 197, 94, 0.4);
}
/* Drag handle */
.drag-handle {
cursor: grab;
@ -258,12 +316,48 @@
border-color: #8b5cf6;
}
.task-checkbox.animating {
background: #22c55e;
border-color: #22c55e;
transform: scale(1.2);
}
.check-icon {
width: 0.75rem;
height: 0.75rem;
color: white;
}
.check-icon.animate-check {
animation: drawCheck 0.3s ease-out forwards;
}
.check-icon.animate-check path {
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: drawPath 0.3s ease-out forwards;
}
@keyframes drawPath {
to {
stroke-dashoffset: 0;
}
}
@keyframes drawCheck {
0% {
transform: scale(0.5);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* Content */
.task-content {
flex: 1;

View file

@ -25,6 +25,9 @@
// Local mutable state for dnd-zone
let items = $state<Task[]>([]);
// Track which task is being animated for completion
let animatingTaskId = $state<string | null>(null);
// Create a stable key from task IDs to detect real changes
let lastTaskIds = '';
@ -54,9 +57,18 @@
const wasInThisList = tasks.some((t) => t.id === movedTaskId);
if (!wasInThisList && dropTargetDate && onTaskDrop) {
// If dropping into completed section, animate first
if (dropTargetDate === 'completed') {
animatingTaskId = movedTaskId;
setTimeout(() => {
animatingTaskId = null;
onTaskDrop(movedTaskId, dropTargetDate);
}, 500);
} else {
// Task moved FROM another section TO this section
onTaskDrop(movedTaskId, dropTargetDate);
}
}
// Update local state and sync lastTaskIds to prevent $effect from reverting
items = newItems;
@ -97,6 +109,7 @@
<TaskItem
{task}
{showCompleted}
animateComplete={animatingTaskId === task.id}
onToggleComplete={() => handleToggleComplete(task)}
onDelete={() => handleDelete(task.id)}
onEdit={onEditTask ? () => onEditTask(task) : undefined}
@ -114,6 +127,7 @@
<TaskItem
{task}
{showCompleted}
animateComplete={animatingTaskId === task.id}
onToggleComplete={() => handleToggleComplete(task)}
onDelete={() => handleDelete(task.id)}
onEdit={onEditTask ? () => onEditTask(task) : undefined}
@ -143,9 +157,10 @@
.empty-placeholder {
color: var(--color-muted-foreground, #9ca3af);
font-size: 0.75rem;
font-size: 0.875rem;
padding: 1rem;
text-align: center;
opacity: 0.5;
}
:global(.task-drop-target) {

View file

@ -10,11 +10,11 @@
let showDropdown = $state(false);
function toggleLabel(labelId: string) {
if (selectedIds.includes(labelId)) {
onChange(selectedIds.filter((id) => id !== labelId));
function toggleTag(tagId: string) {
if (selectedIds.includes(tagId)) {
onChange(selectedIds.filter((id) => id !== tagId));
} else {
onChange([...selectedIds, labelId]);
onChange([...selectedIds, tagId]);
}
}
@ -30,22 +30,22 @@
<svelte:window onclick={handleWindowClick} />
<div class="label-selector">
<button type="button" class="label-trigger" onclick={handleTriggerClick}>
<div class="tag-selector">
<button type="button" class="tag-trigger" onclick={handleTriggerClick}>
{#if selectedIds.length === 0}
<span class="text-muted">Labels auswählen...</span>
<span class="text-muted">Tags auswählen...</span>
{:else}
<div class="selected-labels">
{#each selectedIds.slice(0, 3) as labelId}
{@const label = labelsStore.getById(labelId)}
{#if label}
<span class="label-tag" style="--label-color: {label.color}">
{label.name}
<div class="selected-tags">
{#each selectedIds.slice(0, 3) as tagId}
{@const tag = labelsStore.getById(tagId)}
{#if tag}
<span class="tag-chip" style="--tag-color: {tag.color}">
{tag.name}
</span>
{/if}
{/each}
{#if selectedIds.length > 3}
<span class="label-more">+{selectedIds.length - 3}</span>
<span class="tag-more">+{selectedIds.length - 3}</span>
{/if}
</div>
{/if}
@ -55,19 +55,19 @@
</button>
{#if showDropdown}
<div class="label-dropdown" onclick={(e) => e.stopPropagation()} role="listbox">
{#each labelsStore.labels as label}
<div class="tag-dropdown" onclick={(e) => e.stopPropagation()} role="listbox">
{#each labelsStore.labels as tag}
<button
type="button"
class="label-option"
class:selected={selectedIds.includes(label.id)}
onclick={() => toggleLabel(label.id)}
class="tag-option"
class:selected={selectedIds.includes(tag.id)}
onclick={() => toggleTag(tag.id)}
role="option"
aria-selected={selectedIds.includes(label.id)}
aria-selected={selectedIds.includes(tag.id)}
>
<span class="label-dot" style="background-color: {label.color}"></span>
<span class="label-name">{label.name}</span>
{#if selectedIds.includes(label.id)}
<span class="tag-dot" style="background-color: {tag.color}"></span>
<span class="tag-name">{tag.name}</span>
{#if selectedIds.includes(tag.id)}
<svg class="check-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
@ -80,18 +80,18 @@
</button>
{/each}
{#if labelsStore.labels.length === 0}
<div class="no-labels">Keine Labels vorhanden</div>
<div class="no-tags">Keine Tags vorhanden</div>
{/if}
</div>
{/if}
</div>
<style>
.label-selector {
.tag-selector {
position: relative;
}
.label-trigger {
.tag-trigger {
width: 100%;
display: flex;
align-items: center;
@ -104,12 +104,12 @@
transition: all 0.15s;
}
:global(.dark) .label-trigger {
:global(.dark) .tag-trigger {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
}
.label-trigger:hover {
.tag-trigger:hover {
border-color: rgba(0, 0, 0, 0.25);
}
@ -118,22 +118,22 @@
font-size: 0.875rem;
}
.selected-labels {
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.label-tag {
.tag-chip {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: color-mix(in srgb, var(--label-color) 15%, transparent);
color: var(--label-color);
background: color-mix(in srgb, var(--tag-color) 15%, transparent);
color: var(--tag-color);
font-weight: 500;
}
.label-more {
.tag-more {
font-size: 0.75rem;
color: #6b7280;
}
@ -144,7 +144,7 @@
color: #9ca3af;
}
.label-dropdown {
.tag-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
@ -160,12 +160,12 @@
z-index: 10;
}
:global(.dark) .label-dropdown {
:global(.dark) .tag-dropdown {
background: rgba(40, 40, 40, 0.95);
border-color: rgba(255, 255, 255, 0.15);
}
.label-option {
.tag-option {
display: flex;
align-items: center;
gap: 0.5rem;
@ -179,32 +179,32 @@
text-align: left;
}
.label-option:hover {
.tag-option:hover {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .label-option:hover {
:global(.dark) .tag-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.label-option.selected {
.tag-option.selected {
background: rgba(139, 92, 246, 0.1);
}
.label-dot {
.tag-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 9999px;
flex-shrink: 0;
}
.label-name {
.tag-name {
flex: 1;
font-size: 0.875rem;
color: #374151;
}
:global(.dark) .label-name {
:global(.dark) .tag-name {
color: #e5e7eb;
}
@ -214,7 +214,7 @@
color: #8b5cf6;
}
.no-labels {
.no-tags {
padding: 0.75rem;
text-align: center;
font-size: 0.875rem;

View file

@ -2,4 +2,4 @@ export { default as PrioritySelector } from './PrioritySelector.svelte';
export { default as StorypointsSelector } from './StorypointsSelector.svelte';
export { default as DurationPicker } from './DurationPicker.svelte';
export { default as FunRatingPicker } from './FunRatingPicker.svelte';
export { default as LabelSelector } from './LabelSelector.svelte';
export { default as TagSelector } from './TagSelector.svelte';

View file

@ -184,10 +184,9 @@
<div class="h-6 w-px bg-border hidden sm:block"></div>
<!-- Labels filter -->
<!-- Tags filter -->
<div class="filter-group flex items-center gap-2 relative">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Labels</span
>
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Tags</span>
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-background border border-border rounded-lg hover:border-primary/50 hover:bg-muted/50 transition-all"
onclick={() => (showLabelsDropdown = !showLabelsDropdown)}
@ -234,7 +233,7 @@
class="absolute top-full left-0 mt-2 z-50 min-w-[220px] bg-popover border border-border rounded-xl shadow-lg p-2 animate-in fade-in slide-in-from-top-2 duration-150"
>
{#if labelsStore.labels.length === 0}
<p class="text-sm text-muted-foreground p-3 text-center">Keine Labels vorhanden</p>
<p class="text-sm text-muted-foreground p-3 text-center">Keine Tags vorhanden</p>
{:else}
<div class="max-h-[200px] overflow-y-auto">
{#each labelsStore.labels as label}

View file

@ -259,9 +259,11 @@ export const tasksStore = {
// Handle completion state change first
if (data.isCompleted !== undefined && data.isCompleted !== originalTask.isCompleted) {
if (data.isCompleted) {
await tasksApi.completeTask(id);
const updatedTask = await tasksApi.completeTask(id);
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
} else {
await tasksApi.uncompleteTask(id);
const updatedTask = await tasksApi.uncompleteTask(id);
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
}
}

View file

@ -27,6 +27,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { getTasks } from '$lib/api/tasks';
@ -156,31 +157,33 @@
{ href: '/', label: 'Aufgaben', icon: 'list' },
{ href: '/kanban', label: 'Kanban', icon: 'columns' },
{ href: '/statistics', label: 'Statistiken', icon: 'chart' },
{ href: '/labels', label: 'Labels', icon: 'tag' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation items (base items + dynamic label items in sidebar mode)
// Navigation items (base items + dynamic label items in sidebar mode, filtered by visibility settings)
const navItems = $derived.by(() => {
// In sidebar mode, add labels as sub-items if available
// Start with base items, filter out hidden ones
let items = filterHiddenNavItems('todo', baseNavItems, userSettings.nav.hiddenNavItems);
// In sidebar mode, add tags as sub-items if available
if (isSidebarMode && labelsStore.labels.length > 0) {
const labelItems: PillNavItem[] = labelsStore.labels.slice(0, 5).map((label) => ({
href: `/label/${label.id}`,
const tagItems: PillNavItem[] = labelsStore.labels.slice(0, 5).map((label) => ({
href: `/tag/${label.id}`,
label: label.name,
icon: 'tag',
}));
// Insert label items after "Labels" nav item
const items = [...baseNavItems];
const labelsIndex = items.findIndex((i) => i.href === '/labels');
if (labelsIndex !== -1 && labelItems.length > 0) {
items.splice(labelsIndex + 1, 0, ...labelItems);
// Insert tag items after "Tags" nav item
const tagsIndex = items.findIndex((i) => i.href === '/tags');
if (tagsIndex !== -1 && tagItems.length > 0) {
items = [...items];
items.splice(tagsIndex + 1, 0, ...tagItems);
}
}
return items;
}
return baseNavItems;
});
// Navigation shortcuts (Ctrl+1-6) - use base items for consistent shortcuts

View file

@ -15,16 +15,16 @@
let editingTask = $state<Task | null>(null);
let showEditModal = $state(false);
// Get label ID from URL
const labelId = $derived($page.params.id ?? '');
// Get tag ID from URL
const tagId = $derived($page.params.id ?? '');
// Get label from store
const label = $derived(labelsStore.getById(labelId));
// Get tag from store
const tag = $derived(labelsStore.getById(tagId));
// Get tasks with this label
const labelTasks = $derived(labelId ? tasksStore.getTasksByLabel(labelId) : []);
const incompleteTasks = $derived(labelTasks.filter((t) => !t.isCompleted));
const completedTasks = $derived(labelTasks.filter((t) => t.isCompleted));
// Get tasks with this tag
const tagTasks = $derived(tagId ? tasksStore.getTasksByLabel(tagId) : []);
const incompleteTasks = $derived(tagTasks.filter((t) => !t.isCompleted));
const completedTasks = $derived(tagTasks.filter((t) => t.isCompleted));
onMount(async () => {
if (!authStore.isAuthenticated) {
@ -33,12 +33,12 @@
}
try {
// Ensure labels are loaded
// Ensure tags are loaded
if (labelsStore.labels.length === 0) {
await labelsStore.fetchLabels();
}
// Fetch all tasks to filter by label
// Fetch all tasks to filter by tag
await tasksStore.fetchAllTasks();
} catch (error) {
console.error('Failed to load data:', error);
@ -88,7 +88,7 @@
</script>
<svelte:head>
<title>{label?.name || 'Label'} - Todo</title>
<title>{tag?.name || 'Tag'} - Todo</title>
</svelte:head>
<div class="page-container">
@ -98,39 +98,39 @@
<CaretLeft size={20} weight="bold" />
</a>
<div class="header-content">
{#if label}
<div class="label-icon" style="background-color: {label.color}20">
<div class="label-dot" style="background-color: {label.color}"></div>
{#if tag}
<div class="tag-icon" style="background-color: {tag.color}20">
<div class="tag-dot" style="background-color: {tag.color}"></div>
</div>
<h1 class="title">{label.name}</h1>
<h1 class="title">{tag.name}</h1>
{:else}
<h1 class="title">Label</h1>
<h1 class="title">Tag</h1>
{/if}
</div>
<a href="/labels" class="manage-button" aria-label="Labels verwalten">
<a href="/tags" class="manage-button" aria-label="Tags verwalten">
<Tag size={20} weight="bold" />
</a>
</header>
{#if isLoading}
<TaskListSkeleton />
{:else if !label}
{:else if !tag}
<div class="empty-state">
<div class="empty-icon">
<Tag size={40} weight="light" />
</div>
<h2 class="empty-title">Label nicht gefunden</h2>
<p class="empty-description">Dieses Label existiert nicht mehr.</p>
<a href="/labels" class="btn btn-primary">Zu den Labels</a>
<h2 class="empty-title">Tag nicht gefunden</h2>
<p class="empty-description">Dieser Tag existiert nicht mehr.</p>
<a href="/tags" class="btn btn-primary">Zu den Tags</a>
</div>
{:else if labelTasks.length === 0}
{:else if tagTasks.length === 0}
<div class="empty-state">
<div class="empty-icon" style="background-color: {label.color}20">
<Tag size={40} weight="light" style="color: {label.color}" />
<div class="empty-icon" style="background-color: {tag.color}20">
<Tag size={40} weight="light" style="color: {tag.color}" />
</div>
<h2 class="empty-title">Keine Aufgaben</h2>
<p class="empty-description">
Es gibt keine Aufgaben mit dem Label "{label.name}".
Es gibt keine Aufgaben mit dem Tag "{tag.name}".
</p>
<a href="/" class="btn btn-primary">Aufgabe erstellen</a>
</div>
@ -156,8 +156,8 @@
{/if}
<p class="task-count">
{labelTasks.length}
{labelTasks.length === 1 ? 'Aufgabe' : 'Aufgaben'}
{tagTasks.length}
{tagTasks.length === 1 ? 'Aufgabe' : 'Aufgaben'}
</p>
{/if}
</div>
@ -196,7 +196,7 @@
gap: 0.75rem;
}
.label-icon {
.tag-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.625rem;
@ -205,7 +205,7 @@
justify-content: center;
}
.label-dot {
.tag-dot {
width: 1rem;
height: 1rem;
border-radius: 50%;

View file

@ -87,8 +87,8 @@
}
}
function handleLabelClick(tag: Tag) {
goto(`/label/${tag.id}`);
function handleTagClick(tag: Tag) {
goto(`/tag/${tag.id}`);
}
onMount(() => {
@ -99,7 +99,7 @@
</script>
<svelte:head>
<title>Labels - Todo</title>
<title>Tags - Todo</title>
</svelte:head>
<div class="page-container">
@ -108,8 +108,8 @@
<a href="/" class="back-button" aria-label="Zurück">
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Labels</h1>
<button onclick={openCreateModal} class="add-button" aria-label="Neues Label">
<h1 class="title">Tags</h1>
<button onclick={openCreateModal} class="add-button" aria-label="Neuer Tag">
<Plus size={20} weight="bold" />
</button>
</header>
@ -119,7 +119,7 @@
<MagnifyingGlass size={20} class="search-icon" />
<input
type="text"
placeholder="Labels durchsuchen..."
placeholder="Tags durchsuchen..."
bind:value={searchQuery}
class="search-input"
/>
@ -131,23 +131,23 @@
</div>
{/if}
<!-- Label List using shared component -->
<!-- Tag List using shared component -->
<TagList
tags={filteredLabels.map(labelToTag)}
loading={labelsStore.loading}
onEdit={openEditModal}
onDelete={handleDeleteFromList}
onClick={handleLabelClick}
emptyMessage={searchQuery ? 'Keine Labels gefunden' : 'Keine Labels vorhanden'}
onClick={handleTagClick}
emptyMessage={searchQuery ? 'Keine Tags gefunden' : 'Keine Tags vorhanden'}
emptyDescription={searchQuery
? `Kein Label für "${searchQuery}" gefunden`
: 'Erstelle dein erstes Label'}
? `Kein Tag für "${searchQuery}" gefunden`
: 'Erstelle deinen ersten Tag'}
/>
{#if !labelsStore.loading && labelsStore.labels.length > 0}
<p class="labels-count">
<p class="tags-count">
{labelsStore.labels.length}
{labelsStore.labels.length === 1 ? 'Label' : 'Labels'}
{labelsStore.labels.length === 1 ? 'Tag' : 'Tags'}
</p>
{/if}
@ -155,7 +155,7 @@
<div class="empty-cta">
<button onclick={openCreateModal} class="btn btn-primary">
<Plus size={16} weight="bold" />
Neues Label
Neuer Tag
</button>
</div>
{/if}
@ -168,14 +168,14 @@
onClose={closeModal}
onSave={handleSave}
onDelete={editingLabel ? handleDelete : undefined}
title={editingLabel ? 'Label bearbeiten' : 'Neues Label'}
title={editingLabel ? 'Tag bearbeiten' : 'Neuer Tag'}
saveLabel={editingLabel ? 'Speichern' : 'Erstellen'}
deleteLabel="Löschen"
cancelLabel="Abbrechen"
namePlaceholder="Label Name"
namePlaceholder="Tag Name"
colorLabel="Farbe"
previewLabel="Vorschau"
deleteConfirmMessage={`Label "${editingLabel?.name || ''}" wirklich löschen?`}
deleteConfirmMessage={`Tag "${editingLabel?.name || ''}" wirklich löschen?`}
/>
<!-- Delete confirmation modal -->
@ -187,8 +187,8 @@
}}
onConfirm={confirmDeleteLabel}
variant="danger"
title="Label löschen?"
message={`Das Label "${labelToDelete?.name ?? ''}" wird unwiderruflich gelöscht.`}
title="Tag löschen?"
message={`Der Tag "${labelToDelete?.name ?? ''}" wird unwiderruflich gelöscht.`}
confirmLabel="Löschen"
cancelLabel="Abbrechen"
/>
@ -298,7 +298,7 @@
}
/* Count */
.labels-count {
.tags-count {
text-align: center;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));

View file

@ -14,6 +14,7 @@
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import { filterHiddenNavItems } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -77,8 +78,8 @@
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Zitare
const navItems: PillNavItem[] = [
// Base navigation items for Zitare
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Zitate', icon: 'document' },
{ href: '/search', label: 'Suche', icon: 'search' },
{ href: '/authors', label: 'Autoren', icon: 'users' },
@ -87,8 +88,13 @@
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation shortcuts (Ctrl+1-6)
const navRoutes = navItems.map((item) => item.href);
// Navigation items filtered by visibility settings
const navItems = $derived(
filterHiddenNavItems('zitare', baseNavItems, userSettings.nav.hiddenNavItems)
);
// Navigation shortcuts (Ctrl+1-6) - use base items for consistent shortcuts
const navRoutes = baseNavItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;

View file

@ -6,13 +6,13 @@
*/
/**
* Route definition with i18n label
* Route definition with label
*/
export interface AppRoute {
/** Route path (e.g., '/stopwatch') */
path: string;
/** i18n key for the label (e.g., 'nav.stopwatch') */
labelKey: string;
/** Display label for the route (e.g., 'Stoppuhr') */
label: string;
/** Optional icon name */
icon?: string;
/** If true, this route cannot be hidden (e.g., Settings, Home) */
@ -39,13 +39,14 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'clock',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.dashboard', icon: 'home' },
{ path: '/alarms', labelKey: 'nav.alarms', icon: 'alarm' },
{ path: '/timers', labelKey: 'nav.timers', icon: 'timer' },
{ path: '/stopwatch', labelKey: 'nav.stopwatch', icon: 'stopwatch' },
{ path: '/pomodoro', labelKey: 'nav.pomodoro', icon: 'target' },
{ path: '/world-clock', labelKey: 'nav.worldClock', icon: 'globe' },
{ path: '/life', labelKey: 'nav.lifeClock', icon: 'heart' },
{ path: '/', label: 'Dashboard', icon: 'home', alwaysVisible: true },
{ path: '/alarms', label: 'Wecker', icon: 'alarm' },
{ path: '/timers', label: 'Timer', icon: 'timer' },
{ path: '/stopwatch', label: 'Stoppuhr', icon: 'stopwatch' },
{ path: '/pomodoro', label: 'Pomodoro', icon: 'target' },
{ path: '/world-clock', label: 'Weltuhr', icon: 'globe' },
{ path: '/life', label: 'Lebenszeit', icon: 'heart' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -53,8 +54,11 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'calendar',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.month', icon: 'calendar' },
{ path: '/agenda', labelKey: 'nav.agenda', icon: 'list' },
{ path: '/', label: 'Kalender', icon: 'calendar', alwaysVisible: true },
{ path: '/agenda', label: 'Agenda', icon: 'list' },
{ path: '/tags', label: 'Tags', icon: 'tag' },
{ path: '/network', label: 'Netzwerk', icon: 'share' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -62,20 +66,14 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'contacts',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.contacts', icon: 'users' },
{ path: '/groups', labelKey: 'nav.groups', icon: 'folder' },
{ path: '/favorites', labelKey: 'nav.favorites', icon: 'star' },
],
},
mail: {
appId: 'mail',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.inbox', icon: 'inbox' },
{ path: '/sent', labelKey: 'nav.sent', icon: 'send' },
{ path: '/drafts', labelKey: 'nav.drafts', icon: 'file' },
{ path: '/starred', labelKey: 'nav.starred', icon: 'star' },
{ path: '/', label: 'Kontakte', icon: 'users', alwaysVisible: true },
{ path: '/favorites', label: 'Favoriten', icon: 'star' },
{ path: '/tags', label: 'Tags', icon: 'tag' },
{ path: '/archive', label: 'Archiv', icon: 'archive' },
{ path: '/duplicates', label: 'Duplikate', icon: 'copy' },
{ path: '/data', label: 'Import/Export', icon: 'download' },
{ path: '/network', label: 'Netzwerk', icon: 'share' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -83,21 +81,12 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'todo',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.all', icon: 'list' },
{ path: '/today', labelKey: 'nav.today', icon: 'calendar' },
{ path: '/upcoming', labelKey: 'nav.upcoming', icon: 'clock' },
{ path: '/completed', labelKey: 'nav.completed', icon: 'check' },
],
},
storage: {
appId: 'storage',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.home', icon: 'home' },
{ path: '/files', labelKey: 'nav.files', icon: 'folder' },
{ path: '/favorites', labelKey: 'nav.favorites', icon: 'star' },
{ path: '/shared', labelKey: 'nav.shared', icon: 'share' },
{ path: '/', label: 'Aufgaben', icon: 'list', alwaysVisible: true },
{ path: '/kanban', label: 'Kanban', icon: 'grid' },
{ path: '/labels', label: 'Labels', icon: 'tag' },
{ path: '/statistics', label: 'Statistiken', icon: 'chart' },
{ path: '/network', label: 'Netzwerk', icon: 'share' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -105,10 +94,13 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'chat',
defaultRoute: '/chat',
availableRoutes: [
{ path: '/chat', labelKey: 'nav.chat', icon: 'message' },
{ path: '/spaces', labelKey: 'nav.spaces', icon: 'folder' },
{ path: '/templates', labelKey: 'nav.templates', icon: 'file' },
{ path: '/documents', labelKey: 'nav.documents', icon: 'document' },
{ path: '/chat', label: 'Chat', icon: 'message', alwaysVisible: true },
{ path: '/templates', label: 'Vorlagen', icon: 'file' },
{ path: '/spaces', label: 'Spaces', icon: 'folder' },
{ path: '/documents', label: 'Dokumente', icon: 'document' },
{ path: '/archive', label: 'Archiv', icon: 'archive' },
{ path: '/feedback', label: 'Feedback', icon: 'chat' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -116,10 +108,14 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'picture',
defaultRoute: '/app/gallery',
availableRoutes: [
{ path: '/app/gallery', labelKey: 'nav.gallery', icon: 'image' },
{ path: '/app/generate', labelKey: 'nav.generate', icon: 'sparkle' },
{ path: '/app/board', labelKey: 'nav.board', icon: 'grid' },
{ path: '/app/explore', labelKey: 'nav.explore', icon: 'compass' },
{ path: '/app/gallery', label: 'Galerie', icon: 'image', alwaysVisible: true },
{ path: '/app/board', label: 'Moodboards', icon: 'grid' },
{ path: '/app/explore', label: 'Entdecken', icon: 'compass' },
{ path: '/app/generate', label: 'Generieren', icon: 'sparkle' },
{ path: '/app/upload', label: 'Upload', icon: 'upload' },
{ path: '/app/tags', label: 'Tags', icon: 'tag' },
{ path: '/app/archive', label: 'Archiv', icon: 'archive' },
{ path: '/app/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -127,9 +123,10 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'manadeck',
defaultRoute: '/decks',
availableRoutes: [
{ path: '/decks', labelKey: 'nav.decks', icon: 'layers' },
{ path: '/explore', labelKey: 'nav.explore', icon: 'compass' },
{ path: '/progress', labelKey: 'nav.progress', icon: 'trending' },
{ path: '/decks', label: 'Decks', icon: 'layers', alwaysVisible: true },
{ path: '/explore', label: 'Entdecken', icon: 'compass' },
{ path: '/progress', label: 'Fortschritt', icon: 'trending' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
@ -137,24 +134,23 @@ export const APP_ROUTES: Record<string, AppRouteConfig> = {
appId: 'zitare',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.home', icon: 'home' },
{ path: '/quotes', labelKey: 'nav.quotes', icon: 'quote' },
{ path: '/favorites', labelKey: 'nav.favorites', icon: 'star' },
{ path: '/authors', labelKey: 'nav.authors', icon: 'users' },
{ path: '/lists', labelKey: 'nav.lists', icon: 'list' },
{ path: '/', label: 'Zitate', icon: 'quote', alwaysVisible: true },
{ path: '/search', label: 'Suche', icon: 'search' },
{ path: '/authors', label: 'Autoren', icon: 'users' },
{ path: '/favorites', label: 'Favoriten', icon: 'star' },
{ path: '/lists', label: 'Listen', icon: 'list' },
{ path: '/feedback', label: 'Feedback', icon: 'chat' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
presi: {
appId: 'presi',
defaultRoute: '/',
availableRoutes: [{ path: '/', labelKey: 'nav.home', icon: 'home' }],
},
manacore: {
appId: 'manacore',
defaultRoute: '/',
availableRoutes: [{ path: '/', labelKey: 'nav.dashboard', icon: 'home' }],
availableRoutes: [
{ path: '/', label: 'Dashboard', icon: 'home', alwaysVisible: true },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', alwaysVisible: true },
],
},
};

View file

@ -214,7 +214,7 @@
<div
class="space-y-4 {showNavigation ? 'pt-4 border-t border-[hsl(var(--border))]' : ''}"
>
<NavVisibilitySettings {userSettings} {appId} {t} />
<NavVisibilitySettings {userSettings} {appId} />
</div>
{/if}
@ -347,7 +347,7 @@
>
{#each availableRoutes as route}
<option value={route.path}>
{t(route.labelKey)}
{route.label}
</option>
{/each}
</select>

View file

@ -1,86 +1,38 @@
<script lang="ts">
import type { UserSettingsStore, AppRoute } from '@manacore/shared-theme';
import { getHideableRoutes, APP_ROUTES } from '@manacore/shared-theme';
import type { UserSettingsStore } from '@manacore/shared-theme';
import { getHideableRoutes } from '@manacore/shared-theme';
interface Props {
/** User settings store instance */
userSettings: UserSettingsStore;
/** Current app ID */
appId: string;
/** Translation function (optional, falls back to German) */
t?: (key: string) => string;
}
let { userSettings, appId, t = (key: string) => key }: Props = $props();
let { userSettings, appId }: Props = $props();
// Get all apps that have configurable routes
const configurableApps = $derived(
Object.entries(APP_ROUTES)
.filter(([, config]) => {
const hideableRoutes = config.availableRoutes.filter((r) => !r.alwaysVisible);
return hideableRoutes.length > 0;
})
.map(([id, config]) => ({
id,
label: getAppLabel(id),
routes: config.availableRoutes.filter((r) => !r.alwaysVisible),
}))
);
// Get hideable routes for the current app only
const hideableRoutes = $derived(getHideableRoutes(appId));
// Sort so current app is first
const sortedApps = $derived(
[...configurableApps].sort((a, b) => {
if (a.id === appId) return -1;
if (b.id === appId) return 1;
return a.label.localeCompare(b.label);
})
);
// Check if there are any routes to configure
const hasRoutes = $derived(hideableRoutes.length > 0);
function getAppLabel(id: string): string {
const labels: Record<string, string> = {
clock: 'Uhr',
calendar: 'Kalender',
contacts: 'Kontakte',
mail: 'Mail',
todo: 'Aufgaben',
storage: 'Speicher',
chat: 'Chat',
picture: 'Bilder',
manadeck: 'ManaDeck',
zitare: 'Zitare',
presi: 'Präsentation',
manacore: 'ManaCore',
};
return labels[id] || id;
}
function isRouteHidden(targetAppId: string, path: string): boolean {
const hidden = userSettings.getHiddenNavItemsForApp(targetAppId);
function isRouteHidden(path: string): boolean {
const hidden = userSettings.getHiddenNavItemsForApp(appId);
return hidden.includes(path);
}
async function handleToggle(targetAppId: string, path: string): Promise<void> {
await userSettings.toggleNavItemVisibility(targetAppId, path);
}
// Expanded state per app
let expandedApps = $state<Record<string, boolean>>({});
// Initialize with current app expanded
$effect(() => {
if (appId && expandedApps[appId] === undefined) {
expandedApps[appId] = true;
}
});
function toggleApp(id: string): void {
expandedApps[id] = !expandedApps[id];
async function handleToggle(path: string): Promise<void> {
await userSettings.toggleNavItemVisibility(appId, path);
}
</script>
{#if hasRoutes}
<div class="space-y-4">
<div>
<h3 class="text-xs font-semibold text-[hsl(var(--muted-foreground))] uppercase tracking-wider">
<h3
class="text-xs font-semibold text-[hsl(var(--muted-foreground))] uppercase tracking-wider"
>
Navigation anpassen
</h3>
<p class="text-sm text-[hsl(var(--muted-foreground))] mt-1">
@ -88,61 +40,25 @@
</p>
</div>
<div class="space-y-2">
{#each sortedApps as app (app.id)}
<div class="border border-[hsl(var(--border))] rounded-lg overflow-hidden">
<!-- App Header (collapsible) -->
<button
type="button"
class="w-full flex items-center justify-between px-4 py-3 bg-[hsl(var(--muted))] hover:bg-[hsl(var(--muted))]/80 transition-colors"
onclick={() => toggleApp(app.id)}
>
<span class="font-medium text-[hsl(var(--foreground))] flex items-center gap-2">
{app.label}
{#if app.id === appId}
<span
class="text-xs px-1.5 py-0.5 rounded bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]"
>
Aktuell
</span>
{/if}
</span>
<svg
class="w-5 h-5 text-[hsl(var(--muted-foreground))] transition-transform {expandedApps[
app.id
]
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
></path>
</svg>
</button>
<!-- Routes List (collapsible) -->
{#if expandedApps[app.id]}
<div class="p-3 space-y-1 bg-[hsl(var(--background))]">
{#each app.routes as route (route.path)}
{@const hidden = isRouteHidden(app.id, route.path)}
<div class="space-y-1">
{#each hideableRoutes as route (route.path)}
{@const hidden = isRouteHidden(route.path)}
<label
class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-[hsl(var(--muted))]/50 cursor-pointer transition-colors"
class="flex items-center justify-between py-2.5 px-3 rounded-lg hover:bg-[hsl(var(--muted))]/50 cursor-pointer transition-colors border border-transparent hover:border-[hsl(var(--border))]"
>
<span
class="text-sm {hidden
? 'text-[hsl(var(--muted-foreground))] line-through'
: 'text-[hsl(var(--foreground))]'}"
>
{t(route.labelKey)}
{route.label}
</span>
<button
type="button"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors {!hidden
? 'bg-[hsl(var(--primary))]'
: 'bg-gray-200 dark:bg-gray-700'}"
onclick={() => handleToggle(app.id, route.path)}
onclick={() => handleToggle(route.path)}
aria-label={hidden ? 'Einblenden' : 'Ausblenden'}
>
<span
@ -154,8 +70,5 @@
</label>
{/each}
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>

View file

@ -422,6 +422,8 @@ const APP_CONFIGS = [
vars: {
PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.CALENDAR_BACKEND_PORT || '3014'}`,
PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
PUBLIC_TODO_BACKEND_URL: (env) =>
env.TODO_BACKEND_URL || `http://localhost:${env.TODO_BACKEND_PORT || '3018'}`,
},
},