mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
6b30a914f4
commit
9f13bba3d0
29 changed files with 1964 additions and 383 deletions
|
|
@ -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
|
||||
|
||||
# ============================================
|
||||
|
|
|
|||
370
apps/calendar/apps/web/src/lib/api/todos.ts
Normal file
370
apps/calendar/apps/web/src/lib/api/todos.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
287
apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte
Normal file
287
apps/calendar/apps/web/src/lib/components/todo/TodoItem.svelte
Normal 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>
|
||||
405
apps/calendar/apps/web/src/lib/stores/todos.svelte.ts
Normal file
405
apps/calendar/apps/web/src/lib/stores/todos.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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)' },
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,8 +57,17 @@
|
|||
const wasInThisList = tasks.some((t) => t.id === movedTaskId);
|
||||
|
||||
if (!wasInThisList && dropTargetDate && onTaskDrop) {
|
||||
// Task moved FROM another section TO this section
|
||||
onTaskDrop(movedTaskId, dropTargetDate);
|
||||
// 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
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
return items;
|
||||
});
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-6) - use base items for consistent shortcuts
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
@ -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));
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,161 +1,74 @@
|
|||
<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>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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">
|
||||
Versteckte Seiten bleiben über die URL erreichbar
|
||||
</p>
|
||||
</div>
|
||||
{#if hasRoutes}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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">
|
||||
Versteckte Seiten bleiben über die URL erreichbar
|
||||
</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)}
|
||||
<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.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="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"
|
||||
<span
|
||||
class="text-sm {hidden
|
||||
? 'text-[hsl(var(--muted-foreground))] line-through'
|
||||
: 'text-[hsl(var(--foreground))]'}"
|
||||
>
|
||||
<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)}
|
||||
<label
|
||||
class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-[hsl(var(--muted))]/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<span
|
||||
class="text-sm {hidden
|
||||
? 'text-[hsl(var(--muted-foreground))] line-through'
|
||||
: 'text-[hsl(var(--foreground))]'}"
|
||||
>
|
||||
{t(route.labelKey)}
|
||||
</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)}
|
||||
aria-label={hidden ? 'Einblenden' : 'Ausblenden'}
|
||||
>
|
||||
<span
|
||||
class="inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform shadow-sm {!hidden
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0.5'}"
|
||||
></span>
|
||||
</button>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{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(route.path)}
|
||||
aria-label={hidden ? 'Einblenden' : 'Ausblenden'}
|
||||
>
|
||||
<span
|
||||
class="inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform shadow-sm {!hidden
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0.5'}"
|
||||
></span>
|
||||
</button>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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'}`,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue