Merge branch 'till-dev-backup' into till-dev

This commit is contained in:
Wuesteon 2025-12-11 18:48:28 +01:00
commit 5921cfd257
62 changed files with 2387 additions and 589 deletions

View file

@ -6,7 +6,7 @@ pnpm docker:up:all
pnpm docker:down
pnpm dev:calendar:app
pnpm dev:calendar:full
pnpm dev:todo:full
pnpm dev:contacts:full
pnpm dev:clock:full

View file

@ -12,8 +12,9 @@ import {
import type { EventMetadata } from '../../db/schema/events.schema';
export class CreateEventDto {
@IsOptional()
@IsUUID()
calendarId: string;
calendarId?: string;
@IsString()
@MaxLength(500)

View file

@ -85,11 +85,20 @@ export class EventService {
}
async create(userId: string, dto: CreateEventDto): Promise<Event> {
// Verify user owns the calendar
const calendar = await this.calendarService.findByIdOrThrow(dto.calendarId, userId);
let calendarId = dto.calendarId;
let calendar;
// If no calendarId provided, get or create default calendar
if (!calendarId) {
calendar = await this.calendarService.getOrCreateDefaultCalendar(userId);
calendarId = calendar.id;
} else {
// Verify user owns the specified calendar
calendar = await this.calendarService.findByIdOrThrow(calendarId, userId);
}
const newEvent: NewEvent = {
calendarId: dto.calendarId,
calendarId,
userId,
title: dto.title,
description: dto.description,

View file

@ -0,0 +1,117 @@
/**
* Base API Client Factory
* Eliminates duplication between calendar and todo API clients
*/
import { browser } from '$app/environment';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export interface FetchOptions {
method?: HttpMethod;
body?: unknown;
token?: string;
isFormData?: boolean;
timeout?: number;
}
export interface ApiResult<T> {
data: T | null;
error: Error | null;
}
export interface ApiClientConfig {
baseUrl: string;
apiPrefix?: string;
getAuthToken?: () => string | null;
defaultTimeout?: number;
}
/**
* Creates a configured API client for a specific backend
*/
export function createApiClient(config: ApiClientConfig) {
const { baseUrl, apiPrefix = '/api/v1', defaultTimeout = 30000 } = config;
async function fetchApi<T>(endpoint: string, options: FetchOptions = {}): Promise<ApiResult<T>> {
const { method = 'GET', body, token, isFormData = false, timeout = defaultTimeout } = options;
// Get auth token
let authToken = token;
if (!authToken && browser) {
authToken = config.getAuthToken?.() ?? localStorage.getItem('@auth/appToken') ?? undefined;
}
// Setup abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const headers: Record<string, string> = {};
// Don't set Content-Type for FormData - browser sets it automatically with boundary
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${baseUrl}${apiPrefix}${endpoint}`, {
method,
headers,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `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) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
return {
data: null,
error: new Error('Request timed out'),
};
}
return {
data: null,
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
}
return { fetchApi };
}
/**
* Helper to build query strings from object
*/
export function buildQueryString(params: Record<string, unknown>): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}

View file

@ -6,63 +6,21 @@
*/
import { env } from '$env/dynamic/public';
import { authStore } from '$lib/stores/auth.svelte';
import { createApiClient, type FetchOptions, type ApiResult } from './base-client';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
type FetchOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
token?: string;
isFormData?: boolean;
};
const calendarClient = createApiClient({
baseUrl: API_BASE,
apiPrefix: '/api/v1',
});
export async function fetchApi<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<{ data: T | null; error: Error | null }> {
const { method = 'GET', body, token, isFormData = false } = options;
// Get a valid token (auto-refreshes if expired)
const authToken = token || (await authStore.getValidToken());
try {
const headers: Record<string, string> = {};
// Don't set Content-Type for FormData - browser sets it automatically with boundary
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
method,
headers,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `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('Unknown error'),
};
}
): Promise<ApiResult<T>> {
return calendarClient.fetchApi<T>(endpoint, options);
}
// Re-export types for backwards compatibility
export type { FetchOptions, ApiResult };

View file

@ -3,11 +3,16 @@
* Allows Calendar app to fetch/manage todos from the Todo service
*/
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import { createApiClient, buildQueryString } from './base-client';
const TODO_API_BASE = env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018';
const todoClient = createApiClient({
baseUrl: TODO_API_BASE,
apiPrefix: '/api/v1',
});
// ============================================
// Types (mirrored from @todo/shared for cross-app use)
// ============================================
@ -68,6 +73,11 @@ export interface Task {
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
// Time-Blocking (for calendar integration)
scheduledDate?: string | null;
scheduledStartTime?: string | null; // HH:mm format
scheduledEndTime?: string | null; // HH:mm format
estimatedDuration?: number | null; // Duration in minutes
priority: TaskPriority;
status: TaskStatus;
isCompleted: boolean;
@ -92,6 +102,11 @@ export interface CreateTaskInput {
projectId?: string | null;
dueDate?: string | null;
dueTime?: string | null;
// Time-Blocking
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledEndTime?: string | null;
estimatedDuration?: number | null;
priority?: TaskPriority;
labelIds?: string[];
subtasks?: Omit<Subtask, 'id'>[];
@ -105,6 +120,11 @@ export interface UpdateTaskInput {
projectId?: string | null;
dueDate?: string | null;
dueTime?: string | null;
// Time-Blocking
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledEndTime?: string | null;
estimatedDuration?: number | null;
priority?: TaskPriority;
status?: TaskStatus;
isCompleted?: boolean;
@ -150,78 +170,10 @@ interface LabelsResponse {
}
// ============================================
// API Client
// API Client (using shared base 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}` : '';
}
const fetchTodoApi = todoClient.fetchApi;
// ============================================
// Task API Functions
@ -230,7 +182,7 @@ function buildQueryString(query: TaskQuery): string {
export async function getTasks(
query: TaskQuery = {}
): Promise<{ data: Task[] | null; error: Error | null }> {
const queryString = buildQueryString(query);
const queryString = buildQueryString(query as Record<string, unknown>);
const result = await fetchTodoApi<TasksResponse>(`/tasks${queryString}`);
return {
data: result.data?.tasks || null,

View file

@ -1,16 +1,12 @@
<script lang="ts">
import { toast } from '$lib/stores/toast';
import type { Toast } from '$lib/stores/toast';
import { toastStore, type Toast } from '$lib/stores/toast.svelte';
import { fly } from 'svelte/transition';
let toasts = $state<Toast[]>([]);
toast.subscribe((value) => {
toasts = value;
});
// Reactive getter from the runes-based store
let toasts = $derived(toastStore.toasts);
function handleClose(id: string) {
toast.remove(id);
toastStore.remove(id);
}
function getIcon(type: Toast['type']) {

View file

@ -17,7 +17,8 @@
setMinutes,
} from 'date-fns';
import { de } from 'date-fns/locale';
import type { CalendarEvent } from '../../../../../../packages/shared/src/types/event';
import type { CalendarEvent } from '@calendar/shared';
interface Props {
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
@ -76,8 +77,11 @@
);
// Get display mode for an event (per-event override takes precedence over global setting)
function getEventDisplayMode(event: any): 'header' | 'block' {
return event.metadata?.allDayDisplayMode || settingsStore.allDayDisplayMode;
function getEventDisplayMode(event: CalendarEvent): 'header' | 'block' {
return (
(event.metadata as { allDayDisplayMode?: 'header' | 'block' } | null)?.allDayDisplayMode ||
settingsStore.allDayDisplayMode
);
}
// Split all-day events by display mode
@ -91,7 +95,7 @@
// Drag & Drop State
// ============================================================================
let isDragging = $state(false);
let draggedEvent = $state<any>(null);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragOffsetMinutes = $state(0);
let dragPreviewTop = $state(0);
let dragPreviewHeight = $state(0);
@ -101,7 +105,7 @@
// Resize State
// ============================================================================
let isResizing = $state(false);
let resizeEvent = $state<any>(null);
let resizeEvent = $state<CalendarEvent | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeOriginalStart = $state<Date | null>(null);
let resizeOriginalEnd = $state<Date | null>(null);
@ -148,7 +152,7 @@
// ============================================================================
// Drag Handlers
// ============================================================================
function startDrag(event: any, e: PointerEvent) {
function startDrag(event: CalendarEvent, e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
@ -234,7 +238,7 @@
// ============================================================================
// Resize Handlers
// ============================================================================
function startResize(event: any, edge: 'top' | 'bottom', e: PointerEvent) {
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
@ -586,7 +590,7 @@
// ============================================================================
// Event Styling
// ============================================================================
function getEventStyle(event: any) {
function getEventStyle(event: CalendarEvent) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
@ -641,7 +645,11 @@
}, 100);
return;
}
goto(`/?event=${event.id}`);
if (onEventClick) {
onEventClick(event);
} else {
goto(`/?event=${event.id}`);
}
}
function handleSlotClick(hour: number, e: MouseEvent) {
@ -734,6 +742,7 @@
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
{@const isBeingResized = isResizing && resizeEvent?.id === event.id}
{@const isDraft = eventsStore.isDraftEvent(event.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="event-card"
class:dragging={isBeingDragged}
@ -756,6 +765,7 @@
onpointerdown={(e) => startResize(event, 'top', e)}
role="slider"
aria-label="Startzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
@ -780,6 +790,7 @@
onpointerdown={(e) => startResize(event, 'bottom', e)}
role="slider"
aria-label="Endzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
</div>

View file

@ -28,12 +28,16 @@
setMinutes,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { _ } from 'svelte-i18n';
import type { CalendarEvent } from '@calendar/shared';
interface Props {
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
}
let { onQuickCreate }: Props = $props();
let { onQuickCreate, onEventClick }: Props = $props();
// Get all days to display in the month grid (including days from prev/next months)
let allCalendarDays = $derived.by(() => {
@ -76,7 +80,7 @@
// Drag & Drop State
// ============================================================================
let isDragging = $state(false);
let draggedEvent = $state<any>(null);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragTargetDay = $state<Date | null>(null);
let monthViewRef = $state<HTMLElement | null>(null);
@ -219,7 +223,7 @@
}
}
function handleEventClick(event: any, e: MouseEvent) {
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
// Don't navigate if dragging
if (isDragging) {
e.preventDefault();
@ -227,7 +231,11 @@
return;
}
e.stopPropagation();
goto(`/?event=${event.id}`);
if (onEventClick) {
onEventClick(event);
} else {
goto(`/?event=${event.id}`);
}
}
function handleMoreClick(day: Date, e: MouseEvent) {
@ -251,7 +259,6 @@
<div class="week-row">
{#each week as day}
{@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day-cell"
class:other-month={!isSameMonth(day, viewStore.currentDate)}
@ -262,6 +269,9 @@
onkeydown={(e) => e.key === 'Enter' && handleDayClick(day, e as unknown as MouseEvent)}
role="button"
tabindex="0"
aria-label={$_('a11y.createEventOn', {
values: { date: format(day, 'EEEE, d. MMMM', { locale: de }) },
})}
>
<span class="day-number" class:today={isToday(day)}>
{format(day, 'd')}
@ -276,6 +286,7 @@
{#each getEventsForDay(day) as event}
{@const isBeingDragged = isDragging && draggedEvent?.id === event.id}
{@const isDraft = eventsStore.isDraftEvent(event.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="event-pill"
class:dragging={isBeingDragged}
@ -300,14 +311,17 @@
)}</span
>
{/if}
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span
<span class="event-title"
>{event.title || (isDraft ? $_('calendar.draftEvent') : '')}</span
>
</div>
{/each}
{#if eventsStore.getEventsForDay(day).length > 3}
<button class="more-events" onclick={(e) => handleMoreClick(day, e)}>
+{eventsStore.getEventsForDay(day).length - 3} mehr
{$_('views.moreEvents', {
values: { count: eventsStore.getEventsForDay(day).length - 3 },
})}
</button>
{/if}
</div>

View file

@ -25,6 +25,8 @@
const HOUR_HEIGHT = 60; // px - should match CSS --hour-height
const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals
import type { CalendarEvent } from '@calendar/shared';
// Props
interface Props {
dayCount: 5 | 10 | 14;
@ -97,7 +99,7 @@
// ========== Drag & Drop State ==========
let isDragging = $state(false);
let draggedEvent = $state<any>(null);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragOffsetMinutes = $state(0);
let dragTargetDay = $state<Date | null>(null);
let dragPreviewTop = $state(0);
@ -105,7 +107,7 @@
// ========== Resize State ==========
let isResizing = $state(false);
let resizeEvent = $state<any>(null);
let resizeEvent = $state<CalendarEvent | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeOriginalStart = $state<Date | null>(null);
let resizeOriginalEnd = $state<Date | null>(null);
@ -141,8 +143,11 @@
}
// Get display mode for an event (per-event override takes precedence over global setting)
function getEventDisplayMode(event: any): 'header' | 'block' {
return event.metadata?.allDayDisplayMode || settingsStore.allDayDisplayMode;
function getEventDisplayMode(event: CalendarEvent): 'header' | 'block' {
return (
(event.metadata as { allDayDisplayMode?: 'header' | 'block' } | null)?.allDayDisplayMode ||
settingsStore.allDayDisplayMode
);
}
// Split all-day events by display mode
@ -159,7 +164,7 @@
days.some((day) => getHeaderAllDayEventsForDay(day).length > 0)
);
function getEventStyle(event: any) {
function getEventStyle(event: CalendarEvent) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
@ -208,7 +213,7 @@
return settingsStore.formatTime(d);
}
function handleEventClick(event: any, e: MouseEvent) {
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
// Don't navigate if we just finished dragging or resizing, or if we moved
if (isDragging || isResizing || hasMoved) {
e.preventDefault();
@ -218,7 +223,11 @@
}, 100);
return;
}
goto(`/?event=${event.id}`);
if (onEventClick) {
onEventClick(event);
} else {
goto(`/?event=${event.id}`);
}
}
function handleSlotClick(day: Date, hour: number, e: MouseEvent) {
@ -265,7 +274,7 @@
return Math.round(totalMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
}
function startDrag(event: any, e: PointerEvent) {
function startDrag(event: CalendarEvent, e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
@ -368,7 +377,7 @@
// ========== Resize Functions ==========
function startResize(event: any, edge: 'top' | 'bottom', e: PointerEvent) {
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
@ -872,6 +881,7 @@
onpointerdown={(e) => startResize(event, 'top', e)}
role="slider"
aria-label="Startzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
@ -888,6 +898,7 @@
onpointerdown={(e) => startResize(event, 'bottom', e)}
role="slider"
aria-label="Endzeit ändern"
aria-valuenow={0}
tabindex="-1"
></div>
</div>

View file

@ -63,24 +63,26 @@
<div class="todo-sidebar-section">
<!-- Header -->
<button type="button" class="section-header" onclick={toggleExpanded}>
<div class="header-left">
{#if isExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
<CheckSquare size={16} class="section-icon" />
<span class="section-title">Aufgaben</span>
{#if totalActiveCount > 0}
<span class="count-badge">{totalActiveCount}</span>
{/if}
{#if overdueCount > 0}
<span class="overdue-badge" title="{overdueCount} überfällig">
<AlertTriangle size={12} />
</span>
{/if}
</div>
<div class="section-header">
<button type="button" class="header-toggle" onclick={toggleExpanded}>
<div class="header-left">
{#if isExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
<CheckSquare size={16} class="section-icon" />
<span class="section-title">Aufgaben</span>
{#if totalActiveCount > 0}
<span class="count-badge">{totalActiveCount}</span>
{/if}
{#if overdueCount > 0}
<span class="overdue-badge" title="{overdueCount} überfällig">
<AlertTriangle size={12} />
</span>
{/if}
</div>
</button>
<button
type="button"
class="add-button"
@ -89,7 +91,7 @@
>
<Plus size={16} />
</button>
</button>
</div>
<!-- Content -->
{#if isExpanded}
@ -163,14 +165,21 @@
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
padding: 0 1rem 0 0;
}
.header-toggle {
flex: 1;
display: flex;
align-items: center;
padding: 0.75rem 0 0.75rem 1rem;
border: none;
background: transparent;
cursor: pointer;
transition: background 150ms ease;
}
.section-header:hover {
.header-toggle:hover {
background: hsl(var(--color-muted) / 0.3);
}

View file

@ -22,7 +22,9 @@
getWeek,
} from 'date-fns';
import { de, enUS, fr, es, it } from 'date-fns/locale';
import { locale } from 'svelte-i18n';
import { locale, _ } from 'svelte-i18n';
import type { CalendarEvent } from '@calendar/shared';
interface Props {
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
@ -97,7 +99,7 @@
// Drag & Drop State
let isDragging = $state(false);
let draggedEvent = $state<any>(null);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragOffsetMinutes = $state(0);
let dragTargetDay = $state<Date | null>(null);
let dragPreviewTop = $state(0);
@ -105,7 +107,7 @@
// Resize State
let isResizing = $state(false);
let resizeEvent = $state<any>(null);
let resizeEvent = $state<CalendarEvent | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeOriginalStart = $state<Date | null>(null);
let resizeOriginalEnd = $state<Date | null>(null);
@ -141,8 +143,11 @@
}
// Get display mode for an event (per-event override takes precedence over global setting)
function getEventDisplayMode(event: any): 'header' | 'block' {
return event.metadata?.allDayDisplayMode || settingsStore.allDayDisplayMode;
function getEventDisplayMode(event: CalendarEvent): 'header' | 'block' {
return (
(event.metadata as { allDayDisplayMode?: 'header' | 'block' } | null)?.allDayDisplayMode ||
settingsStore.allDayDisplayMode
);
}
// Split all-day events by display mode
@ -159,7 +164,7 @@
days.some((day) => getHeaderAllDayEventsForDay(day).length > 0)
);
function getEventStyle(event: any) {
function getEventStyle(event: CalendarEvent) {
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
@ -210,7 +215,7 @@
return settingsStore.formatTime(d);
}
function handleEventClick(event: any, e: MouseEvent) {
function handleEventClick(event: CalendarEvent, e: MouseEvent) {
// Don't navigate if we just finished dragging or resizing, or if we moved
if (isDragging || isResizing || hasMoved) {
e.preventDefault();
@ -221,7 +226,11 @@
}, 100);
return;
}
goto(`/?event=${event.id}`);
if (onEventClick) {
onEventClick(event);
} else {
goto(`/?event=${event.id}`);
}
}
function handleSlotClick(day: Date, hour: number, e: MouseEvent) {
@ -268,7 +277,7 @@
return Math.round(totalMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT;
}
function startDrag(event: any, e: PointerEvent) {
function startDrag(event: CalendarEvent, e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
@ -371,7 +380,7 @@
// ========== Resize Functions ==========
function startResize(event: any, edge: 'top' | 'bottom', e: PointerEvent) {
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
@ -792,7 +801,8 @@
<!-- Week number indicator (if enabled) -->
{#if settingsStore.showWeekNumbers}
<div class="week-number-indicator">
KW {weekNumber}
{$_('views.weekNumber')}
{weekNumber}
</div>
{/if}
@ -801,7 +811,7 @@
<div class="all-day-row">
<div class="time-gutter">
{#if settingsStore.showWeekNumbers}
<span class="week-label">KW {weekNumber}</span>
<span class="week-label">{$_('views.weekNumber')} {weekNumber}</span>
{/if}
</div>
{#each days as day}
@ -906,6 +916,7 @@
: getEventStyle(event)}
role="button"
tabindex="0"
aria-label={event.title || $_('calendar.draftEvent')}
onpointerdown={(e) => startDrag(event, e)}
onclick={(e) => !isDraft && handleEventClick(event, e)}
onkeydown={(e) => !isDraft && e.key === 'Enter' && goto(`/?event=${event.id}`)}
@ -915,21 +926,25 @@
class="resize-handle top"
onpointerdown={(e) => startResize(event, 'top', e)}
role="slider"
aria-label="Startzeit ändern"
aria-label={$_('event.changeStartTime')}
aria-valuenow={0}
tabindex="-1"
></div>
<span class="event-time">
{formatEventTime(event.startTime)} - {formatEventTime(event.endTime)}
</span>
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span>
<span class="event-title"
>{event.title || (isDraft ? $_('calendar.draftEvent') : '')}</span
>
<!-- Bottom resize handle -->
<div
class="resize-handle bottom"
onpointerdown={(e) => startResize(event, 'bottom', e)}
role="slider"
aria-label="Endzeit ändern"
aria-label={$_('event.changeEndTime')}
aria-valuenow={0}
tabindex="-1"
></div>
</div>

View file

@ -16,13 +16,14 @@
setMinutes,
} from 'date-fns';
import { de } from 'date-fns/locale';
import type { CalendarViewType } from '@calendar/shared';
import type { CalendarViewType, CalendarEvent } from '@calendar/shared';
interface Props {
onQuickCreate?: (date: Date, position: { x: number; y: number }) => void;
onEventClick?: (event: CalendarEvent) => void;
}
let { onQuickCreate }: Props = $props();
let { onQuickCreate, onEventClick }: Props = $props();
// Derived values
let year = $derived(viewStore.currentDate.getFullYear());

View file

@ -145,8 +145,8 @@
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_to_interactive_role -->
<div class="modal-backdrop" onclick={handleBackdropClick} role="button" tabindex="-1">
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="modal-title">
{#if loading}
<EventDetailSkeleton />

View file

@ -136,7 +136,7 @@
e.preventDefault();
if (!title.trim()) return;
if (!calendarId) return;
// calendarId is now optional - backend will use/create default calendar if not provided
const startDateTime = new Date(`${startDate}T${isAllDay ? '00:00' : startTime}`);
const endDateTime = new Date(`${endDate}T${isAllDay ? '23:59' : endTime}`);
@ -189,7 +189,8 @@
isAllDay,
startTime: startDateTime.toISOString(),
endTime: endDateTime.toISOString(),
calendarId,
// Only include calendarId if set - backend will use default if not provided
...(calendarId ? { calendarId } : {}),
metadata: finalMetadata,
tagIds: selectedTags.length > 0 ? selectedTags.map((t) => t.id) : undefined,
};
@ -214,15 +215,19 @@
<div class="flex flex-col gap-2">
<label for="calendar" class="text-sm font-medium text-foreground">Kalender</label>
<select
id="calendar"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={calendarId}
>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
{#if calendarsStore.calendars.length > 0}
<select
id="calendar"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={calendarId}
>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
{:else}
<p class="text-sm text-muted-foreground italic">Standardkalender wird automatisch erstellt</p>
{/if}
</div>
<div class="flex flex-col gap-2">
@ -390,7 +395,7 @@
<!-- Tags -->
{#if availableTags.length > 0 || eventTagsStore.loading}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground">Tags</label>
<span class="text-sm font-medium text-foreground">Tags</span>
<TagSelector
tags={availableTags}
{selectedTags}
@ -421,7 +426,7 @@
<button
type="submit"
class="px-4 py-2 rounded-lg font-medium text-primary-foreground bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
disabled={submitting || !title.trim() || !calendarId}
disabled={submitting || !title.trim()}
>
{mode === 'create' ? 'Erstellen' : 'Speichern'}
</button>

View file

@ -2,18 +2,25 @@
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import type { LocationDetails } from '@calendar/shared';
import { toast } from '$lib/stores/toast';
import type { LocationDetails, CalendarEvent } from '@calendar/shared';
import { format, addMinutes, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
import { tick, onMount, onDestroy } from 'svelte';
interface Props {
startTime: Date;
startTime?: Date;
event?: CalendarEvent;
onClose: () => void;
onCreated?: () => void;
onUpdated?: () => void;
onDeleted?: () => void;
}
let { startTime, onClose, onCreated }: Props = $props();
let { startTime, event, onClose, onCreated, onUpdated, onDeleted }: Props = $props();
// Mode: create or edit
let isEditMode = $derived(!!event);
// Input ref for programmatic focus
let titleInputRef = $state<HTMLInputElement | null>(null);
@ -25,12 +32,17 @@
// Track when draft event was last modified (to ignore clicks after drag/resize)
let lastDraftUpdateTime = $state(0);
// Calculate position relative to draft event element
// Calculate position relative to draft event element or existing event
function updatePosition() {
if (typeof window === 'undefined') return;
const draftElement = document.querySelector('[data-event-id="__draft__"]');
if (!draftElement) {
// In edit mode, position relative to the existing event element
const eventSelector = isEditMode
? `[data-event-id="${event!.id}"]`
: '[data-event-id="__draft__"]';
const eventElement = document.querySelector(eventSelector);
if (!eventElement) {
// Fallback: center in viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
@ -42,7 +54,7 @@
return;
}
const rect = draftElement.getBoundingClientRect();
const rect = eventElement.getBoundingClientRect();
const overlayWidth = 380;
const maxOverlayHeight = 450;
const margin = 16;
@ -79,7 +91,7 @@
positionInitialized = true;
}
// Handle clicks outside overlay (but allow clicks on draft event)
// Handle clicks outside overlay (but allow clicks on event)
function handleDocumentClick(e: MouseEvent) {
// Ignore clicks within 250ms of draft event update (drag/resize just ended)
if (Date.now() - lastDraftUpdateTime < 250) {
@ -88,10 +100,13 @@
const target = e.target as HTMLElement;
const overlay = document.querySelector('.quick-event-overlay');
const draftEvent = document.querySelector('[data-event-id="__draft__"]');
const eventSelector = isEditMode
? `[data-event-id="${event!.id}"]`
: '[data-event-id="__draft__"]';
const eventElement = document.querySelector(eventSelector);
// Don't close if clicking on overlay or draft event
if (overlay?.contains(target) || draftEvent?.contains(target)) {
// Don't close if clicking on overlay or event element
if (overlay?.contains(target) || eventElement?.contains(target)) {
return;
}
@ -115,18 +130,19 @@
document.removeEventListener('click', handleDocumentClick);
});
// Update position when draft event changes (user dragged it)
// Also track the update time to prevent closing overlay after drag/resize
// Update position when draft event changes (user dragged it) - only in create mode
$effect(() => {
const draft = eventsStore.draftEvent;
if (draft && positionInitialized) {
// Track when draft was updated (for click ignore logic)
lastDraftUpdateTime = Date.now();
if (!isEditMode) {
const draft = eventsStore.draftEvent;
if (draft && positionInitialized) {
// Track when draft was updated (for click ignore logic)
lastDraftUpdateTime = Date.now();
// Use requestAnimationFrame to wait for DOM update
requestAnimationFrame(() => {
updatePosition();
});
// Use requestAnimationFrame to wait for DOM update
requestAnimationFrame(() => {
updatePosition();
});
}
}
});
@ -135,11 +151,15 @@
if (titleInputRef) {
tick().then(() => {
titleInputRef?.focus();
// Select all text in edit mode for easy replacement
if (isEditMode) {
titleInputRef?.select();
}
});
}
});
// Form state - initialize from draft event
// Form state - initialize from event (edit mode) or draft event (create mode)
let title = $state('');
let calendarId = $state('');
let description = $state('');
@ -155,82 +175,132 @@
let locationCountry = $state('');
let submitting = $state(false);
// Date/time fields - derive from draft event
// Editable date/time strings (for form inputs)
let startDateStr = $state('');
let startTimeStr = $state('');
let endDateStr = $state('');
let endTimeStr = $state('');
// Initialize form state from event in edit mode
$effect(() => {
if (isEditMode && event) {
title = event.title || '';
calendarId = event.calendarId || '';
description = event.description || '';
location = event.location || '';
isAllDay = event.isAllDay || false;
allDayDisplayMode =
(event.metadata?.allDayDisplayMode as 'default' | 'header' | 'block') || 'default';
// Initialize location details
const loc = event.metadata?.locationDetails;
if (loc) {
showLocationDetails = true;
locationStreet = loc.street || '';
locationPostalCode = loc.postalCode || '';
locationCity = loc.city || '';
locationCountry = loc.country || '';
}
// Initialize time fields
const eventStart =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
startDateStr = format(eventStart, 'yyyy-MM-dd');
startTimeStr = format(eventStart, 'HH:mm');
endDateStr = format(eventEnd, 'yyyy-MM-dd');
endTimeStr = format(eventEnd, 'HH:mm');
}
});
// Date/time fields - derive from draft event (create mode) or event (edit mode)
let draftStart = $derived(() => {
if (isEditMode && event) {
return typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
}
const draft = eventsStore.draftEvent;
if (draft) {
return typeof draft.startTime === 'string' ? parseISO(draft.startTime) : draft.startTime;
}
return startTime;
return startTime || new Date();
});
let draftEnd = $derived(() => {
if (isEditMode && event) {
return typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
}
const draft = eventsStore.draftEvent;
if (draft) {
return typeof draft.endTime === 'string' ? parseISO(draft.endTime) : draft.endTime;
}
return addMinutes(startTime, settingsStore.defaultEventDuration);
return addMinutes(startTime || new Date(), settingsStore.defaultEventDuration);
});
// Display date/time - derived from draft event
// Display date/time - derived from draft event or event
let displayStartDate = $derived(format(draftStart(), 'yyyy-MM-dd'));
let displayStartTime = $derived(format(draftStart(), 'HH:mm'));
let displayEndDate = $derived(format(draftEnd(), 'yyyy-MM-dd'));
let displayEndTime = $derived(format(draftEnd(), 'HH:mm'));
// Editable date/time strings (for form inputs)
let startDateStr = $state(format(startTime, 'yyyy-MM-dd'));
let startTimeStr = $state(format(startTime, 'HH:mm'));
let endDateStr = $state('');
let endTimeStr = $state('');
// Sync form fields from draft event when it changes (e.g., user drags it)
// Sync form fields from draft event when it changes (e.g., user drags it) - only in create mode
$effect(() => {
startDateStr = displayStartDate;
startTimeStr = displayStartTime;
endDateStr = displayEndDate;
endTimeStr = displayEndTime;
if (!isEditMode) {
startDateStr = displayStartDate;
startTimeStr = displayStartTime;
endDateStr = displayEndDate;
endTimeStr = displayEndTime;
}
});
// Set default calendar
// Set default calendar - only in create mode
$effect(() => {
if (!calendarId && calendarsStore.defaultCalendar?.id) {
if (!isEditMode && !calendarId && calendarsStore.defaultCalendar?.id) {
calendarId = calendarsStore.defaultCalendar.id;
// Update draft event with calendar
eventsStore.updateDraftEvent({ calendarId });
}
});
// Update draft event when title changes
// Update draft event when title changes - only in create mode
function handleTitleChange(e: Event) {
const target = e.target as HTMLInputElement;
title = target.value;
eventsStore.updateDraftEvent({ title: target.value });
if (!isEditMode) {
eventsStore.updateDraftEvent({ title: target.value });
}
}
// Update draft event when time fields change
function handleStartDateChange(e: Event) {
const target = e.target as HTMLInputElement;
startDateStr = target.value;
updateDraftTimes();
if (!isEditMode) {
updateDraftTimes();
}
}
function handleStartTimeChange(e: Event) {
const target = e.target as HTMLInputElement;
startTimeStr = target.value;
updateDraftTimes();
if (!isEditMode) {
updateDraftTimes();
}
}
function handleEndDateChange(e: Event) {
const target = e.target as HTMLInputElement;
endDateStr = target.value;
updateDraftTimes();
if (!isEditMode) {
updateDraftTimes();
}
}
function handleEndTimeChange(e: Event) {
const target = e.target as HTMLInputElement;
endTimeStr = target.value;
updateDraftTimes();
if (!isEditMode) {
updateDraftTimes();
}
}
function updateDraftTimes() {
@ -252,13 +322,17 @@
function handleCalendarChange(e: Event) {
const target = e.target as HTMLSelectElement;
calendarId = target.value;
eventsStore.updateDraftEvent({ calendarId: target.value });
if (!isEditMode) {
eventsStore.updateDraftEvent({ calendarId: target.value });
}
}
// Update draft when all-day changes
function handleAllDayToggle() {
isAllDay = !isAllDay;
updateDraftTimes();
if (!isEditMode) {
updateDraftTimes();
}
}
// Overlay style
@ -292,18 +366,32 @@
}
: undefined;
// Build metadata
let metadata: Record<string, unknown> | undefined = undefined;
// Build metadata - preserve existing metadata in edit mode
let metadata: Record<string, unknown> | undefined = isEditMode
? { ...(event?.metadata || {}) }
: undefined;
if (isAllDay && allDayDisplayMode !== 'default') {
metadata = { allDayDisplayMode: allDayDisplayMode as 'header' | 'block' };
metadata = {
...(metadata || {}),
allDayDisplayMode: allDayDisplayMode as 'header' | 'block',
};
} else if (metadata) {
delete metadata.allDayDisplayMode;
}
if (locationDetails) {
metadata = { ...(metadata || {}), locationDetails };
} else if (metadata) {
delete metadata.locationDetails;
}
await eventsStore.createEvent({
// Clean up empty metadata
if (metadata && Object.keys(metadata).length === 0) {
metadata = undefined;
}
const eventData = {
title: title.trim(),
calendarId,
startTime: startDateTime.toISOString(),
@ -312,12 +400,56 @@
description: description.trim() || undefined,
location: location.trim() || undefined,
metadata,
});
};
if (isEditMode && event) {
// Update existing event
const result = await eventsStore.updateEvent(event.id, eventData);
if (result.error) {
toast.error(`Fehler beim Speichern: ${result.error.message}`);
return;
}
toast.success('Termin aktualisiert');
onUpdated?.();
} else {
// Create new event
await eventsStore.createEvent(eventData);
// Refresh calendars if none existed (in case default was created)
if (calendarsStore.calendars.length === 0) {
await calendarsStore.fetchCalendars();
}
onCreated?.();
}
onCreated?.();
onClose();
} catch (error) {
console.error('Failed to create event:', error);
console.error('Failed to save event:', error);
toast.error('Fehler beim Speichern');
} finally {
submitting = false;
}
}
async function handleDelete() {
if (!event) return;
if (!confirm('Möchten Sie diesen Termin wirklich löschen?')) {
return;
}
submitting = true;
try {
const result = await eventsStore.deleteEvent(event.id);
if (result.error) {
toast.error(`Fehler beim Löschen: ${result.error.message}`);
return;
}
toast.success('Termin gelöscht');
onDeleted?.();
onClose();
} catch (error) {
console.error('Failed to delete event:', error);
toast.error('Fehler beim Löschen');
} finally {
submitting = false;
}
@ -338,22 +470,42 @@
style={overlayStyle}
role="dialog"
aria-modal="true"
aria-label="Termin erstellen"
aria-label={isEditMode ? 'Termin bearbeiten' : 'Termin erstellen'}
>
<form onsubmit={handleSubmit}>
<!-- Header -->
<div class="overlay-header">
<span class="header-title">Neuer Termin</span>
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<span class="header-title">{isEditMode ? 'Termin bearbeiten' : 'Neuer Termin'}</span>
<div class="header-actions">
{#if isEditMode}
<button
type="button"
class="delete-btn"
onclick={handleDelete}
disabled={submitting}
aria-label="Löschen"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Scrollable content -->
@ -399,17 +551,22 @@
></div>
</div>
<div class="row-content">
<label class="field-label">Kalender</label>
<select class="field-select" value={calendarId} onchange={handleCalendarChange}>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
<span class="field-label">Kalender</span>
{#if calendarsStore.calendars.length > 0}
<select class="field-select" value={calendarId} onchange={handleCalendarChange}>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
{:else}
<span class="field-placeholder">Standardkalender wird erstellt</span>
{/if}
</div>
</div>
<!-- All day toggle -->
<div class="form-row clickable" onclick={handleAllDayToggle}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_to_interactive_role -->
<div class="form-row clickable" onclick={handleAllDayToggle} role="button" tabindex="0">
<div class="row-icon">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -437,7 +594,7 @@
<div class="form-row sub-row">
<div class="row-icon"></div>
<div class="row-content">
<label class="field-label">Anzeigeart</label>
<span class="field-label">Anzeigeart</span>
<select class="field-select" bind:value={allDayDisplayMode}>
<option value="default">Standard (aus Einstellungen)</option>
<option value="header">In Kopfzeile</option>
@ -461,7 +618,7 @@
</div>
<div class="row-content datetime-row">
<div class="datetime-field">
<label class="field-label">Beginn</label>
<span class="field-label">Beginn</span>
<input
type="date"
class="field-input"
@ -471,7 +628,7 @@
</div>
{#if !isAllDay}
<div class="datetime-field time-field">
<label class="field-label">Uhrzeit</label>
<span class="field-label">Uhrzeit</span>
<input
type="time"
class="field-input"
@ -497,7 +654,7 @@
</div>
<div class="row-content datetime-row">
<div class="datetime-field">
<label class="field-label">Ende</label>
<span class="field-label">Ende</span>
<input
type="date"
class="field-input"
@ -507,7 +664,7 @@
</div>
{#if !isAllDay}
<div class="datetime-field time-field">
<label class="field-label">Uhrzeit</label>
<span class="field-label">Uhrzeit</span>
<input
type="time"
class="field-input"
@ -575,7 +732,7 @@
<div class="row-icon"></div>
<div class="row-content address-details-form">
<div class="address-field">
<label class="field-label">Straße</label>
<span class="field-label">Straße</span>
<input
type="text"
class="field-input"
@ -585,7 +742,7 @@
</div>
<div class="address-row">
<div class="address-field postal">
<label class="field-label">PLZ</label>
<span class="field-label">PLZ</span>
<input
type="text"
class="field-input"
@ -594,7 +751,7 @@
/>
</div>
<div class="address-field city">
<label class="field-label">Stadt</label>
<span class="field-label">Stadt</span>
<input
type="text"
class="field-input"
@ -604,7 +761,7 @@
</div>
</div>
<div class="address-field">
<label class="field-label">Land</label>
<span class="field-label">Land</span>
<input
type="text"
class="field-input"
@ -664,14 +821,14 @@
display: flex;
flex-direction: column;
animation: slideIn 150ms ease-out;
overflow: hidden; /* Prevent any content from overflowing */
overflow: hidden;
}
.quick-event-overlay form {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0; /* Allow form to shrink below content size */
min-height: 0;
height: 100%;
}
@ -701,7 +858,14 @@
color: hsl(var(--color-foreground));
}
.close-btn {
.header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.close-btn,
.delete-btn {
padding: 0.375rem;
border: none;
background: transparent;
@ -716,11 +880,21 @@
color: hsl(var(--color-foreground));
}
.delete-btn:hover {
background: hsl(var(--color-error) / 0.1);
color: hsl(var(--color-error));
}
.delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.overlay-content {
flex: 1;
min-height: 0; /* Important for flex scroll */
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain; /* Prevent scroll chaining to background */
overscroll-behavior: contain;
padding: 0.75rem 0;
}
@ -841,6 +1015,14 @@
border-color: hsl(var(--color-primary));
}
.field-placeholder {
display: block;
padding: 0.5rem 0.625rem;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
font-style: italic;
}
.field-input.full {
padding: 0.625rem;
}

View file

@ -83,6 +83,7 @@
</button>
{:else}
<form class="quick-add-form" onsubmit={handleSubmit}>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:this={inputRef}
bind:value={title}

View file

@ -211,14 +211,7 @@
>
<div class="form-group">
<label for="title">Titel</label>
<input
id="title"
type="text"
bind:value={title}
placeholder="Aufgabentitel"
required
autofocus
/>
<input id="title" type="text" bind:value={title} placeholder="Aufgabentitel" required />
</div>
<div class="form-group">
@ -280,7 +273,7 @@
</div>
<div class="form-group">
<label>Priorität</label>
<span class="label-text">Priorität</span>
<div class="priority-options">
{#each Object.entries(PRIORITY_LABELS) as [key, label]}
<button
@ -671,6 +664,12 @@
min-height: 80px;
}
.label-text {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.priority-options {
display: flex;
flex-wrap: wrap;

View file

@ -95,6 +95,7 @@
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="todo-item"
class:completed={task.isCompleted}

View file

@ -0,0 +1,243 @@
/**
* Drag & Drop Composable for Calendar Events
* Extracts drag logic from WeekView/DayView for reusability
*/
import type { CalendarEvent } from '@calendar/shared';
import { parseISO, differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
import { eventsStore } from '$lib/stores/events.svelte';
export interface DragDropConfig {
/** Reference to the container element for position calculations */
containerEl: HTMLElement | null;
/** Array of visible days */
days: Date[];
/** First visible hour (for filtered hours mode) */
firstVisibleHour: number;
/** Last visible hour (for filtered hours mode) */
lastVisibleHour: number;
/** Height of one hour in pixels */
hourHeight: number;
/** Minutes per snap interval */
snapMinutes?: number;
}
export interface DragState {
isDragging: boolean;
draggedEvent: CalendarEvent | null;
dragTargetDay: Date | null;
dragPreviewTop: number;
dragPreviewHeight: number;
hasMoved: boolean;
}
export function useDragDrop(getConfig: () => DragDropConfig) {
// State
let isDragging = $state(false);
let draggedEvent = $state<CalendarEvent | null>(null);
let dragOffsetMinutes = $state(0);
let dragTargetDay = $state<Date | null>(null);
let dragPreviewTop = $state(0);
let dragPreviewHeight = $state(0);
let hasMoved = $state(false);
// Derived values
const totalVisibleHours = $derived(() => {
const config = getConfig();
return config.lastVisibleHour - config.firstVisibleHour;
});
/**
* Convert minutes to percentage position (accounting for hidden hours)
*/
function minutesToPercent(minutes: number): number {
const config = getConfig();
const adjustedMinutes = minutes - config.firstVisibleHour * 60;
return (adjustedMinutes / (totalVisibleHours() * 60)) * 100;
}
/**
* Get day from X coordinate
*/
function getDayFromX(clientX: number): Date | null {
const config = getConfig();
if (!config.containerEl) return null;
const rect = config.containerEl.getBoundingClientRect();
const relativeX = clientX - rect.left;
const dayWidth = rect.width / config.days.length;
const dayIndex = Math.floor(relativeX / dayWidth);
if (dayIndex >= 0 && dayIndex < config.days.length) {
return config.days[dayIndex];
}
return null;
}
/**
* Get minutes from Y coordinate
*/
function getMinutesFromY(clientY: number): number {
const config = getConfig();
if (!config.containerEl) return 0;
const rect = config.containerEl.getBoundingClientRect();
const scrollTop = config.containerEl.parentElement?.scrollTop || 0;
const relativeY = clientY - rect.top + scrollTop;
// Account for hidden early hours
const visibleMinutes =
(relativeY / (totalVisibleHours() * config.hourHeight)) * totalVisibleHours() * 60;
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
// Snap to interval
const snapMinutes = config.snapMinutes ?? 15;
return Math.round(totalMinutes / snapMinutes) * snapMinutes;
}
/**
* Start dragging an event
*/
function startDrag(event: CalendarEvent, e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
const config = getConfig();
isDragging = true;
draggedEvent = event;
hasMoved = false;
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
const duration = differenceInMinutes(end, start);
// Calculate initial preview position
const startMinutes = start.getHours() * 60 + start.getMinutes();
dragPreviewTop = minutesToPercent(startMinutes);
dragPreviewHeight = (duration / (totalVisibleHours() * 60)) * 100;
dragTargetDay = start;
// Calculate offset from event start to click position
const clickMinutes = getMinutesFromY(e.clientY);
dragOffsetMinutes = clickMinutes - startMinutes;
document.addEventListener('pointermove', handleDragMove);
document.addEventListener('pointerup', handleDragEnd);
}
function handleDragMove(e: PointerEvent) {
if (!isDragging || !draggedEvent) return;
const config = getConfig();
hasMoved = true;
// Calculate new position
const newDay = getDayFromX(e.clientX);
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
// Clamp to valid range
const clampedMinutes = Math.max(
config.firstVisibleHour * 60,
Math.min(config.lastVisibleHour * 60 - 15, newMinutes)
);
// Update preview
dragPreviewTop = minutesToPercent(clampedMinutes);
if (newDay) {
dragTargetDay = newDay;
}
}
async function handleDragEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) {
cleanup();
return;
}
const config = getConfig();
const start =
typeof draggedEvent.startTime === 'string'
? parseISO(draggedEvent.startTime)
: draggedEvent.startTime;
const end =
typeof draggedEvent.endTime === 'string'
? parseISO(draggedEvent.endTime)
: draggedEvent.endTime;
const duration = differenceInMinutes(end, start);
// Calculate new start time
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
const clampedMinutes = Math.max(0, Math.min(24 * 60 - 15, newMinutes));
const newHours = Math.floor(clampedMinutes / 60);
const newMins = clampedMinutes % 60;
let newStart = new Date(dragTargetDay);
newStart = setHours(newStart, newHours);
newStart = setMinutes(newStart, newMins);
const newEnd = addMinutes(newStart, duration);
// Update event via store
if (eventsStore.isDraftEvent(draggedEvent.id)) {
eventsStore.updateDraftEvent({
startTime: newStart.toISOString(),
endTime: newEnd.toISOString(),
});
} else {
await eventsStore.updateEvent(draggedEvent.id, {
startTime: newStart.toISOString(),
endTime: newEnd.toISOString(),
});
}
cleanup();
}
function cleanup() {
isDragging = false;
draggedEvent = null;
dragTargetDay = null;
hasMoved = false;
}
/**
* Cancel drag operation (e.g., on Escape key)
*/
function cancelDrag() {
if (isDragging) {
document.removeEventListener('pointermove', handleDragMove);
document.removeEventListener('pointerup', handleDragEnd);
cleanup();
}
}
return {
// State (reactive getters)
get isDragging() {
return isDragging;
},
get draggedEvent() {
return draggedEvent;
},
get dragTargetDay() {
return dragTargetDay;
},
get dragPreviewTop() {
return dragPreviewTop;
},
get dragPreviewHeight() {
return dragPreviewHeight;
},
get hasMoved() {
return hasMoved;
},
// Methods
startDrag,
cancelDrag,
minutesToPercent,
};
}

View file

@ -0,0 +1,235 @@
/**
* Resize Composable for Calendar Events
* Extracts resize logic from WeekView/DayView for reusability
*/
import type { CalendarEvent } from '@calendar/shared';
import { parseISO, differenceInMinutes, setHours, setMinutes } from 'date-fns';
import { eventsStore } from '$lib/stores/events.svelte';
export interface ResizeConfig {
/** Reference to the container element for position calculations */
containerEl: HTMLElement | null;
/** First visible hour (for filtered hours mode) */
firstVisibleHour: number;
/** Last visible hour (for filtered hours mode) */
lastVisibleHour: number;
/** Height of one hour in pixels */
hourHeight: number;
/** Minutes per snap interval */
snapMinutes?: number;
}
export interface ResizeState {
isResizing: boolean;
resizeEvent: CalendarEvent | null;
resizeEdge: 'top' | 'bottom';
resizePreviewTop: number;
resizePreviewHeight: number;
hasMoved: boolean;
}
export function useResize(getConfig: () => ResizeConfig) {
// State
let isResizing = $state(false);
let resizeEvent = $state<CalendarEvent | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeOriginalStart = $state<Date | null>(null);
let resizeOriginalEnd = $state<Date | null>(null);
let resizePreviewTop = $state(0);
let resizePreviewHeight = $state(0);
let hasMoved = $state(false);
// Derived values
const totalVisibleHours = $derived(() => {
const config = getConfig();
return config.lastVisibleHour - config.firstVisibleHour;
});
/**
* Convert minutes to percentage position
*/
function minutesToPercent(minutes: number): number {
const config = getConfig();
const adjustedMinutes = minutes - config.firstVisibleHour * 60;
return (adjustedMinutes / (totalVisibleHours() * 60)) * 100;
}
/**
* Get minutes from Y coordinate
*/
function getMinutesFromY(clientY: number): number {
const config = getConfig();
if (!config.containerEl) return 0;
const rect = config.containerEl.getBoundingClientRect();
const scrollTop = config.containerEl.parentElement?.scrollTop || 0;
const relativeY = clientY - rect.top + scrollTop;
const visibleMinutes =
(relativeY / (totalVisibleHours() * config.hourHeight)) * totalVisibleHours() * 60;
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
const snapMinutes = config.snapMinutes ?? 15;
return Math.round(totalMinutes / snapMinutes) * snapMinutes;
}
/**
* Start resizing an event
*/
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
e.preventDefault();
e.stopPropagation();
isResizing = true;
resizeEvent = event;
resizeEdge = edge;
hasMoved = false;
const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime;
resizeOriginalStart = start;
resizeOriginalEnd = end;
// Set initial preview
const startMinutes = start.getHours() * 60 + start.getMinutes();
const duration = differenceInMinutes(end, start);
resizePreviewTop = minutesToPercent(startMinutes);
resizePreviewHeight = (duration / (totalVisibleHours() * 60)) * 100;
document.addEventListener('pointermove', handleResizeMove);
document.addEventListener('pointerup', handleResizeEnd);
}
function handleResizeMove(e: PointerEvent) {
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return;
const config = getConfig();
hasMoved = true;
const currentMinutes = getMinutesFromY(e.clientY);
const originalStartMinutes =
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
if (resizeEdge === 'bottom') {
// Resize from bottom - change end time
const newEndMinutes = Math.max(
originalStartMinutes + 15,
Math.min(config.lastVisibleHour * 60, currentMinutes)
);
const newDuration = newEndMinutes - originalStartMinutes;
resizePreviewHeight = (newDuration / (totalVisibleHours() * 60)) * 100;
} else {
// Resize from top - change start time
const newStartMinutes = Math.max(
config.firstVisibleHour * 60,
Math.min(originalEndMinutes - 15, currentMinutes)
);
const newDuration = originalEndMinutes - newStartMinutes;
resizePreviewTop = minutesToPercent(newStartMinutes);
resizePreviewHeight = (newDuration / (totalVisibleHours() * 60)) * 100;
}
}
async function handleResizeEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) {
cleanup();
return;
}
const config = getConfig();
const currentMinutes = getMinutesFromY(e.clientY);
const originalStartMinutes =
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
let newStart = resizeOriginalStart;
let newEnd = resizeOriginalEnd;
if (resizeEdge === 'bottom') {
const newEndMinutes = Math.max(
originalStartMinutes + 15,
Math.min(config.lastVisibleHour * 60, currentMinutes)
);
const newHours = Math.floor(newEndMinutes / 60);
const newMins = newEndMinutes % 60;
newEnd = setHours(new Date(resizeOriginalEnd), newHours);
newEnd = setMinutes(newEnd, newMins);
} else {
const newStartMinutes = Math.max(
config.firstVisibleHour * 60,
Math.min(originalEndMinutes - 15, currentMinutes)
);
const newHours = Math.floor(newStartMinutes / 60);
const newMins = newStartMinutes % 60;
newStart = setHours(new Date(resizeOriginalStart), newHours);
newStart = setMinutes(newStart, newMins);
}
// Update event via store
if (eventsStore.isDraftEvent(resizeEvent.id)) {
eventsStore.updateDraftEvent({
startTime: newStart.toISOString(),
endTime: newEnd.toISOString(),
});
} else {
await eventsStore.updateEvent(resizeEvent.id, {
startTime: newStart.toISOString(),
endTime: newEnd.toISOString(),
});
}
cleanup();
}
function cleanup() {
isResizing = false;
resizeEvent = null;
resizeOriginalStart = null;
resizeOriginalEnd = null;
hasMoved = false;
}
/**
* Cancel resize operation
*/
function cancelResize() {
if (isResizing) {
document.removeEventListener('pointermove', handleResizeMove);
document.removeEventListener('pointerup', handleResizeEnd);
cleanup();
}
}
return {
// State (reactive getters)
get isResizing() {
return isResizing;
},
get resizeEvent() {
return resizeEvent;
},
get resizeEdge() {
return resizeEdge;
},
get resizePreviewTop() {
return resizePreviewTop;
},
get resizePreviewHeight() {
return resizePreviewHeight;
},
get hasMoved() {
return hasMoved;
},
// Methods
startResize,
cancelResize,
minutesToPercent,
};
}

View file

@ -35,11 +35,17 @@ function getInitialLocale(): SupportedLocale {
}
// Initialize i18n at module scope (required for SSR)
// Always set initialLocale to ensure it's never undefined
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
initialLocale: browser ? getInitialLocale() : defaultLocale,
});
// On browser, also explicitly set locale to ensure it's loaded
if (browser) {
locale.set(getInitialLocale());
}
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);

View file

@ -19,7 +19,9 @@
"month": "Monat",
"year": "Jahr",
"agenda": "Agenda",
"weekdaysOnly": "Nur Wochentage"
"weekdaysOnly": "Nur Wochentage",
"weekNumber": "KW",
"moreEvents": "+{count} mehr"
},
"calendar": {
"today": "Heute",
@ -27,7 +29,10 @@
"noEvents": "Keine Termine",
"allDay": "Ganztägig",
"myCalendars": "Meine Kalender",
"sharedCalendars": "Geteilte Kalender"
"sharedCalendars": "Geteilte Kalender",
"draftEvent": "(Neuer Termin)",
"hideSidebar": "Sidebar ausblenden",
"showSidebar": "Sidebar einblenden"
},
"event": {
"title": "Titel",
@ -41,7 +46,9 @@
"calendar": "Kalender",
"save": "Speichern",
"delete": "Löschen",
"cancel": "Abbrechen"
"cancel": "Abbrechen",
"changeStartTime": "Startzeit ändern",
"changeEndTime": "Endzeit ändern"
},
"repeat": {
"none": "Nicht wiederholen",

View file

@ -19,7 +19,9 @@
"month": "Month",
"year": "Year",
"agenda": "Agenda",
"weekdaysOnly": "Weekdays only"
"weekdaysOnly": "Weekdays only",
"weekNumber": "W",
"moreEvents": "+{count} more"
},
"calendar": {
"today": "Today",
@ -27,7 +29,10 @@
"noEvents": "No events",
"allDay": "All day",
"myCalendars": "My Calendars",
"sharedCalendars": "Shared Calendars"
"sharedCalendars": "Shared Calendars",
"draftEvent": "(New Event)",
"hideSidebar": "Hide sidebar",
"showSidebar": "Show sidebar"
},
"event": {
"title": "Title",
@ -41,7 +46,9 @@
"calendar": "Calendar",
"save": "Save",
"delete": "Delete",
"cancel": "Cancel"
"cancel": "Cancel",
"changeStartTime": "Change start time",
"changeEndTime": "Change end time"
},
"repeat": {
"none": "Don't repeat",

View file

@ -5,6 +5,7 @@
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
import * as api from '$lib/api/events';
import { format, isWithinInterval, parseISO, isSameDay } from 'date-fns';
import { toastStore } from './toast.svelte';
// State
let events = $state<CalendarEvent[]>([]);
@ -45,6 +46,7 @@ export const eventsStore = {
if (result.error) {
error = result.error.message;
toastStore.error(`Termine konnten nicht geladen werden: ${result.error.message}`);
} else {
// API returns { events: [...] }
const data = result.data as { events: CalendarEvent[] } | null;
@ -119,8 +121,11 @@ export const eventsStore = {
async createEvent(data: CreateEventInput) {
const result = await api.createEvent(data);
if (result.data) {
if (result.error) {
toastStore.error(`Termin konnte nicht erstellt werden: ${result.error.message}`);
} else if (result.data) {
events = [...events, result.data];
toastStore.success('Termin erstellt');
}
return result;
@ -132,7 +137,9 @@ export const eventsStore = {
async updateEvent(id: string, data: UpdateEventInput) {
const result = await api.updateEvent(id, data);
if (result.data) {
if (result.error) {
toastStore.error(`Termin konnte nicht aktualisiert werden: ${result.error.message}`);
} else if (result.data) {
events = events.map((e) => (e.id === id ? result.data! : e));
}
@ -140,13 +147,23 @@ export const eventsStore = {
},
/**
* Delete an event
* Delete an event (optimistic update)
*/
async deleteEvent(id: string) {
// Optimistic: remove event immediately
const eventToDelete = events.find((e) => e.id === id);
events = events.filter((e) => e.id !== id);
const result = await api.deleteEvent(id);
if (!result.error) {
events = events.filter((e) => e.id !== id);
if (result.error) {
// Rollback: restore the event on error
if (eventToDelete) {
events = [...events, eventToDelete];
}
toastStore.error(`Termin konnte nicht gelöscht werden: ${result.error.message}`);
} else {
toastStore.success('Termin gelöscht');
}
return result;

View file

@ -0,0 +1,57 @@
/**
* Toast Store - Svelte 5 Runes version
* Manages toast notifications
*/
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
}
// State
let toasts = $state<Toast[]>([]);
function add(message: string, type: ToastType = 'info', duration: number = 4000): string {
const id = crypto.randomUUID();
const toast: Toast = { id, type, message, duration };
toasts = [...toasts, toast];
if (duration > 0) {
setTimeout(() => {
remove(id);
}, duration);
}
return id;
}
function remove(id: string) {
toasts = toasts.filter((t) => t.id !== id);
}
function clear() {
toasts = [];
}
export const toastStore = {
get toasts() {
return toasts;
},
add,
remove,
clear,
success: (message: string, duration?: number) => add(message, 'success', duration),
error: (message: string, duration?: number) => add(message, 'error', duration ?? 6000),
warning: (message: string, duration?: number) => add(message, 'warning', duration),
info: (message: string, duration?: number) => add(message, 'info', duration),
};
// Keep old export for backwards compatibility
export const toast = toastStore;

View file

@ -61,7 +61,7 @@ export const todosStore = {
// ========== Derived Getters ==========
/**
* Get todos for a specific day
* Get todos for a specific day (by dueDate)
*/
getTodosForDay(date: Date): Task[] {
const currentTodos = todos ?? [];
@ -348,7 +348,11 @@ export const todosStore = {
if (result.error) {
error = result.error.message;
serviceAvailable = false;
// Only set serviceAvailable to false if we have no todos yet
// (if fetchTodayTodos succeeded, we should still show the service as available)
if (todos.length === 0) {
serviceAvailable = false;
}
} else {
// Merge with existing todos (avoid duplicates)
const newTodos = result.data || [];
@ -415,13 +419,20 @@ export const todosStore = {
},
/**
* Delete a todo
* Delete a todo (optimistic update)
*/
async deleteTodo(id: string) {
// Optimistic: remove todo immediately
const todoToDelete = todos.find((t) => t.id === id);
todos = todos.filter((t) => t.id !== id);
const result = await api.deleteTask(id);
if (!result.error) {
todos = todos.filter((t) => t.id !== id);
if (result.error) {
// Rollback: restore the todo on error
if (todoToDelete) {
todos = [...todos, todoToDelete];
}
}
return result;

View file

@ -0,0 +1,41 @@
/**
* Event Date Helpers
* Utilities for consistent date handling across the calendar app
*/
import { parseISO } from 'date-fns';
/**
* Convert a date value that may be either a string or Date to a Date object
* This handles the common pattern where API returns ISO strings but we need Date objects
*/
export function toDate(value: string | Date): Date {
return typeof value === 'string' ? parseISO(value) : value;
}
/**
* Get the start time of an event as a Date object
*/
export function getEventStart(event: { startTime: string | Date }): Date {
return toDate(event.startTime);
}
/**
* Get the end time of an event as a Date object
*/
export function getEventEnd(event: { endTime: string | Date }): Date {
return toDate(event.endTime);
}
/**
* Get both start and end times of an event as Date objects
*/
export function getEventTimes(event: { startTime: string | Date; endTime: string | Date }): {
start: Date;
end: Date;
} {
return {
start: toDate(event.startTime),
end: toDate(event.endTime),
};
}

View file

@ -104,12 +104,6 @@
const tags = eventTagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
const resolved = resolveEventIds(parsed, calendars, tags);
// Ensure we have a calendar
if (!resolved.calendarId) {
console.error('No calendar available');
return;
}
// Ensure we have start and end times
if (!resolved.startTime) {
// Default to now + 1 hour
@ -119,8 +113,10 @@
resolved.endTime = end.toISOString();
}
// Create event - calendarId is now optional, backend will use/create default if not provided
await eventsStore.createEvent({
calendarId: resolved.calendarId,
// Only include calendarId if resolved (from command or default calendar)
...(resolved.calendarId ? { calendarId: resolved.calendarId } : {}),
title: resolved.title,
startTime: resolved.startTime,
endTime: resolved.endTime || resolved.startTime,
@ -128,6 +124,11 @@
location: resolved.location,
tagIds: resolved.tagIds,
});
// Refresh calendars if none existed (in case default was created)
if (calendarsStore.calendars.length === 0) {
await calendarsStore.fetchCalendars();
}
}
let isSidebarMode = $state(false);

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { _ } from 'svelte-i18n';
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
@ -18,24 +19,24 @@
import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte';
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
import EventDetailModal from '$lib/components/event/EventDetailModal.svelte';
import { CalendarViewSkeleton } from '$lib/components/skeletons';
import { format, addMinutes } from 'date-fns';
import { de } from 'date-fns/locale';
import type { CalendarEvent } from '@calendar/shared';
import { addMinutes } from 'date-fns';
let initialized = $state(false);
// Quick event overlay state
let showQuickCreate = $state(false);
// Quick event overlay state - for both create and edit
let showQuickOverlay = $state(false);
let quickCreateDate = $state<Date>(new Date());
let editingEvent = $state<CalendarEvent | null>(null);
// Event modal state (local state for reactivity)
let selectedEventId = $state<string | null>(null);
// Derive modal open state from URL
let modalEventId = $derived($page.url.searchParams.get('event'));
// Generate a unique key for the overlay to force remount
let overlayKey = $state(0);
function handleQuickCreate(date: Date, position: { x: number; y: number }) {
// Close any existing overlay first
editingEvent = null;
quickCreateDate = date;
// Create draft event immediately so it appears in the grid
@ -50,11 +51,22 @@
isAllDay: false,
});
showQuickCreate = true;
overlayKey++;
showQuickOverlay = true;
}
function handleQuickCreateClose() {
showQuickCreate = false;
function handleEventClick(event: CalendarEvent) {
// Close any existing overlay/draft first
eventsStore.clearDraftEvent();
editingEvent = event;
overlayKey++;
showQuickOverlay = true;
}
function handleQuickOverlayClose() {
showQuickOverlay = false;
editingEvent = null;
eventsStore.clearDraftEvent();
}
@ -63,6 +75,14 @@
eventsStore.clearDraftEvent();
}
function handleEventUpdated() {
// Event is automatically updated in store
}
function handleEventDeleted() {
// Event is automatically removed from store
}
onMount(async () => {
if (!authStore.isAuthenticated) {
goto('/login');
@ -74,11 +94,6 @@
initialized = true;
});
function handleEventModalClose() {
// Remove event param from URL
goto('/', { replaceState: true });
}
// Refetch events when view changes
$effect(() => {
if (initialized && authStore.isAuthenticated) {
@ -96,7 +111,7 @@
</script>
<svelte:head>
<title>Kalender</title>
<title>{$_('app.name')}</title>
</svelte:head>
<div class="calendar-layout">
@ -106,7 +121,7 @@
<button
class="sidebar-collapse-btn"
onclick={() => settingsStore.toggleSidebar()}
title="Sidebar ausblenden"
title={$_('calendar.hideSidebar')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -125,7 +140,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neuer Termin
{$_('calendar.newEvent')}
</button>
<MiniCalendar selectedDate={viewStore.currentDate} onDateSelect={handleDateSelect} />
@ -141,7 +156,7 @@
<button
class="fab-expand"
onclick={() => settingsStore.toggleSidebar()}
title="Sidebar einblenden"
title={$_('calendar.showSidebar')}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -152,7 +167,7 @@
/>
</svg>
</button>
<button class="fab-new-event" onclick={handleNewEvent} title="Neuer Termin">
<button class="fab-new-event" onclick={handleNewEvent} title={$_('calendar.newEvent')}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
@ -173,37 +188,49 @@
{#if !initialized}
<CalendarViewSkeleton />
{:else if viewStore.viewType === 'day'}
<DayView onQuickCreate={handleQuickCreate} />
<DayView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === '5day'}
<MultiDayView dayCount={5} onQuickCreate={handleQuickCreate} />
<MultiDayView
dayCount={5}
onQuickCreate={handleQuickCreate}
onEventClick={handleEventClick}
/>
{:else if viewStore.viewType === 'week'}
<WeekView onQuickCreate={handleQuickCreate} />
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === '10day'}
<MultiDayView dayCount={10} onQuickCreate={handleQuickCreate} />
<MultiDayView
dayCount={10}
onQuickCreate={handleQuickCreate}
onEventClick={handleEventClick}
/>
{:else if viewStore.viewType === '14day'}
<MultiDayView dayCount={14} onQuickCreate={handleQuickCreate} />
<MultiDayView
dayCount={14}
onQuickCreate={handleQuickCreate}
onEventClick={handleEventClick}
/>
{:else if viewStore.viewType === 'month'}
<MonthView onQuickCreate={handleQuickCreate} />
<MonthView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else if viewStore.viewType === 'year'}
<YearView onQuickCreate={handleQuickCreate} />
<YearView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{:else}
<WeekView onQuickCreate={handleQuickCreate} />
<WeekView onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{/if}
</div>
</div>
<!-- Quick Event Overlay -->
{#if showQuickCreate}
<QuickEventOverlay
startTime={quickCreateDate}
onClose={handleQuickCreateClose}
onCreated={handleEventCreated}
/>
{/if}
<!-- Event Detail Modal -->
{#if modalEventId}
<EventDetailModal eventId={modalEventId} onClose={handleEventModalClose} />
<!-- Quick Event Overlay (for both create and edit) -->
{#if showQuickOverlay}
{#key overlayKey}
<QuickEventOverlay
startTime={editingEvent ? undefined : quickCreateDate}
event={editingEvent ?? undefined}
onClose={handleQuickOverlayClose}
onCreated={handleEventCreated}
onUpdated={handleEventUpdated}
onDeleted={handleEventDeleted}
/>
{/key}
{/if}
</div>

View file

@ -34,6 +34,11 @@
return;
}
// Refresh calendars in case a default calendar was created
if (calendarsStore.calendars.length === 0) {
await calendarsStore.fetchCalendars();
}
toast.success('Termin erstellt');
goto('/');
}

View file

@ -5,7 +5,7 @@
import { NetworkGraph, NetworkControls } from '@manacore/shared-ui';
import '$lib/i18n';
let graphComponent: NetworkGraph;
let graphComponent = $state<NetworkGraph | null>(null);
let controlsComponent: NetworkControls;
let graphContainer: HTMLDivElement;
@ -172,7 +172,11 @@
<div class="info-panel">
<div class="info-header">
<h3>{networkStore.selectedNode.name}</h3>
<button class="close-btn" onclick={() => networkStore.selectNode(null)}>
<button
class="close-btn"
onclick={() => networkStore.selectNode(null)}
aria-label="Schließen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"

View file

@ -516,105 +516,6 @@
color: hsl(var(--color-muted-foreground));
}
/* Language options */
.locale-options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.locale-option {
padding: 0.5rem 1rem;
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
cursor: pointer;
transition: all 150ms ease;
}
.locale-option:hover {
border-color: hsl(var(--color-primary) / 0.5);
}
.locale-option.active {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
/* Theme options */
.theme-options {
display: flex;
gap: 0.5rem;
}
.theme-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background: transparent;
color: hsl(var(--color-foreground));
font-size: 0.875rem;
cursor: pointer;
transition: all 150ms ease;
}
.theme-option:hover {
border-color: hsl(var(--color-primary) / 0.5);
}
.theme-option.active {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.theme-option .icon {
width: 1.25rem;
height: 1.25rem;
}
/* Variant grid */
.variant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.5rem;
}
.variant-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.75rem;
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background: transparent;
cursor: pointer;
transition: all 150ms ease;
}
.variant-option:hover {
border-color: hsl(var(--color-primary) / 0.5);
}
.variant-option.active {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.variant-icon {
font-size: 1.5rem;
}
.variant-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
/* Select input */
.select-input {
width: 100%;

View file

@ -1,5 +1,7 @@
<script lang="ts">
import '../app.css';
// Initialize i18n early - must be imported before any component that uses $_
import { waitLocale } from '$lib/i18n';
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
@ -11,6 +13,9 @@
let loading = $state(true);
onMount(async () => {
// Wait for i18n locale to be loaded
await waitLocale();
// Initialize theme
theme.initialize();

View file

@ -137,7 +137,8 @@ export interface CalendarEventWithCalendar extends CalendarEvent {
* Data required to create a new event
*/
export interface CreateEventInput {
calendarId: string;
/** Calendar ID. If not provided, the default calendar will be used (or created if none exists) */
calendarId?: string;
title: string;
description?: string;
location?: string;

View file

@ -0,0 +1,374 @@
<script lang="ts">
import { networkStore } from '$lib/stores/network.svelte';
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X } from 'lucide-svelte';
interface Props {
onZoomIn: () => void;
onZoomOut: () => void;
onResetZoom: () => void;
}
let { onZoomIn, onZoomOut, onResetZoom }: Props = $props();
let searchInput = $state(networkStore.searchQuery);
let showFilters = $state(false);
function handleSearchInput(event: Event) {
const target = event.target as HTMLInputElement;
searchInput = target.value;
networkStore.setSearch(target.value);
}
function clearSearch() {
searchInput = '';
networkStore.setSearch('');
}
function handleTagChange(event: Event) {
const target = event.target as HTMLSelectElement;
networkStore.setFilterTag(target.value || null);
}
function handleCompanyChange(event: Event) {
const target = event.target as HTMLSelectElement;
networkStore.setFilterCompany(target.value || null);
}
function clearAllFilters() {
searchInput = '';
networkStore.clearFilters();
}
const hasActiveFilters = $derived(
networkStore.searchQuery || networkStore.filterTagId || networkStore.filterCompany
);
</script>
<div class="network-controls">
<!-- Search bar -->
<div class="search-container">
<Search size={18} class="search-icon" />
<input
type="text"
placeholder="Kontakt suchen..."
value={searchInput}
oninput={handleSearchInput}
class="search-input"
/>
{#if searchInput}
<button onclick={clearSearch} class="clear-btn" aria-label="Suche löschen">
<X size={16} />
</button>
{/if}
</div>
<!-- Filter toggle -->
<button
onclick={() => (showFilters = !showFilters)}
class="control-btn"
class:active={showFilters || hasActiveFilters}
aria-label="Filter anzeigen"
title="Filter"
>
<Filter size={18} />
{#if hasActiveFilters}
<span class="filter-badge"></span>
{/if}
</button>
<!-- Zoom controls -->
<div class="zoom-controls">
<button onclick={onZoomIn} class="control-btn" aria-label="Vergrößern" title="Vergrößern">
<ZoomIn size={18} />
</button>
<button onclick={onZoomOut} class="control-btn" aria-label="Verkleinern" title="Verkleinern">
<ZoomOut size={18} />
</button>
<button
onclick={onResetZoom}
class="control-btn"
aria-label="Ansicht zurücksetzen"
title="Zurücksetzen"
>
<RotateCcw size={18} />
</button>
</div>
<!-- Stats -->
<div class="stats">
<span class="stat">
{networkStore.nodes.length} Kontakte
</span>
<span class="stat-divider"></span>
<span class="stat">
{networkStore.links.length} Verbindungen
</span>
</div>
</div>
<!-- Filter panel -->
{#if showFilters}
<div class="filter-panel">
<div class="filter-row">
<!-- Tag filter -->
<div class="filter-group">
<label for="tag-filter" class="filter-label">Tag</label>
<select
id="tag-filter"
onchange={handleTagChange}
value={networkStore.filterTagId || ''}
class="filter-select"
>
<option value="">Alle Tags</option>
{#each networkStore.uniqueTags as tag}
<option value={tag.id}>
{tag.name}
</option>
{/each}
</select>
</div>
<!-- Company filter -->
<div class="filter-group">
<label for="company-filter" class="filter-label">Firma</label>
<select
id="company-filter"
onchange={handleCompanyChange}
value={networkStore.filterCompany || ''}
class="filter-select"
>
<option value="">Alle Firmen</option>
{#each networkStore.uniqueCompanies as company}
<option value={company}>
{company}
</option>
{/each}
</select>
</div>
<!-- Clear filters button -->
{#if hasActiveFilters}
<button onclick={clearAllFilters} class="clear-filters-btn">
<X size={14} />
Filter löschen
</button>
{/if}
</div>
</div>
{/if}
<style>
.network-controls {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 9999px;
flex-wrap: wrap;
}
.search-container {
position: relative;
flex: 1;
min-width: 200px;
max-width: 300px;
}
.search-container :global(.search-icon) {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.5rem 2rem 0.5rem 2.5rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.search-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
}
.search-input::placeholder {
color: hsl(var(--muted-foreground));
}
.clear-btn {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
padding: 0.25rem;
background: none;
border: none;
color: hsl(var(--muted-foreground));
cursor: pointer;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted));
}
.control-btn {
position: relative;
padding: 0.5rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
color: hsl(var(--muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.control-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.control-btn.active {
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary));
color: hsl(var(--primary));
}
.filter-badge {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: hsl(var(--primary));
border-radius: 50%;
}
.zoom-controls {
display: flex;
gap: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid hsl(var(--border));
}
.stats {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.stat-divider {
opacity: 0.5;
}
/* Filter panel */
.filter-panel {
margin-top: 0.5rem;
padding: 0.75rem 1rem;
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 1rem;
}
.filter-row {
display: flex;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 150px;
}
.filter-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.filter-select {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: hsl(var(--primary));
}
.clear-filters-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.2);
border-radius: 0.5rem;
color: hsl(var(--destructive));
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.clear-filters-btn:hover {
background: hsl(var(--destructive) / 0.15);
}
@media (max-width: 640px) {
.network-controls {
flex-direction: column;
align-items: stretch;
}
.search-container {
max-width: none;
}
.zoom-controls {
padding-left: 0;
border-left: none;
justify-content: center;
}
.stats {
justify-content: center;
margin-left: 0;
}
}
</style>

View file

@ -0,0 +1,492 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import {
networkStore,
type SimulationNode,
type SimulationLink,
} from '$lib/stores/network.svelte';
import { zoom, zoomIdentity, type ZoomBehavior } from 'd3-zoom';
import { select } from 'd3-selection';
interface Props {
width?: number;
height?: number;
onNodeClick?: (node: SimulationNode) => void;
}
let { width = 800, height = 600, onNodeClick }: Props = $props();
let svgElement: SVGSVGElement;
let containerElement: HTMLDivElement;
let zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> | null = null;
let transform = $state({ x: 0, y: 0, k: 1 });
let draggedNode: SimulationNode | null = null;
let resizeObserver: ResizeObserver | null = null;
let containerWidth = $state(0);
let containerHeight = $state(0);
let hasInitialized = $state(false);
let initTimeoutId: ReturnType<typeof setTimeout> | null = null;
// Initialize simulation ONCE when nodes are loaded AND dimensions are stable
function tryInitialize() {
const nodeCount = networkStore.allNodes.length;
if (!hasInitialized && nodeCount > 0 && containerWidth > 100 && containerHeight > 100) {
console.log(
'[NetworkGraph] Initializing with dimensions:',
containerWidth,
'x',
containerHeight
);
hasInitialized = true;
networkStore.initSimulation(containerWidth, containerHeight);
}
}
// Try to initialize when nodes become available
$effect(() => {
const nodeCount = networkStore.allNodes.length;
if (nodeCount > 0 && containerWidth > 100 && containerHeight > 100) {
tryInitialize();
}
});
// Get nodes and links (these will update on each tick)
const graphNodes = $derived(networkStore.nodes);
const graphLinks = $derived(networkStore.links);
// Setup zoom behavior
$effect(() => {
if (svgElement) {
zoomBehavior = zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
transform = {
x: event.transform.x,
y: event.transform.y,
k: event.transform.k,
};
});
select(svgElement).call(zoomBehavior);
}
});
onMount(() => {
// Setup resize observer - wait for stable dimensions before initializing
if (containerElement) {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newWidth = entry.contentRect.width;
const newHeight = entry.contentRect.height;
if (newWidth > 100 && newHeight > 100) {
containerWidth = newWidth;
containerHeight = newHeight;
// Debounce initialization to wait for layout to stabilize
if (!hasInitialized) {
if (initTimeoutId) clearTimeout(initTimeoutId);
initTimeoutId = setTimeout(() => {
console.log(
'[NetworkGraph] Stable dimensions:',
containerWidth,
'x',
containerHeight
);
tryInitialize();
}, 100);
}
}
}
});
resizeObserver.observe(containerElement);
}
});
onDestroy(() => {
if (initTimeoutId) clearTimeout(initTimeoutId);
networkStore.reset();
resizeObserver?.disconnect();
});
function handleNodeClick(node: SimulationNode) {
networkStore.selectNode(node.id);
onNodeClick?.(node);
}
function handleNodeDoubleClick(node: SimulationNode) {
// Navigate to contact detail
goto(`/contacts/${node.id}`);
}
function handleDragStart(event: MouseEvent, node: SimulationNode) {
event.stopPropagation();
draggedNode = node;
networkStore.fixNode(node.id, node.x ?? 0, node.y ?? 0);
networkStore.reheatSimulation();
}
function handleDrag(event: MouseEvent) {
if (!draggedNode) return;
// Convert screen coordinates to graph coordinates
const x = (event.clientX - svgElement.getBoundingClientRect().left - transform.x) / transform.k;
const y = (event.clientY - svgElement.getBoundingClientRect().top - transform.y) / transform.k;
networkStore.fixNode(draggedNode.id, x, y);
}
function handleDragEnd() {
if (draggedNode) {
networkStore.releaseNode(draggedNode.id);
draggedNode = null;
}
}
function resetZoom() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(300).call(zoomBehavior.transform, zoomIdentity);
}
}
function zoomIn() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 1.3);
}
}
function zoomOut() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 0.7);
}
}
// Helper to get node initials
function getInitials(name: string): string {
const parts = name.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
// Helper to generate consistent color from string
function stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = hash % 360;
return `hsl(${hue}, 70%, 50%)`;
}
// Get link coordinates
function getLinkCoords(link: SimulationLink) {
const source = link.source as SimulationNode;
const target = link.target as SimulationNode;
return {
x1: source.x ?? 0,
y1: source.y ?? 0,
x2: target.x ?? 0,
y2: target.y ?? 0,
};
}
// Check if a node is connected to selected node
function isConnectedToSelected(nodeId: string, links: typeof graphLinks): boolean {
if (!networkStore.selectedNodeId) return false;
if (nodeId === networkStore.selectedNodeId) return true;
return links.some((link) => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
return (
(sourceId === networkStore.selectedNodeId && targetId === nodeId) ||
(targetId === networkStore.selectedNodeId && sourceId === nodeId)
);
});
}
// Export zoom functions for parent component
export { resetZoom, zoomIn, zoomOut };
</script>
<div
bind:this={containerElement}
class="network-graph-container"
onmousemove={handleDrag}
onmouseup={handleDragEnd}
onmouseleave={handleDragEnd}
role="application"
aria-label="Kontakt-Netzwerk Graph"
>
<svg bind:this={svgElement} class="network-graph-svg" style="width: 100%; height: 100%;">
<g transform="translate({transform.x}, {transform.y}) scale({transform.k})">
<!-- Links -->
<g class="links">
{#each graphLinks as link}
{@const coords = getLinkCoords(link)}
{@const sourceId = typeof link.source === 'string' ? link.source : link.source.id}
{@const targetId = typeof link.target === 'string' ? link.target : link.target.id}
{@const isHighlighted =
networkStore.selectedNodeId &&
(sourceId === networkStore.selectedNodeId || targetId === networkStore.selectedNodeId)}
<line
x1={coords.x1}
y1={coords.y1}
x2={coords.x2}
y2={coords.y2}
stroke-width={Math.max(1, link.strength / 25)}
class="link"
class:highlighted={isHighlighted}
class:dimmed={networkStore.selectedNodeId && !isHighlighted}
>
<title>{link.sharedTags.join(', ')}</title>
</line>
{/each}
</g>
<!-- Nodes -->
<g class="nodes">
{#each graphNodes as node (node.id)}
{@const isSelected = node.id === networkStore.selectedNodeId}
{@const isConnected = isConnectedToSelected(node.id, graphLinks)}
{@const isDimmed = networkStore.selectedNodeId && !isConnected}
<g
transform="translate({node.x ?? 0}, {node.y ?? 0})"
class="node"
class:selected={isSelected}
class:connected={isConnected && !isSelected}
class:dimmed={isDimmed}
onmousedown={(e) => handleDragStart(e, node)}
onclick={() => handleNodeClick(node)}
ondblclick={() => handleNodeDoubleClick(node)}
role="button"
tabindex="0"
aria-label={node.name}
>
<!-- Node circle -->
<circle r={isSelected ? 28 : 24} fill={stringToColor(node.name)} class="node-circle" />
<!-- Avatar image or initials -->
{#if node.photoUrl}
<clipPath id="clip-{node.id}">
<circle r={isSelected ? 26 : 22} />
</clipPath>
<image
href={node.photoUrl}
x={isSelected ? -26 : -22}
y={isSelected ? -26 : -22}
width={isSelected ? 52 : 44}
height={isSelected ? 52 : 44}
clip-path="url(#clip-{node.id})"
preserveAspectRatio="xMidYMid slice"
/>
{:else}
<text
class="node-initials"
text-anchor="middle"
dominant-baseline="central"
fill="white"
font-size={isSelected ? 14 : 12}
font-weight="600"
>
{getInitials(node.name)}
</text>
{/if}
<!-- Favorite indicator -->
{#if node.isFavorite}
<circle
cx={isSelected ? 20 : 17}
cy={isSelected ? -20 : -17}
r="8"
fill="hsl(var(--background))"
/>
<text
x={isSelected ? 20 : 17}
y={isSelected ? -20 : -17}
text-anchor="middle"
dominant-baseline="central"
font-size="10"
>
</text>
{/if}
<!-- Connection count badge -->
{#if node.connectionCount > 0}
<circle
cx={isSelected ? -20 : -17}
cy={isSelected ? -20 : -17}
r="10"
fill="hsl(var(--primary))"
/>
<text
x={isSelected ? -20 : -17}
y={isSelected ? -20 : -17}
text-anchor="middle"
dominant-baseline="central"
fill="white"
font-size="9"
font-weight="600"
>
{node.connectionCount}
</text>
{/if}
<!-- Node label -->
<text
y={isSelected ? 42 : 38}
class="node-label"
text-anchor="middle"
font-size={isSelected ? 13 : 11}
font-weight={isSelected ? '600' : '500'}
>
{node.name}
</text>
<!-- Company label -->
{#if node.company}
<text
y={isSelected ? 56 : 50}
class="node-company"
text-anchor="middle"
font-size="9"
>
{node.company}
</text>
{/if}
</g>
{/each}
</g>
</g>
</svg>
<!-- Empty state -->
{#if graphNodes.length === 0 && !networkStore.loading}
<div class="empty-state">
<div class="empty-icon">🔗</div>
<p class="empty-title">Keine Verbindungen gefunden</p>
<p class="empty-description">
Kontakte werden verbunden, wenn sie gemeinsame Tags haben. Füge Tags zu deinen Kontakten
hinzu, um das Netzwerk zu sehen.
</p>
</div>
{/if}
</div>
<style>
.network-graph-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: hsl(var(--background));
}
.network-graph-svg {
display: block;
cursor: grab;
}
.network-graph-svg:active {
cursor: grabbing;
}
/* Links */
.link {
stroke: hsl(var(--muted-foreground) / 0.3);
transition:
stroke 0.2s,
stroke-width 0.2s,
opacity 0.2s;
}
.link.highlighted {
stroke: hsl(var(--primary));
stroke-width: 3 !important;
}
.link.dimmed {
opacity: 0.1;
}
/* Nodes */
.node {
cursor: pointer;
transition: opacity 0.2s;
}
.node:hover .node-circle {
filter: brightness(1.1);
}
.node.selected .node-circle {
stroke: hsl(var(--primary));
stroke-width: 4;
}
.node.connected .node-circle {
stroke: hsl(var(--primary) / 0.5);
stroke-width: 2;
}
.node.dimmed {
opacity: 0.3;
}
.node-circle {
transition:
r 0.2s,
stroke 0.2s,
stroke-width 0.2s,
filter 0.2s;
}
.node-initials {
pointer-events: none;
user-select: none;
}
.node-label {
fill: hsl(var(--foreground));
pointer-events: none;
user-select: none;
}
.node-company {
fill: hsl(var(--muted-foreground));
pointer-events: none;
user-select: none;
}
/* Empty state */
.empty-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
padding: 2rem;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.5rem;
}
.empty-description {
color: hsl(var(--muted-foreground));
max-width: 300px;
line-height: 1.5;
}
</style>

View file

@ -1,5 +1,5 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL, sql } from 'drizzle-orm';
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL, sql, inArray } from 'drizzle-orm';
import { RRule, RRuleSet, rrulestr } from 'rrule';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
@ -125,6 +125,11 @@ export class TaskService {
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
dueTime: dto.dueTime,
startDate: dto.startDate ? new Date(dto.startDate) : null,
// Time-Blocking fields
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
scheduledStartTime: dto.scheduledStartTime,
scheduledEndTime: dto.scheduledEndTime,
estimatedDuration: dto.estimatedDuration,
priority: dto.priority ?? 'medium',
recurrenceRule: dto.recurrenceRule,
recurrenceEndDate: dto.recurrenceEndDate ? new Date(dto.recurrenceEndDate) : null,
@ -162,6 +167,12 @@ export class TaskService {
: dto.startDate === null
? null
: undefined,
// Time-Blocking fields
scheduledDate: dto.scheduledDate
? new Date(dto.scheduledDate)
: dto.scheduledDate === null
? null
: undefined,
recurrenceEndDate: dto.recurrenceEndDate
? new Date(dto.recurrenceEndDate)
: dto.recurrenceEndDate === null
@ -477,10 +488,13 @@ export class TaskService {
}
async getUpcomingTasks(userId: string, days: number = 7): Promise<TaskWithLabels[]> {
// Ensure days is a valid number
const daysNum = typeof days === 'number' && !isNaN(days) ? days : 7;
const today = new Date();
today.setHours(0, 0, 0, 0);
const endDate = new Date(today);
endDate.setDate(endDate.getDate() + days);
const endDate = new Date(today.getTime());
endDate.setDate(endDate.getDate() + daysNum);
const result = await this.db.query.tasks.findMany({
where: and(
@ -568,10 +582,11 @@ export class TaskService {
const taskIds = taskList.map((t) => t.id);
// Single query to get all task-label relationships
const allTaskLabels = await this.db.query.taskLabels.findMany({
where: or(...taskIds.map((id) => eq(taskLabels.taskId, id))),
});
// Single query to get all task-label relationships using inArray
const allTaskLabels = await this.db
.select()
.from(taskLabels)
.where(inArray(taskLabels.taskId, taskIds));
if (allTaskLabels.length === 0) {
// No labels for any task - return tasks with empty labels array
@ -581,10 +596,8 @@ export class TaskService {
// Get unique label IDs
const uniqueLabelIds = [...new Set(allTaskLabels.map((tl) => tl.labelId))];
// Single query to get all labels
const allLabels = await this.db.query.labels.findMany({
where: or(...uniqueLabelIds.map((id) => eq(labels.id, id))),
});
// Single query to get all labels using inArray
const allLabels = await this.db.select().from(labels).where(inArray(labels.id, uniqueLabelIds));
// Create a map of labelId -> label for fast lookup
const labelMap = new Map(allLabels.map((l) => [l.id, l]));

View file

@ -14,13 +14,21 @@ interface CreateTaskDto {
interface UpdateTaskDto {
title?: string;
description?: string;
description?: string | null;
projectId?: string | null;
parentTaskId?: string | null;
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
priority?: TaskPriority;
status?: TaskStatus;
subtasks?: Subtask[];
isCompleted?: boolean;
order?: number;
subtasks?: Subtask[] | null;
recurrenceRule?: string | null;
recurrenceEndDate?: string | null;
metadata?: Record<string, unknown> | null;
labelIds?: string[];
}
interface TaskQuery {

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import type { KanbanColumn, Task, UpdateTaskInput } from '@todo/shared';
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID, type DndEvent } from 'svelte-dnd-action';
import type { KanbanColumn, Task } from '@todo/shared';
import KanbanTaskCard from './KanbanTaskCard.svelte';
import KanbanColumnHeader from './KanbanColumnHeader.svelte';
import QuickAddTaskInline from './QuickAddTaskInline.svelte';
@ -36,13 +36,11 @@
const flipDurationMs = 200;
function handleDndConsider(e: CustomEvent<{ items: Task[] }>) {
function handleDndConsider(e: CustomEvent<DndEvent<Task>>) {
localTasks = e.detail.items;
}
function handleDndFinalize(
e: CustomEvent<{ items: Task[]; info: { id: string; source: { items: Task[] } } }>
) {
function handleDndFinalize(e: CustomEvent<DndEvent<Task>>) {
const newItems = e.detail.items.filter((t) => t.id !== SHADOW_PLACEHOLDER_ITEM_ID);
const movedTaskId = e.detail.info.id;
@ -71,20 +69,21 @@
}
}
async function handleSaveTask(task: Task, data: UpdateTaskInput) {
// Transform data to match updateTask API (convert null to undefined)
const updateData: UpdateTaskInput = {};
async function handleSaveTask(task: Task, data: Partial<Task>) {
// Transform Partial<Task> to updateTask format
const updateData: Record<string, unknown> = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description ?? undefined;
if (data.description !== undefined) updateData.description = data.description;
if (data.projectId !== undefined) updateData.projectId = data.projectId;
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate ?? undefined;
if (data.dueDate !== undefined) {
updateData.dueDate = data.dueDate instanceof Date ? data.dueDate.toISOString() : data.dueDate;
}
if (data.priority !== undefined) updateData.priority = data.priority;
if (data.status !== undefined) updateData.status = data.status;
if (data.subtasks !== undefined) updateData.subtasks = data.subtasks ?? undefined;
if (data.recurrenceRule !== undefined)
updateData.recurrenceRule = data.recurrenceRule ?? undefined;
if (data.subtasks !== undefined) updateData.subtasks = data.subtasks;
if (data.recurrenceRule !== undefined) updateData.recurrenceRule = data.recurrenceRule;
if (data.metadata !== undefined) updateData.metadata = data.metadata;
if (data.labelIds !== undefined) updateData.labelIds = data.labelIds;
if (data.labels !== undefined) updateData.labelIds = data.labels?.map((l) => l.id);
await tasksStore.updateTask(task.id, updateData);
}

View file

@ -0,0 +1,23 @@
/**
* Feedback Service Instance for Todo Web App
*/
import { browser } from '$app/environment';
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
export const feedbackService = createFeedbackService({
apiUrl: getAuthUrl(),
appId: 'todo',
getAuthToken: async () => authStore.getAccessToken(),
});

View file

@ -217,13 +217,21 @@ export const tasksStore = {
id: string,
data: {
title?: string;
description?: string;
description?: string | null;
projectId?: string | null;
parentTaskId?: string | null;
dueDate?: string | null;
dueTime?: string | null;
startDate?: string | null;
priority?: TaskPriority;
status?: TaskStatus;
subtasks?: Subtask[];
isCompleted?: boolean;
order?: number;
subtasks?: Subtask[] | null;
recurrenceRule?: string | null;
recurrenceEndDate?: string | null;
metadata?: { [key: string]: unknown } | null;
labelIds?: string[];
}
) {
error = null;

View file

@ -100,8 +100,12 @@
if (!editingTask) return;
try {
// Update task
await tasksStore.updateTask(editingTask.id, data);
// Update task - cast metadata to be compatible with store type
const updateData = {
...data,
metadata: data.metadata as { [key: string]: unknown } | null | undefined,
};
await tasksStore.updateTask(editingTask.id, updateData);
// Update labels if provided
if (data.labelIds !== undefined) {

View file

@ -1,22 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { FeedbackPage } from '@manacore/shared-feedback-ui';
onMount(() => {
if (!authStore.isAuthenticated) {
goto('/login');
}
});
import { feedbackService } from '$lib/services/feedback';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<svelte:head>
<title>Feedback | Todo</title>
</svelte:head>
<FeedbackPage
appName="Todo"
userEmail={authStore.user?.email || undefined}
primaryColor="#8b5cf6"
/>
<FeedbackPage {feedbackService} appName="Todo" currentUserId={authStore.user?.id} />

View file

@ -11,20 +11,20 @@
// Get translations based on current locale
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
async function handleResetPassword(email: string) {
async function handleForgotPassword(email: string) {
return authStore.resetPassword(email);
}
</script>
<svelte:head>
<title>{translations.title} | Todo</title>
<title>{translations.titleForm} | Todo</title>
</svelte:head>
<ForgotPasswordPage
appName="Todo"
logo={TodoLogo}
primaryColor="#8b5cf6"
onResetPassword={handleResetPassword}
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
lightBackground="#f3e8ff"

View file

@ -26,8 +26,6 @@
primaryColor="#8b5cf6"
onSignUp={handleSignUp}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect="/"
loginPath="/login"
lightBackground="#f3e8ff"

View file

@ -110,8 +110,6 @@
color: hsl(var(--color-foreground, 0 0% 17%));
}
.feedback-form__input,
.feedback-form__select,
.feedback-form__textarea {
padding: 0.75rem;
border: 1px solid hsl(var(--color-border, 0 0% 90%));
@ -126,8 +124,6 @@
color: hsl(var(--color-muted-foreground, 0 0% 40%));
}
.feedback-form__input:focus,
.feedback-form__select:focus,
.feedback-form__textarea:focus {
outline: none;
border-color: hsl(var(--color-primary, 47 95% 58%));

View file

@ -164,10 +164,7 @@
border-bottom: none;
}
/* Body */
.card__body {
/* Padding applied via variant classes above */
}
/* Body - padding applied via variant classes above */
/* Footer */
.card__footer {

View file

@ -102,7 +102,7 @@
let creating = $state(false);
let selectedIndex = $state(0);
let searchTimeout: ReturnType<typeof setTimeout>;
let inputElement: HTMLInputElement;
let inputElement = $state<HTMLInputElement | null>(null);
// Computed create preview
let createPreview = $derived(
@ -260,6 +260,7 @@
role="dialog"
aria-modal="true"
aria-label="Suchen"
tabindex="-1"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>

View file

@ -80,6 +80,7 @@
const isClickable = $derived(interactive || !!onclick);
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="data-card rounded-xl p-4 transition-colors {variantClasses[variant]} {isClickable
? 'cursor-pointer hover:bg-menu-hover'
@ -153,6 +154,7 @@
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

View file

@ -5,6 +5,7 @@
value: string;
oninput?: (value: string) => void;
onchange?: (value: string) => void;
onkeydown?: (e: KeyboardEvent) => void;
label?: string;
placeholder?: string;
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
@ -21,6 +22,7 @@
value = $bindable(),
oninput,
onchange,
onkeydown,
label,
placeholder,
type = 'text',
@ -66,6 +68,7 @@
autocomplete={autocomplete as HTMLInputAttributes['autocomplete']}
oninput={handleInput}
onchange={handleChange}
{onkeydown}
class="w-full rounded-lg border px-4 py-2.5 text-theme bg-content transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed {error
? 'border-red-500 focus:ring-red-500/50'
: 'border-theme'}"

View file

@ -10,7 +10,7 @@
/** Alternative name field (for compatibility) */
text?: string;
/** Tag color (hex) */
color?: string;
color?: string | null;
/** Nested style object with color */
style?: { color?: string };
}
@ -55,36 +55,64 @@
}
</script>
<span
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium transition-all"
class:cursor-pointer={clickable}
class:hover:scale-105={clickable}
style="background-color: {tagColor}20; color: {tagColor}"
onclick={handleClick}
onkeydown={handleKeyDown}
role={clickable ? 'button' : undefined}
tabindex={clickable ? 0 : undefined}
>
<!-- Color indicator dot -->
<div class="h-2 w-2 rounded-full" style="background-color: {tagColor}"></div>
{#if clickable}
<span
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium transition-all cursor-pointer hover:scale-105"
style="background-color: {tagColor}20; color: {tagColor}"
onclick={handleClick}
onkeydown={handleKeyDown}
role="button"
tabindex="0"
>
<!-- Color indicator dot -->
<div class="h-2 w-2 rounded-full" style="background-color: {tagColor}"></div>
<span>{tagName}</span>
<span>{tagName}</span>
{#if removable}
<button
onclick={handleRemove}
class="ml-1 rounded-full hover:bg-black/10 p-0.5 transition-colors"
type="button"
aria-label="Remove tag"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</span>
{#if removable}
<button
onclick={handleRemove}
class="ml-1 rounded-full hover:bg-black/10 p-0.5 transition-colors"
type="button"
aria-label="Remove tag"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</span>
{:else}
<span
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium"
style="background-color: {tagColor}20; color: {tagColor}"
>
<!-- Color indicator dot -->
<div class="h-2 w-2 rounded-full" style="background-color: {tagColor}"></div>
<span>{tagName}</span>
{#if removable}
<button
onclick={handleRemove}
class="ml-1 rounded-full hover:bg-black/10 p-0.5 transition-colors"
type="button"
aria-label="Remove tag"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</span>
{/if}

View file

@ -79,22 +79,22 @@
<div class="space-y-6">
<!-- Name Input -->
<div>
<Input bind:value={name} placeholder={namePlaceholder} onkeydown={handleKeyDown} autofocus />
<Input bind:value={name} placeholder={namePlaceholder} onkeydown={handleKeyDown} />
</div>
<!-- Color Picker -->
<div>
<label class="block text-sm font-medium text-muted-foreground mb-3">
<span class="block text-sm font-medium text-muted-foreground mb-3">
{colorLabel}
</label>
</span>
<TagColorPicker selectedColor={color} onColorChange={(c) => (color = c)} />
</div>
<!-- Preview -->
<div>
<label class="block text-sm font-medium text-muted-foreground mb-3">
<span class="block text-sm font-medium text-muted-foreground mb-3">
{previewLabel}
</label>
</span>
<div class="flex items-center gap-2">
<TagBadge tag={previewTag} />
</div>

View file

@ -83,6 +83,7 @@
<div class={layout === 'grid' ? gridClasses : listClasses}>
{#each tags as tag (tag.id)}
{@const color = getTagColor(tag)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="
group relative flex items-center gap-3 p-4

View file

@ -32,7 +32,7 @@ export interface Tag {
export interface TagData {
name?: string;
text?: string;
color?: string;
color?: string | null;
style?: { color?: string };
}

View file

@ -148,7 +148,11 @@
{#if open}
<!-- Backdrop -->
<button class="menu-backdrop" onclick={close} onkeydown={(e) => e.key === 'Escape' && close()}
<button
class="menu-backdrop"
onclick={close}
onkeydown={(e) => e.key === 'Escape' && close()}
aria-label="Close dropdown"
></button>
<!-- Dropdown items -->

View file

@ -467,7 +467,8 @@
{:else if item.iconSvg}
{@html item.iconSvg}
{:else if phosphorIcons[item.icon]}
<svelte:component this={phosphorIcons[item.icon]} size={18} class="pill-icon" />
{@const IconComponent = phosphorIcons[item.icon]}
<IconComponent size={18} class="pill-icon" />
{:else}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -500,7 +501,8 @@
<a href={element.href} class="pill glass-pill" class:active={isActive(element.href)}>
{#if element.icon}
{#if phosphorIcons[element.icon]}
<svelte:component this={phosphorIcons[element.icon]} size={18} class="pill-icon" />
{@const IconComponent = phosphorIcons[element.icon]}
<IconComponent size={18} class="pill-icon" />
{:else}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -1206,9 +1208,7 @@
min-height: 1rem;
}
.sidebar-container .toggle-pill {
margin-top: auto;
}
/* Note: .toggle-pill class may be applied dynamically */
/* Segmented control */
.segmented-control {

View file

@ -84,6 +84,7 @@
</script>
<Modal {visible} {onClose} {title} {icon} {maxWidth}>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form onsubmit={handleSubmit} onkeydown={handleKeydown} class="space-y-4">
<!-- Error message -->
{#if error}

View file

@ -256,6 +256,7 @@
export { resetZoom, zoomIn, zoomOut, focusOnSelectedNode };
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={containerElement}
class="network-graph-container"
@ -265,11 +266,14 @@
role="application"
aria-label="Network Graph"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<svg
bind:this={svgElement}
class="network-graph-svg"
style="width: 100%; height: 100%;"
onclick={handleBackgroundClick}
role="img"
aria-label="Network graph visualization"
>
<g transform="translate({transform.x}, {transform.y}) scale({transform.k})">
<!-- Links -->
@ -280,6 +284,7 @@
{@const targetId = typeof link.target === 'string' ? link.target : link.target.id}
{@const isHighlighted =
selectedNodeId && (sourceId === selectedNodeId || targetId === selectedNodeId)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- Invisible wider line for easier hover -->
<line
x1={coords.x1}

View file

@ -156,6 +156,7 @@
<!-- Modal -->
{#if selectedAppIndex !== null}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="modal-overlay" onclick={closeModal} role="dialog" aria-modal="true" tabindex="-1">
<button onclick={closeModal} class="modal-close-btn" aria-label="Close modal">
<svg
@ -383,6 +384,7 @@
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

View file

@ -209,6 +209,7 @@
: 'bg-gray-200 dark:bg-gray-700'}"
onclick={() =>
handleSidebarChange(!userSettings.globalSettings.nav.sidebarCollapsed)}
aria-label="Toggle sidebar collapsed state"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {userSettings
@ -416,6 +417,7 @@
? 'bg-[hsl(var(--primary))]'
: 'bg-gray-200 dark:bg-gray-700'}"
onclick={() => handleSoundsChange(!(userSettings.general?.soundsEnabled ?? true))}
aria-label="Toggle sound effects"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {(userSettings

View file

@ -28,10 +28,8 @@
// Check if there are any routes to configure
const hasRoutes = $derived(hideableItems.length > 0);
function isRouteHidden(href: string): boolean {
const hidden = userSettings.getHiddenNavItemsForApp(appId);
return hidden.includes(href);
}
// Reactive: get hidden items from nav settings (triggers re-render when hiddenNavItems changes)
const hiddenItems = $derived(userSettings.nav.hiddenNavItems?.[appId] || []);
async function handleToggle(href: string): Promise<void> {
await userSettings.toggleNavItemVisibility(appId, href);
@ -129,7 +127,7 @@
<div class="space-y-1">
{#each hideableItems as item (item.href)}
{@const hidden = isRouteHidden(item.href)}
{@const hidden = hiddenItems.includes(item.href)}
{@const iconPath = item.icon ? getIconPath(item.icon) : ''}
<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))]"