diff --git a/COMMANDS.md b/COMMANDS.md index b64107c1f..5b5e201ee 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -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 diff --git a/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts b/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts index 70a7fbee0..f1778fb84 100644 --- a/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts +++ b/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts @@ -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) diff --git a/apps/calendar/apps/backend/src/event/event.service.ts b/apps/calendar/apps/backend/src/event/event.service.ts index 91eeb3ac4..6e80e89cc 100644 --- a/apps/calendar/apps/backend/src/event/event.service.ts +++ b/apps/calendar/apps/backend/src/event/event.service.ts @@ -85,11 +85,20 @@ export class EventService { } async create(userId: string, dto: CreateEventDto): Promise { - // 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, diff --git a/apps/calendar/apps/web/src/lib/api/base-client.ts b/apps/calendar/apps/web/src/lib/api/base-client.ts new file mode 100644 index 000000000..06f9ba7c7 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/api/base-client.ts @@ -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 { + 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(endpoint: string, options: FetchOptions = {}): Promise> { + 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 = {}; + + // 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 { + 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}` : ''; +} diff --git a/apps/calendar/apps/web/src/lib/api/client.ts b/apps/calendar/apps/web/src/lib/api/client.ts index 3e6a3b7e5..94103f722 100644 --- a/apps/calendar/apps/web/src/lib/api/client.ts +++ b/apps/calendar/apps/web/src/lib/api/client.ts @@ -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( 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 = {}; - - // 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> { + return calendarClient.fetchApi(endpoint, options); } + +// Re-export types for backwards compatibility +export type { FetchOptions, ApiResult }; diff --git a/apps/calendar/apps/web/src/lib/api/todos.ts b/apps/calendar/apps/web/src/lib/api/todos.ts index fa649d829..db72aac5d 100644 --- a/apps/calendar/apps/web/src/lib/api/todos.ts +++ b/apps/calendar/apps/web/src/lib/api/todos.ts @@ -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[]; @@ -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( - 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 = { - '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); const result = await fetchTodoApi(`/tasks${queryString}`); return { data: result.data?.tasks || null, diff --git a/apps/calendar/apps/web/src/lib/components/ToastContainer.svelte b/apps/calendar/apps/web/src/lib/components/ToastContainer.svelte index 733730d0b..8ef3a8637 100644 --- a/apps/calendar/apps/web/src/lib/components/ToastContainer.svelte +++ b/apps/calendar/apps/web/src/lib/components/ToastContainer.svelte @@ -1,16 +1,12 @@ +
DragDropConfig) { + // State + let isDragging = $state(false); + let draggedEvent = $state(null); + let dragOffsetMinutes = $state(0); + let dragTargetDay = $state(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, + }; +} diff --git a/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts new file mode 100644 index 000000000..44e8c511a --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts @@ -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(null); + let resizeEdge = $state<'top' | 'bottom'>('bottom'); + let resizeOriginalStart = $state(null); + let resizeOriginalEnd = $state(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, + }; +} diff --git a/apps/calendar/apps/web/src/lib/i18n/index.ts b/apps/calendar/apps/web/src/lib/i18n/index.ts index 00626549b..4928d3441 100644 --- a/apps/calendar/apps/web/src/lib/i18n/index.ts +++ b/apps/calendar/apps/web/src/lib/i18n/index.ts @@ -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); diff --git a/apps/calendar/apps/web/src/lib/i18n/locales/de.json b/apps/calendar/apps/web/src/lib/i18n/locales/de.json index 8eff3b65a..cd100cbe8 100644 --- a/apps/calendar/apps/web/src/lib/i18n/locales/de.json +++ b/apps/calendar/apps/web/src/lib/i18n/locales/de.json @@ -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", diff --git a/apps/calendar/apps/web/src/lib/i18n/locales/en.json b/apps/calendar/apps/web/src/lib/i18n/locales/en.json index a092895dc..f8f14805c 100644 --- a/apps/calendar/apps/web/src/lib/i18n/locales/en.json +++ b/apps/calendar/apps/web/src/lib/i18n/locales/en.json @@ -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", diff --git a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts index 736dc5f79..9578f7870 100644 --- a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts @@ -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([]); @@ -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; diff --git a/apps/calendar/apps/web/src/lib/stores/toast.svelte.ts b/apps/calendar/apps/web/src/lib/stores/toast.svelte.ts new file mode 100644 index 000000000..79775d554 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/toast.svelte.ts @@ -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([]); + +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; diff --git a/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts b/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts index 5c1f17957..4ac372ac0 100644 --- a/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/todos.svelte.ts @@ -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; diff --git a/apps/calendar/apps/web/src/lib/utils/eventDateHelpers.ts b/apps/calendar/apps/web/src/lib/utils/eventDateHelpers.ts new file mode 100644 index 000000000..9e8063377 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/utils/eventDateHelpers.ts @@ -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), + }; +} diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index d97272905..d321c5b31 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -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); diff --git a/apps/calendar/apps/web/src/routes/(app)/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/+page.svelte index 6707d9259..73373d0d3 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+page.svelte @@ -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(new Date()); + let editingEvent = $state(null); - // Event modal state (local state for reactivity) - let selectedEventId = $state(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 @@ - Kalender + {$_('app.name')}
@@ -106,7 +121,7 @@ @@ -141,7 +156,7 @@ -
- - {#if showQuickCreate} - - {/if} - - - {#if modalEventId} - + + {#if showQuickOverlay} + {#key overlayKey} + + {/key} {/if} diff --git a/apps/calendar/apps/web/src/routes/(app)/event/new/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/event/new/+page.svelte index ff5ff819d..fb375c300 100644 --- a/apps/calendar/apps/web/src/routes/(app)/event/new/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/event/new/+page.svelte @@ -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('/'); } diff --git a/apps/calendar/apps/web/src/routes/(app)/network/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/network/+page.svelte index 7b26f27d5..aaaa9860a 100644 --- a/apps/calendar/apps/web/src/routes/(app)/network/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/network/+page.svelte @@ -5,7 +5,7 @@ import { NetworkGraph, NetworkControls } from '@manacore/shared-ui'; import '$lib/i18n'; - let graphComponent: NetworkGraph; + let graphComponent = $state(null); let controlsComponent: NetworkControls; let graphContainer: HTMLDivElement; @@ -172,7 +172,11 @@

{networkStore.selectedNode.name}

- + {/if} +
+ + + + + +
+ + + +
+ + +
+ + {networkStore.nodes.length} Kontakte + + + + {networkStore.links.length} Verbindungen + +
+
+ + +{#if showFilters} +
+
+ +
+ + +
+ + +
+ + +
+ + + {#if hasActiveFilters} + + {/if} +
+
+{/if} + + diff --git a/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte b/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte new file mode 100644 index 000000000..af1ff0d97 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte @@ -0,0 +1,492 @@ + + +
+ + + + + {#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)} + + {link.sharedTags.join(', ')} + + {/each} + + + + + {#each graphNodes as node (node.id)} + {@const isSelected = node.id === networkStore.selectedNodeId} + {@const isConnected = isConnectedToSelected(node.id, graphLinks)} + {@const isDimmed = networkStore.selectedNodeId && !isConnected} + handleDragStart(e, node)} + onclick={() => handleNodeClick(node)} + ondblclick={() => handleNodeDoubleClick(node)} + role="button" + tabindex="0" + aria-label={node.name} + > + + + + + {#if node.photoUrl} + + + + + {:else} + + {getInitials(node.name)} + + {/if} + + + {#if node.isFavorite} + + + ⭐ + + {/if} + + + {#if node.connectionCount > 0} + + + {node.connectionCount} + + {/if} + + + + {node.name} + + + + {#if node.company} + + {node.company} + + {/if} + + {/each} + + + + + + {#if graphNodes.length === 0 && !networkStore.loading} +
+
🔗
+

Keine Verbindungen gefunden

+

+ Kontakte werden verbunden, wenn sie gemeinsame Tags haben. Füge Tags zu deinen Kontakten + hinzu, um das Netzwerk zu sehen. +

+
+ {/if} +
+ + diff --git a/apps/todo/apps/backend/src/task/task.service.ts b/apps/todo/apps/backend/src/task/task.service.ts index c5d681981..5de26a457 100644 --- a/apps/todo/apps/backend/src/task/task.service.ts +++ b/apps/todo/apps/backend/src/task/task.service.ts @@ -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 { + // 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])); diff --git a/apps/todo/apps/web/src/lib/api/tasks.ts b/apps/todo/apps/web/src/lib/api/tasks.ts index 8696ec407..85f126e84 100644 --- a/apps/todo/apps/web/src/lib/api/tasks.ts +++ b/apps/todo/apps/web/src/lib/api/tasks.ts @@ -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 | null; + labelIds?: string[]; } interface TaskQuery { diff --git a/apps/todo/apps/web/src/lib/components/kanban/KanbanColumn.svelte b/apps/todo/apps/web/src/lib/components/kanban/KanbanColumn.svelte index b3c216c74..8e366a5e2 100644 --- a/apps/todo/apps/web/src/lib/components/kanban/KanbanColumn.svelte +++ b/apps/todo/apps/web/src/lib/components/kanban/KanbanColumn.svelte @@ -1,6 +1,6 @@ Feedback | Todo - + diff --git a/apps/todo/apps/web/src/routes/(auth)/forgot-password/+page.svelte b/apps/todo/apps/web/src/routes/(auth)/forgot-password/+page.svelte index 671982a89..d1a66724d 100644 --- a/apps/todo/apps/web/src/routes/(auth)/forgot-password/+page.svelte +++ b/apps/todo/apps/web/src/routes/(auth)/forgot-password/+page.svelte @@ -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); } - {translations.title} | Todo + {translations.titleForm} | Todo ; - let inputElement: HTMLInputElement; + let inputElement = $state(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} > diff --git a/packages/shared-ui/src/molecules/DataCard.svelte b/packages/shared-ui/src/molecules/DataCard.svelte index 6f7049b17..d46c43d7f 100644 --- a/packages/shared-ui/src/molecules/DataCard.svelte +++ b/packages/shared-ui/src/molecules/DataCard.svelte @@ -80,6 +80,7 @@ const isClickable = $derived(interactive || !!onclick); +
- - -
+{#if clickable} + + +
- {tagName} + {tagName} - {#if removable} - - {/if} -
+ {#if removable} + + {/if} +
+{:else} + + +
+ + {tagName} + + {#if removable} + + {/if} +
+{/if} diff --git a/packages/shared-ui/src/molecules/tags/TagEditModal.svelte b/packages/shared-ui/src/molecules/tags/TagEditModal.svelte index 66e5dd99e..2560273b6 100644 --- a/packages/shared-ui/src/molecules/tags/TagEditModal.svelte +++ b/packages/shared-ui/src/molecules/tags/TagEditModal.svelte @@ -79,22 +79,22 @@
- +
- + (color = c)} />
- +
diff --git a/packages/shared-ui/src/molecules/tags/TagList.svelte b/packages/shared-ui/src/molecules/tags/TagList.svelte index cc9dd7d0c..6b57c117b 100644 --- a/packages/shared-ui/src/molecules/tags/TagList.svelte +++ b/packages/shared-ui/src/molecules/tags/TagList.svelte @@ -83,6 +83,7 @@
{#each tags as tag (tag.id)} {@const color = getTagColor(tag)} +
e.key === 'Escape' && close()} + diff --git a/packages/shared-ui/src/navigation/PillNavigation.svelte b/packages/shared-ui/src/navigation/PillNavigation.svelte index b3a41bd84..8aa8b0482 100644 --- a/packages/shared-ui/src/navigation/PillNavigation.svelte +++ b/packages/shared-ui/src/navigation/PillNavigation.svelte @@ -467,7 +467,8 @@ {:else if item.iconSvg} {@html item.iconSvg} {:else if phosphorIcons[item.icon]} - + {@const IconComponent = phosphorIcons[item.icon]} + {:else} {#if element.icon} {#if phosphorIcons[element.icon]} - + {@const IconComponent = phosphorIcons[element.icon]} + {:else} +
{#if error} diff --git a/packages/shared-ui/src/organisms/network/NetworkGraph.svelte b/packages/shared-ui/src/organisms/network/NetworkGraph.svelte index c01518888..527993061 100644 --- a/packages/shared-ui/src/organisms/network/NetworkGraph.svelte +++ b/packages/shared-ui/src/organisms/network/NetworkGraph.svelte @@ -256,6 +256,7 @@ export { resetZoom, zoomIn, zoomOut, focusOnSelectedNode }; +
+ @@ -280,6 +284,7 @@ {@const targetId = typeof link.target === 'string' ? link.target : link.target.id} {@const isHighlighted = selectedNodeId && (sourceId === selectedNodeId || targetId === selectedNodeId)} + {#if selectedAppIndex !== null} +