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/calendar/calendar.service.ts b/apps/calendar/apps/backend/src/calendar/calendar.service.ts index c13adbbee..dea77d014 100644 --- a/apps/calendar/apps/backend/src/calendar/calendar.service.ts +++ b/apps/calendar/apps/backend/src/calendar/calendar.service.ts @@ -113,12 +113,56 @@ export class CalendarService { return updated; } - // Create a new default calendar - return this.create(userId, { - name: 'Mein Kalender', - isDefault: true, - color: '#3B82F6', - }); + // Create default calendars for new user + await this.createDefaultCalendars(userId); + + // Return the default one + const defaultCal = await this.db + .select() + .from(calendars) + .where(and(eq(calendars.userId, userId), eq(calendars.isDefault, true))); + + return defaultCal[0]; + } + + /** + * Create default calendars for a new user + */ + async createDefaultCalendars(userId: string): Promise { + const defaultCalendars = [ + { + name: 'PersΓΆnlich', + color: '#3B82F6', // Blue + isDefault: true, + description: 'Private Termine', + }, + { + name: 'Beruf', + color: '#10B981', // Green + isDefault: false, + description: 'Arbeit, Meetings, Projekte', + }, + { + name: 'Familie', + color: '#F97316', // Orange + isDefault: false, + description: 'Familientermine, Geburtstage', + }, + { + name: 'Freizeit', + color: '#8B5CF6', // Violet + isDefault: false, + description: 'Hobbies, Sport, Events', + }, + ]; + + const created: Calendar[] = []; + for (const cal of defaultCalendars) { + const calendar = await this.create(userId, cal); + created.push(calendar); + } + + return created; } private async clearDefaultCalendar(userId: string): Promise { 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..bfd40249c 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, @@ -175,6 +184,16 @@ export class EventService { conditions.push(inArray(events.calendarId, query.calendarIds)); } + // Search filter - search in title and description + if (query.search) { + conditions.push( + or( + ilike(events.title, `%${query.search}%`), + ilike(events.description, `%${query.search}%`) + ) as any + ); + } + const result = await this.db .select({ event: events, diff --git a/apps/calendar/apps/web/Dockerfile b/apps/calendar/apps/web/Dockerfile index e1d6210fa..4f42a5e7f 100644 --- a/apps/calendar/apps/web/Dockerfile +++ b/apps/calendar/apps/web/Dockerfile @@ -37,6 +37,7 @@ COPY packages/shared-profile-ui ./packages/shared-profile-ui COPY packages/shared-ui ./packages/shared-ui COPY packages/shared-utils ./packages/shared-utils COPY packages/shared-tags ./packages/shared-tags +COPY packages/shared-splitscreen ./packages/shared-splitscreen # Copy calendar packages and web COPY apps/calendar/packages ./apps/calendar/packages diff --git a/apps/calendar/apps/web/package.json b/apps/calendar/apps/web/package.json index 535a04307..bd75e50f6 100644 --- a/apps/calendar/apps/web/package.json +++ b/apps/calendar/apps/web/package.json @@ -19,6 +19,7 @@ "@tailwindcss/vite": "^4.1.7", "@types/d3-force": "^3.0.0", "@types/node": "^20.0.0", + "@types/suncalc": "^1.9.2", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", "svelte": "^5.0.0", @@ -31,6 +32,7 @@ "dependencies": { "@calendar/shared": "workspace:*", "@manacore/shared-auth": "workspace:*", + "@manacore/shared-splitscreen": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-feedback-service": "workspace:*", @@ -43,12 +45,14 @@ "@manacore/shared-tailwind": "workspace:*", "@manacore/shared-theme": "workspace:*", "@manacore/shared-theme-ui": "workspace:*", + "@manacore/shared-types": "workspace:*", "@manacore/shared-ui": "workspace:*", "@manacore/shared-utils": "workspace:*", "@neodrag/svelte": "^2.3.3", "d3-force": "^3.0.0", "date-fns": "^4.1.0", "lucide-svelte": "^0.559.0", + "suncalc": "^1.9.0", "svelte-dnd-action": "^0.9.68", "svelte-i18n": "^4.0.1" }, diff --git a/apps/calendar/apps/web/src/app.css b/apps/calendar/apps/web/src/app.css index d625b6bc8..558ec4ab5 100644 --- a/apps/calendar/apps/web/src/app.css +++ b/apps/calendar/apps/web/src/app.css @@ -42,18 +42,18 @@ /* Hour slot in day/week view */ .hour-slot { height: var(--hour-height); - border-bottom: 1px solid hsl(var(--color-border) / 0.5); + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent); position: relative; } .hour-slot:hover { - background-color: hsl(var(--color-muted) / 0.3); + background-color: color-mix(in srgb, var(--color-muted) 30%, transparent); } /* Event card in calendar */ .event-card { - background-color: hsl(var(--color-primary)); - color: hsl(var(--color-primary-foreground)); + background-color: var(--color-primary); + color: var(--color-primary-foreground); border-radius: var(--radius-sm); padding: 2px 6px; font-size: 0.75rem; @@ -70,17 +70,17 @@ /* Day cell in month view */ .day-cell { min-height: 100px; - border: 1px solid hsl(var(--color-border)); + border: 1px solid var(--color-border); padding: var(--spacing-xs); transition: background-color var(--transition-fast); } .day-cell:hover { - background-color: hsl(var(--color-muted) / 0.3); + background-color: color-mix(in srgb, var(--color-muted) 30%, transparent); } .day-cell.today { - background-color: hsl(var(--color-primary) / 0.1); + background-color: color-mix(in srgb, var(--color-primary) 10%, transparent); } .day-cell.other-month { @@ -93,7 +93,7 @@ left: 0; right: 0; height: 2px; - background-color: hsl(var(--color-error)); + background-color: var(--color-error); z-index: 10; } @@ -105,7 +105,7 @@ width: 10px; height: 10px; border-radius: 50%; - background-color: hsl(var(--color-error)); + background-color: var(--color-error); } /* Mini calendar */ @@ -125,24 +125,24 @@ } .mini-calendar .day:hover { - background-color: hsl(var(--color-muted)); + background-color: var(--color-muted); } .mini-calendar .day.today { - background-color: hsl(var(--color-primary)); - color: hsl(var(--color-primary-foreground)); + background-color: var(--color-primary); + color: var(--color-primary-foreground); } .mini-calendar .day.selected { - border: 2px solid hsl(var(--color-primary)); + border: 2px solid var(--color-primary); } /* Card styles */ .card { - background-color: hsl(var(--color-surface)); + background-color: var(--color-surface); border-radius: var(--radius-lg); padding: var(--spacing-lg); - border: 1px solid hsl(var(--color-border)); + border: 1px solid var(--color-border); } /* Button styles */ @@ -161,12 +161,12 @@ } .btn-primary { - background: hsl(var(--color-primary)); - color: hsl(var(--color-primary-foreground)); + background: var(--color-primary); + color: var(--color-primary-foreground); } .btn-primary:hover { - background: hsl(var(--color-primary) / 0.9); + filter: brightness(0.9); } .btn-primary:disabled { @@ -175,21 +175,21 @@ } .btn-secondary { - background: hsl(var(--color-secondary)); - color: hsl(var(--color-secondary-foreground)); + background: var(--color-secondary); + color: var(--color-secondary-foreground); } .btn-secondary:hover { - background: hsl(var(--color-secondary) / 0.8); + filter: brightness(0.9); } .btn-ghost { background: transparent; - color: hsl(var(--color-foreground)); + color: var(--color-foreground); } .btn-ghost:hover { - background: hsl(var(--color-muted)); + background: var(--color-muted); } .btn-icon { @@ -206,21 +206,21 @@ display: block; width: 100%; padding: 0.5rem 0.75rem; - border: 2px solid hsl(var(--color-border)); + border: 2px solid var(--color-border); border-radius: var(--radius-md); - background-color: hsl(var(--color-background)); - color: hsl(var(--color-foreground)); + background-color: var(--color-background); + color: var(--color-foreground); font-size: 0.875rem; transition: border-color var(--transition-fast); } .input:focus { outline: none; - border-color: hsl(var(--color-primary)); + border-color: var(--color-primary); } .input::placeholder { - color: hsl(var(--color-muted-foreground)); + color: var(--color-muted-foreground); } /* Select styling */ @@ -235,7 +235,7 @@ select.input { /* Text colors */ .text-destructive { - color: hsl(var(--color-error)); + color: var(--color-error); } /* Scrollbar styling */ 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/events.ts b/apps/calendar/apps/web/src/lib/api/events.ts index ad35c3040..1ce53b469 100644 --- a/apps/calendar/apps/web/src/lib/api/events.ts +++ b/apps/calendar/apps/web/src/lib/api/events.ts @@ -23,17 +23,22 @@ export async function getEvents(params: QueryEventsParams) { if (params.search) { searchParams.set('search', params.search); } - return fetchApi(`/events?${searchParams.toString()}`); + const result = await fetchApi<{ events: CalendarEvent[] }>(`/events?${searchParams.toString()}`); + if (result.error || !result.data) { + return { data: null, error: result.error }; + } + return { data: result.data.events, error: null }; } export async function searchEvents(query: string, limit: number = 10) { - // Search events within the next year - const now = new Date(); + // Search events within a wide range (1 year past to 1 year future) + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); const oneYearFromNow = new Date(); oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1); return getEvents({ - startDate: now.toISOString(), + startDate: oneYearAgo.toISOString(), endDate: oneYearFromNow.toISOString(), search: query, }); 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 @@ -
-
- - - - -

{title}

-
- -
- -
- - - - - -
- -
- {#each visibleViews as type} - - {/each} -
-
+
+

{title}

diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte new file mode 100644 index 000000000..607f9d473 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbar.svelte @@ -0,0 +1,189 @@ + + +{#if !isCollapsed} + + + + + + +
+ +
+ +
+
+{/if} + + +{#if isCollapsed} + +{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte new file mode 100644 index 000000000..dd5d4461d --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/CalendarToolbarContent.svelte @@ -0,0 +1,195 @@ + + +
+ + + + {#if !vertical} + + {/if} + + + settingsStore.set('showOnlyWeekdays', !settingsStore.showOnlyWeekdays)} + active={settingsStore.showOnlyWeekdays} + title="Nur Wochentage anzeigen (Mo-Fr)" + > + Mo-Fr + + + + settingsStore.set('filterHoursEnabled', !settingsStore.filterHoursEnabled)} + labelFormat="range" + /> + + {#if !vertical} + + {/if} + + + +
+ + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte new file mode 100644 index 000000000..86e875875 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/DateStrip.svelte @@ -0,0 +1,500 @@ + + +
+ {#if !isTodayVisible} + + {/if} + +
+ +
+ {visibleMonth} +
+ + +
+ {#each days as day} + {@const dayIsToday = isToday(day)} + {@const dayIsSelected = isSameDay(day, currentDate)} + {@const dayIsWeekend = day.getDay() === 0 || day.getDay() === 6} + {@const dayInRange = isWithinInterval(day, { start: viewRange.start, end: viewRange.end })} + {@const dayIsRangeStart = isSameDay(day, viewRange.start)} + {@const dayIsRangeEnd = isSameDay(day, viewRange.end)} + {@const isFirstOfMonth = day.getDate() === 1} + {@const moonPhase = isSignificantMoonPhase(day)} + {@const eventCount = getEventCount(day)} + {#if isFirstOfMonth} +
+ {/if} + + {/each} +
+
+
+ + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte index 4c6894068..f1c692594 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -3,8 +3,9 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; - import { todosStore } from '$lib/stores/todos.svelte'; - import TodoRow from './TodoRow.svelte'; + import { searchStore } from '$lib/stores/search.svelte'; + import { todosStore, type Task } from '$lib/stores/todos.svelte'; + import TaskBlock from './TaskBlock.svelte'; import { goto } from '$app/navigation'; import { format, @@ -17,11 +18,15 @@ } from 'date-fns'; import { de } from 'date-fns/locale'; + import type { CalendarEvent } from '@calendar/shared'; + interface Props { onQuickCreate?: (date: Date, position: { x: number; y: number }) => void; + onEventClick?: (event: CalendarEvent) => void; + onTaskClick?: (task: Task) => void; } - let { onQuickCreate }: Props = $props(); + let { onQuickCreate, onEventClick, onTaskClick }: Props = $props(); // Constants const HOUR_HEIGHT = 60; // pixels per hour @@ -63,17 +68,74 @@ return () => clearInterval(interval); }); - let timedEvents = $derived( - eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay) - ); + // Get timed events, filtering out those outside visible range when hour filter is enabled + let timedEvents = $derived.by(() => { + const allEvents = eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay); + + if (settingsStore.filterHoursEnabled) { + const visibleStartMinutes = settingsStore.dayStartHour * 60; + const visibleEndMinutes = settingsStore.dayEndHour * 60; + + return allEvents.filter((event) => { + const start = + typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + + const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); + const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); + + // Event overlaps with visible range + return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes; + }); + } + + return allEvents; + }); + + // Get events that are completely outside the visible time range + let overflowEvents = $derived.by(() => { + if (!settingsStore.filterHoursEnabled) { + return { before: [] as CalendarEvent[], after: [] as CalendarEvent[] }; + } + + const allEvents = eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => !e.isAllDay); + const before: CalendarEvent[] = []; + const after: CalendarEvent[] = []; + + const visibleStartMinutes = settingsStore.dayStartHour * 60; + const visibleEndMinutes = settingsStore.dayEndHour * 60; + + for (const event of allEvents) { + const start = + typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + + const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); + const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); + + // Event ends before visible range starts + if (eventEndMinutes <= visibleStartMinutes) { + before.push(event); + } + // Event starts after visible range ends + else if (eventStartMinutes >= visibleEndMinutes) { + after.push(event); + } + } + + return { before, after }; + }); let allDayEvents = $derived( eventsStore.getEventsForDay(viewStore.currentDate).filter((e) => e.isAllDay) ); // 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 @@ -87,7 +149,7 @@ // Drag & Drop State // ============================================================================ let isDragging = $state(false); - let draggedEvent = $state(null); + let draggedEvent = $state(null); let dragOffsetMinutes = $state(0); let dragPreviewTop = $state(0); let dragPreviewHeight = $state(0); @@ -97,7 +159,7 @@ // Resize State // ============================================================================ let isResizing = $state(false); - let resizeEvent = $state(null); + let resizeEvent = $state(null); let resizeEdge = $state<'top' | 'bottom'>('bottom'); let resizeOriginalStart = $state(null); let resizeOriginalEnd = $state(null); @@ -107,6 +169,21 @@ // Track if we actually moved during drag/resize (to prevent click on simple mousedown/up) let hasMoved = $state(false); + // ============================================================================ + // Task Drag & Drop State + // ============================================================================ + let isTaskDragging = $state(false); + let draggedTask = $state(null); + let taskDragPreviewTop = $state(0); + let taskDragPreviewHeight = $state(0); + + // Task Resize State + let isTaskResizing = $state(false); + let resizeTask = $state(null); + let taskResizeEdge = $state<'top' | 'bottom'>('bottom'); + let taskResizePreviewTop = $state(0); + let taskResizePreviewHeight = $state(0); + // ============================================================================ // Helper Functions // ============================================================================ @@ -129,7 +206,7 @@ // ============================================================================ // Drag Handlers // ============================================================================ - function startDrag(event: any, e: PointerEvent) { + function startDrag(event: CalendarEvent, e: PointerEvent) { e.preventDefault(); e.stopPropagation(); @@ -215,7 +292,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(); @@ -321,13 +398,238 @@ document.removeEventListener('pointerup', handleDragEnd); document.removeEventListener('pointermove', handleResizeMove); document.removeEventListener('pointerup', handleResizeEnd); + // Task cleanup + isTaskDragging = false; + draggedTask = null; + isTaskResizing = false; + resizeTask = null; + document.removeEventListener('pointermove', handleTaskDragMove); + document.removeEventListener('pointerup', handleTaskDragEnd); + document.removeEventListener('pointermove', handleTaskResizeMove); + document.removeEventListener('pointerup', handleTaskResizeEnd); + } + + // ============================================================================ + // Task Drag & Drop + // ============================================================================ + function handleTaskDragStart(task: Task, e: PointerEvent) { + e.preventDefault(); + isTaskDragging = true; + draggedTask = task; + hasMoved = false; + + if (task.scheduledStartTime) { + const [h, m] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = h * 60 + m - firstVisibleHour * 60; + taskDragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + } + + const duration = task.estimatedDuration || 30; + taskDragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + document.addEventListener('pointermove', handleTaskDragMove); + document.addEventListener('pointerup', handleTaskDragEnd); + } + + function handleTaskDragMove(e: PointerEvent) { + if (!isTaskDragging || !draggedTask || !dayColumnRef) return; + hasMoved = true; + + const rect = dayColumnRef.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / 15) * 15; + taskDragPreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100; + } + + async function handleTaskDragEnd() { + document.removeEventListener('pointermove', handleTaskDragMove); + document.removeEventListener('pointerup', handleTaskDragEnd); + + if (!isTaskDragging || !draggedTask || !hasMoved) { + isTaskDragging = false; + draggedTask = null; + return; + } + + const minutesFromStart = (taskDragPreviewTop / 100) * (totalVisibleHours * 60); + const totalMinutes = firstVisibleHour * 60 + minutesFromStart; + const hours = Math.floor(totalMinutes / 60); + const minutes = Math.round(totalMinutes % 60); + + const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + const duration = draggedTask.estimatedDuration || 30; + const endTotalMinutes = totalMinutes + duration; + const endHours = Math.floor(endTotalMinutes / 60); + const endMins = Math.round(endTotalMinutes % 60); + const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + + await todosStore.updateTodo(draggedTask.id, { + scheduledStartTime: newStartTime, + scheduledEndTime: newEndTime, + }); + + isTaskDragging = false; + draggedTask = null; + hasMoved = false; + } + + // ============================================================================ + // Task Resize + // ============================================================================ + function handleTaskResizeStart(task: Task, edge: 'top' | 'bottom', e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + isTaskResizing = true; + resizeTask = task; + taskResizeEdge = edge; + hasMoved = false; + + if (task.scheduledStartTime) { + const [h, m] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = h * 60 + m - firstVisibleHour * 60; + taskResizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + } + + const duration = task.estimatedDuration || 30; + taskResizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + document.addEventListener('pointermove', handleTaskResizeMove); + document.addEventListener('pointerup', handleTaskResizeEnd); + } + + function handleTaskResizeMove(e: PointerEvent) { + if (!isTaskResizing || !resizeTask || !dayColumnRef) return; + hasMoved = true; + + const rect = dayColumnRef.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + + if (taskResizeEdge === 'top') { + const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / 15) * 15; + taskResizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop); + } else { + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / 15) * 15; + const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop); + } + } + + async function handleTaskResizeEnd() { + document.removeEventListener('pointermove', handleTaskResizeMove); + document.removeEventListener('pointerup', handleTaskResizeEnd); + + if (!isTaskResizing || !resizeTask || !hasMoved) { + isTaskResizing = false; + resizeTask = null; + return; + } + + const startMinutes = + (taskResizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; + const endMinutes = + ((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (totalVisibleHours * 60) + + firstVisibleHour * 60; + + const startHours = Math.floor(startMinutes / 60); + const startMins = Math.round(startMinutes % 60); + const endHours = Math.floor(endMinutes / 60); + const endMins = Math.round(endMinutes % 60); + + const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`; + const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + const newDuration = Math.round(endMinutes - startMinutes); + + await todosStore.updateTodo(resizeTask.id, { + scheduledStartTime: newStartTime, + scheduledEndTime: newEndTime, + estimatedDuration: newDuration, + }); + + isTaskResizing = false; + resizeTask = null; + hasMoved = false; + } + + // ============================================================================ + // Sidebar Task Drop + // ============================================================================ + let isSidebarDropTarget = $state(false); + + function handleSidebarDragOver(e: DragEvent) { + e.preventDefault(); + if (!e.dataTransfer) return; + const types = e.dataTransfer.types; + if (!types.includes('application/json')) return; + e.dataTransfer.dropEffect = 'move'; + isSidebarDropTarget = true; + } + + function handleSidebarDragLeave(e: DragEvent) { + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget?.closest('.day-column')) { + isSidebarDropTarget = false; + } + } + + async function handleSidebarDrop(e: DragEvent) { + e.preventDefault(); + isSidebarDropTarget = false; + + if (!e.dataTransfer || !dayColumnRef) return; + + const jsonData = e.dataTransfer.getData('application/json'); + if (!jsonData) return; + + try { + const data = JSON.parse(jsonData); + if (data.type !== 'sidebar-task') return; + + const rect = dayColumnRef.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / 15) * 15; + const totalMinutes = firstVisibleHour * 60 + snappedMinutes; + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const startTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + + const duration = data.estimatedDuration || 30; + const endMinutes = totalMinutes + duration; + const endHours = Math.floor(endMinutes / 60); + const endMins = endMinutes % 60; + const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + + await todosStore.updateTodo(data.taskId, { + scheduledDate: format(viewStore.currentDate, 'yyyy-MM-dd'), + scheduledStartTime: startTime, + scheduledEndTime: endTime, + estimatedDuration: duration, + }); + } catch (err) { + console.error('Failed to parse drop data:', err); + } } // ============================================================================ // Keyboard Handling // ============================================================================ function handleKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape' && (isDragging || isResizing)) { + if (e.key === 'Escape' && (isDragging || isResizing || isTaskDragging || isTaskResizing)) { e.preventDefault(); cleanup(); } @@ -342,7 +644,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; @@ -358,7 +660,36 @@ return `top: ${top}%; height: ${height}%; background-color: ${color};`; } - function handleEventClick(event: any, e: MouseEvent) { + /** + * Get style for a scheduled task (time-blocking) + */ + function getTaskStyle(task: Task): string { + if (!task.scheduledStartTime) return ''; + + const [startHour, startMin] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = startHour * 60 + startMin; + + let duration = task.estimatedDuration || 30; + if (task.scheduledEndTime) { + const [endHour, endMin] = task.scheduledEndTime.split(':').map(Number); + const endMinutes = endHour * 60 + endMin; + duration = endMinutes - startMinutes; + } + + const top = minutesToPercent(startMinutes); + const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 1.5); + + return `top: ${top}%; height: ${height}%;`; + } + + /** + * Get scheduled tasks for current day + */ + function getScheduledTasks(): Task[] { + return todosStore.getScheduledTasksForDay(viewStore.currentDate); + } + + function handleEventClick(event: CalendarEvent, e: MouseEvent) { // Don't navigate if dragging or resizing, or if we moved if (isDragging || isResizing || hasMoved) { e.preventDefault(); @@ -368,7 +699,11 @@ }, 100); return; } - goto(`/?event=${event.id}`); + if (onEventClick) { + onEventClick(event); + } else { + goto(`/?event=${event.id}`); + } } function handleSlotClick(hour: number, e: MouseEvent) { @@ -397,6 +732,8 @@ {#each headerAllDayEvents as event} {/if} @@ -444,6 +460,20 @@ animation: pulse-outline 1.5s ease-in-out infinite; } + /* Search highlighting */ + .event-pill.search-highlighted { + outline: 2px solid hsl(var(--color-primary)); + outline-offset: 1px; + box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.3); + z-index: 10; + transform: scale(1.02); + } + + .event-pill.search-dimmed { + opacity: 0.35; + filter: grayscale(0.3); + } + @keyframes pulse-outline { 0%, 100% { diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte index 824281d3f..2348ea8ae 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte @@ -3,6 +3,9 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; + import { searchStore } from '$lib/stores/search.svelte'; + import { todosStore, type Task } from '$lib/stores/todos.svelte'; + import TaskBlock from './TaskBlock.svelte'; import { goto } from '$app/navigation'; import { format, @@ -23,12 +26,16 @@ 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; onQuickCreate?: (date: Date, position: { x: number; y: number }) => void; + onEventClick?: (event: CalendarEvent) => void; + onTaskClick?: (task: Task) => void; } - let { dayCount, onQuickCreate }: Props = $props(); + let { dayCount, onQuickCreate, onEventClick, onTaskClick }: Props = $props(); // Get date-fns locale based on current app locale const dateLocales = { de, en: enUS, fr, es, it }; @@ -93,7 +100,7 @@ // ========== Drag & Drop State ========== let isDragging = $state(false); - let draggedEvent = $state(null); + let draggedEvent = $state(null); let dragOffsetMinutes = $state(0); let dragTargetDay = $state(null); let dragPreviewTop = $state(0); @@ -101,7 +108,7 @@ // ========== Resize State ========== let isResizing = $state(false); - let resizeEvent = $state(null); + let resizeEvent = $state(null); let resizeEdge = $state<'top' | 'bottom'>('bottom'); let resizeOriginalStart = $state(null); let resizeOriginalEnd = $state(null); @@ -111,11 +118,79 @@ // Track if we actually moved during drag/resize (to prevent click on simple mousedown/up) let hasMoved = $state(false); + // Task Drag & Drop State + let isTaskDragging = $state(false); + let draggedTask = $state(null); + let taskDragTargetDay = $state(null); + let taskDragPreviewTop = $state(0); + let taskDragPreviewHeight = $state(0); + + // Task Resize State + let isTaskResizing = $state(false); + let resizeTask = $state(null); + let taskResizeEdge = $state<'top' | 'bottom'>('bottom'); + let taskResizePreviewTop = $state(0); + let taskResizePreviewHeight = $state(0); + // Reference to the days container for position calculations let daysContainerEl: HTMLDivElement; function getEventsForDay(day: Date) { - return eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); + const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); + + // If hour filtering is enabled, only show events that overlap with visible range + if (settingsStore.filterHoursEnabled) { + const visibleStartMinutes = settingsStore.dayStartHour * 60; + const visibleEndMinutes = settingsStore.dayEndHour * 60; + + return allEvents.filter((event) => { + const start = + typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + + const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); + const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); + + // Event overlaps with visible range + return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes; + }); + } + + return allEvents; + } + + // Get events that are completely outside the visible time range + function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } { + if (!settingsStore.filterHoursEnabled) { + return { before: [], after: [] }; + } + + const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); + const before: CalendarEvent[] = []; + const after: CalendarEvent[] = []; + + const visibleStartMinutes = settingsStore.dayStartHour * 60; + const visibleEndMinutes = settingsStore.dayEndHour * 60; + + for (const event of allEvents) { + const start = + typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + + const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); + const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); + + // Event ends before visible range starts + if (eventEndMinutes <= visibleStartMinutes) { + before.push(event); + } + // Event starts after visible range ends + else if (eventStartMinutes >= visibleEndMinutes) { + after.push(event); + } + } + + return { before, after }; } function getAllDayEventsForDay(day: Date) { @@ -123,8 +198,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 @@ -141,7 +219,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; @@ -156,12 +234,41 @@ return `top: ${top}%; height: ${height}%; background-color: ${color};`; } + /** + * Get style for a scheduled task (time-blocking) + */ + function getTaskStyle(task: Task): string { + if (!task.scheduledStartTime) return ''; + + const [startHour, startMin] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = startHour * 60 + startMin; + + let duration = task.estimatedDuration || 30; + if (task.scheduledEndTime) { + const [endHour, endMin] = task.scheduledEndTime.split(':').map(Number); + const endMinutes = endHour * 60 + endMin; + duration = endMinutes - startMinutes; + } + + const top = minutesToPercent(startMinutes); + const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2); + + return `top: ${top}%; height: ${height}%;`; + } + + /** + * Get scheduled tasks for a specific day + */ + function getScheduledTasksForDay(day: Date): Task[] { + return todosStore.getScheduledTasksForDay(day); + } + function formatEventTime(date: Date | string): string { const d = typeof date === 'string' ? parseISO(date) : date; 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(); @@ -171,7 +278,11 @@ }, 100); return; } - goto(`/?event=${event.id}`); + if (onEventClick) { + onEventClick(event); + } else { + goto(`/?event=${event.id}`); + } } function handleSlotClick(day: Date, hour: number, e: MouseEvent) { @@ -218,7 +329,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(); @@ -321,7 +432,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(); @@ -437,15 +548,258 @@ hasMoved = false; } + // ========== Task Drag & Drop ========== + + function handleTaskDragStart(task: Task, e: PointerEvent) { + e.preventDefault(); + isTaskDragging = true; + draggedTask = task; + hasMoved = false; + + if (task.scheduledStartTime) { + const [h, m] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = h * 60 + m - firstVisibleHour * 60; + taskDragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + } + + const duration = task.estimatedDuration || 30; + taskDragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + document.addEventListener('pointermove', handleTaskDragMove); + document.addEventListener('pointerup', handleTaskDragEnd); + } + + function handleTaskDragMove(e: PointerEvent) { + if (!isTaskDragging || !draggedTask) return; + hasMoved = true; + + const daysEl = daysContainerEl; + if (!daysEl) return; + + const dayColumns = daysEl.querySelectorAll('.day-column'); + for (let i = 0; i < dayColumns.length; i++) { + const col = dayColumns[i]; + const rect = col.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right) { + taskDragTargetDay = days[i]; + break; + } + } + + const targetColumn = daysEl.querySelector('.day-column'); + if (!targetColumn) return; + const rect = targetColumn.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + taskDragPreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100; + } + + async function handleTaskDragEnd() { + document.removeEventListener('pointermove', handleTaskDragMove); + document.removeEventListener('pointerup', handleTaskDragEnd); + + if (!isTaskDragging || !draggedTask || !hasMoved) { + isTaskDragging = false; + draggedTask = null; + taskDragTargetDay = null; + return; + } + + const minutesFromStart = (taskDragPreviewTop / 100) * (totalVisibleHours * 60); + const totalMinutes = firstVisibleHour * 60 + minutesFromStart; + const hours = Math.floor(totalMinutes / 60); + const minutes = Math.round(totalMinutes % 60); + + const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + const duration = draggedTask.estimatedDuration || 30; + const endTotalMinutes = totalMinutes + duration; + const endHours = Math.floor(endTotalMinutes / 60); + const endMins = Math.round(endTotalMinutes % 60); + const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + + await todosStore.updateTodo(draggedTask.id, { + scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined, + scheduledStartTime: newStartTime, + scheduledEndTime: newEndTime, + }); + + isTaskDragging = false; + draggedTask = null; + taskDragTargetDay = null; + hasMoved = false; + } + + // ========== Task Resize ========== + + function handleTaskResizeStart(task: Task, edge: 'top' | 'bottom', e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + isTaskResizing = true; + resizeTask = task; + taskResizeEdge = edge; + hasMoved = false; + + if (task.scheduledStartTime) { + const [h, m] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = h * 60 + m - firstVisibleHour * 60; + taskResizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + } + + const duration = task.estimatedDuration || 30; + taskResizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + document.addEventListener('pointermove', handleTaskResizeMove); + document.addEventListener('pointerup', handleTaskResizeEnd); + } + + function handleTaskResizeMove(e: PointerEvent) { + if (!isTaskResizing || !resizeTask) return; + hasMoved = true; + + const daysEl = daysContainerEl; + if (!daysEl) return; + + const targetColumn = daysEl.querySelector('.day-column'); + if (!targetColumn) return; + + const rect = targetColumn.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + + if (taskResizeEdge === 'top') { + const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + taskResizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop); + } else { + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop); + } + } + + async function handleTaskResizeEnd() { + document.removeEventListener('pointermove', handleTaskResizeMove); + document.removeEventListener('pointerup', handleTaskResizeEnd); + + if (!isTaskResizing || !resizeTask || !hasMoved) { + isTaskResizing = false; + resizeTask = null; + return; + } + + const startMinutes = + (taskResizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; + const endMinutes = + ((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (totalVisibleHours * 60) + + firstVisibleHour * 60; + + const startHours = Math.floor(startMinutes / 60); + const startMins = Math.round(startMinutes % 60); + const endHours = Math.floor(endMinutes / 60); + const endMins = Math.round(endMinutes % 60); + + const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`; + const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + const newDuration = Math.round(endMinutes - startMinutes); + + await todosStore.updateTodo(resizeTask.id, { + scheduledStartTime: newStartTime, + scheduledEndTime: newEndTime, + estimatedDuration: newDuration, + }); + + isTaskResizing = false; + resizeTask = null; + hasMoved = false; + } + + // ========== Sidebar Task Drop ========== + let sidebarDropTarget = $state<{ day: Date; y: number } | null>(null); + + function handleSidebarDragOver(e: DragEvent, day: Date) { + e.preventDefault(); + if (!e.dataTransfer) return; + const types = e.dataTransfer.types; + if (!types.includes('application/json')) return; + e.dataTransfer.dropEffect = 'move'; + sidebarDropTarget = { day, y: e.clientY }; + } + + function handleSidebarDragLeave(e: DragEvent) { + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget?.closest('.day-column')) { + sidebarDropTarget = null; + } + } + + async function handleSidebarDrop(e: DragEvent, day: Date) { + e.preventDefault(); + sidebarDropTarget = null; + + if (!e.dataTransfer) return; + + const jsonData = e.dataTransfer.getData('application/json'); + if (!jsonData) return; + + try { + const data = JSON.parse(jsonData); + if (data.type !== 'sidebar-task') return; + + const dayColumn = (e.target as HTMLElement).closest('.day-column'); + if (!dayColumn) return; + + const rect = dayColumn.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + const totalMinutes = firstVisibleHour * 60 + snappedMinutes; + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const startTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + + const duration = data.estimatedDuration || 30; + const endMinutes = totalMinutes + duration; + const endHours = Math.floor(endMinutes / 60); + const endMins = endMinutes % 60; + const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + + await todosStore.updateTodo(data.taskId, { + scheduledDate: format(day, 'yyyy-MM-dd'), + scheduledStartTime: startTime, + scheduledEndTime: endTime, + estimatedDuration: duration, + }); + } catch (err) { + console.error('Failed to parse drop data:', err); + } + } + // ========== Keyboard Handling ========== function handleKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape' && (isDragging || isResizing)) { + if (e.key === 'Escape' && (isDragging || isResizing || isTaskDragging || isTaskResizing)) { e.preventDefault(); document.removeEventListener('pointermove', handleDragMove); document.removeEventListener('pointerup', handleDragEnd); document.removeEventListener('pointermove', handleResizeMove); document.removeEventListener('pointerup', handleResizeEnd); + document.removeEventListener('pointermove', handleTaskDragMove); + document.removeEventListener('pointerup', handleTaskDragEnd); + document.removeEventListener('pointermove', handleTaskResizeMove); + document.removeEventListener('pointerup', handleTaskResizeEnd); isDragging = false; draggedEvent = null; dragTargetDay = null; @@ -453,6 +807,11 @@ resizeEvent = null; resizeOriginalStart = null; resizeOriginalEnd = null; + isTaskDragging = false; + draggedTask = null; + taskDragTargetDay = null; + isTaskResizing = false; + resizeTask = null; hasMoved = false; } } @@ -477,6 +836,8 @@ {#each getHeaderAllDayEventsForDay(day) as event} + + {#if isOpen} + + + + + + {/if} + + + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TaskBlock.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TaskBlock.svelte new file mode 100644 index 000000000..69342e21f --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/calendar/TaskBlock.svelte @@ -0,0 +1,297 @@ + + +
+ + {#if onResizeStart && !task.isCompleted} +
+ {/if} + +
+ + + +
+ + {task.scheduledStartTime || ''} + {#if task.scheduledEndTime} + - {task.scheduledEndTime} + {/if} + + {task.title} +
+ + + {#if onResizeStart && !task.isCompleted} +
+ {/if} +
+ + diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte index 5a61b09cf..429915e86 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/TodoSidebarSection.svelte @@ -27,6 +27,8 @@ // Fetch todos on mount await todosStore.fetchTodayTodos(); await todosStore.fetchUpcomingTodos(); + // Also fetch scheduled todos (including completed) for calendar display + await todosStore.fetchScheduledTodos(); }); function toggleExpanded() { @@ -116,6 +118,7 @@ {task} variant="compact" showProject={false} + draggable={!task.isCompleted} onclick={() => handleTaskClick(task)} /> {/each} diff --git a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte index 17330c4c7..ab7380493 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -3,8 +3,9 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; - import { todosStore } from '$lib/stores/todos.svelte'; - import TodoRow from './TodoRow.svelte'; + import { searchStore } from '$lib/stores/search.svelte'; + import { todosStore, type Task } from '$lib/stores/todos.svelte'; + import TaskBlock from './TaskBlock.svelte'; import { goto } from '$app/navigation'; import { format, @@ -21,13 +22,17 @@ 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; + onEventClick?: (event: CalendarEvent) => void; + onTaskClick?: (task: Task) => void; } - let { onQuickCreate }: Props = $props(); + let { onQuickCreate, onEventClick, onTaskClick }: Props = $props(); // Constants const HOUR_HEIGHT = 60; // px - should match CSS --hour-height @@ -94,7 +99,7 @@ // Drag & Drop State let isDragging = $state(false); - let draggedEvent = $state(null); + let draggedEvent = $state(null); let dragOffsetMinutes = $state(0); let dragTargetDay = $state(null); let dragPreviewTop = $state(0); @@ -102,7 +107,7 @@ // Resize State let isResizing = $state(false); - let resizeEvent = $state(null); + let resizeEvent = $state(null); let resizeEdge = $state<'top' | 'bottom'>('bottom'); let resizeOriginalStart = $state(null); let resizeOriginalEnd = $state(null); @@ -112,11 +117,79 @@ // Track if we actually moved during drag/resize (to prevent click on simple mousedown/up) let hasMoved = $state(false); + // Task Drag & Drop State + let isTaskDragging = $state(false); + let draggedTask = $state(null); + let taskDragTargetDay = $state(null); + let taskDragPreviewTop = $state(0); + let taskDragPreviewHeight = $state(0); + + // Task Resize State + let isTaskResizing = $state(false); + let resizeTask = $state(null); + let taskResizeEdge = $state<'top' | 'bottom'>('bottom'); + let taskResizePreviewTop = $state(0); + let taskResizePreviewHeight = $state(0); + // Reference to the days container for position calculations let daysContainerEl: HTMLDivElement; function getEventsForDay(day: Date) { - return eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); + const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); + + // If hour filtering is enabled, only show events that overlap with visible range + if (settingsStore.filterHoursEnabled) { + const visibleStartMinutes = settingsStore.dayStartHour * 60; + const visibleEndMinutes = settingsStore.dayEndHour * 60; + + return allEvents.filter((event) => { + const start = + typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + + const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); + const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); + + // Event overlaps with visible range + return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes; + }); + } + + return allEvents; + } + + // Get events that are completely outside the visible time range + function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } { + if (!settingsStore.filterHoursEnabled) { + return { before: [], after: [] }; + } + + const allEvents = eventsStore.getEventsForDay(day).filter((e) => !e.isAllDay); + const before: CalendarEvent[] = []; + const after: CalendarEvent[] = []; + + const visibleStartMinutes = settingsStore.dayStartHour * 60; + const visibleEndMinutes = settingsStore.dayEndHour * 60; + + for (const event of allEvents) { + const start = + typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + + const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); + const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); + + // Event ends before visible range starts + if (eventEndMinutes <= visibleStartMinutes) { + before.push(event); + } + // Event starts after visible range ends + else if (eventStartMinutes >= visibleEndMinutes) { + after.push(event); + } + } + + return { before, after }; } function getAllDayEventsForDay(day: Date) { @@ -124,8 +197,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 @@ -142,7 +218,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; @@ -157,12 +233,43 @@ return `top: ${top}%; height: ${height}%; background-color: ${color};`; } + /** + * Get style for a scheduled task (time-blocking) + */ + function getTaskStyle(task: Task): string { + if (!task.scheduledStartTime) return ''; + + // Parse HH:mm time + const [startHour, startMin] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = startHour * 60 + startMin; + + // Calculate duration - use estimatedDuration or scheduledEndTime or default 30 min + let duration = task.estimatedDuration || 30; + if (task.scheduledEndTime) { + const [endHour, endMin] = task.scheduledEndTime.split(':').map(Number); + const endMinutes = endHour * 60 + endMin; + duration = endMinutes - startMinutes; + } + + const top = minutesToPercent(startMinutes); + const height = Math.max((duration / (totalVisibleHours * 60)) * 100, 2); + + return `top: ${top}%; height: ${height}%;`; + } + + /** + * Get scheduled tasks for a specific day + */ + function getScheduledTasksForDay(day: Date): Task[] { + return todosStore.getScheduledTasksForDay(day); + } + function formatEventTime(date: Date | string): string { const d = typeof date === 'string' ? parseISO(date) : date; 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(); @@ -173,7 +280,11 @@ }, 100); return; } - goto(`/?event=${event.id}`); + if (onEventClick) { + onEventClick(event); + } else { + goto(`/?event=${event.id}`); + } } function handleSlotClick(day: Date, hour: number, e: MouseEvent) { @@ -220,7 +331,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(); @@ -323,7 +434,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(); @@ -439,6 +550,263 @@ hasMoved = false; } + // ========== Task Drag & Drop ========== + + function handleTaskDragStart(task: Task, e: PointerEvent) { + e.preventDefault(); + isTaskDragging = true; + draggedTask = task; + hasMoved = false; + + // Initialize preview position + if (task.scheduledStartTime) { + const [h, m] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = h * 60 + m - firstVisibleHour * 60; + taskDragPreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + } + + const duration = task.estimatedDuration || 30; + taskDragPreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + document.addEventListener('pointermove', handleTaskDragMove); + document.addEventListener('pointerup', handleTaskDragEnd); + } + + function handleTaskDragMove(e: PointerEvent) { + if (!isTaskDragging || !draggedTask) return; + hasMoved = true; + + // Find which day column we're over + const daysEl = daysContainerEl; + if (!daysEl) return; + + const dayColumns = daysEl.querySelectorAll('.day-column'); + for (let i = 0; i < dayColumns.length; i++) { + const col = dayColumns[i]; + const rect = col.getBoundingClientRect(); + if (e.clientX >= rect.left && e.clientX <= rect.right) { + taskDragTargetDay = days[i]; + break; + } + } + + // Calculate vertical position + const targetColumn = daysEl.querySelector('.day-column'); + if (!targetColumn) return; + const rect = targetColumn.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + // Snap to 15-minute intervals + const minutesPerPercent = (totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + taskDragPreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100; + } + + async function handleTaskDragEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleTaskDragMove); + document.removeEventListener('pointerup', handleTaskDragEnd); + + if (!isTaskDragging || !draggedTask || !hasMoved) { + isTaskDragging = false; + draggedTask = null; + taskDragTargetDay = null; + return; + } + + // Calculate new time from position + const minutesFromStart = (taskDragPreviewTop / 100) * (totalVisibleHours * 60); + const totalMinutes = firstVisibleHour * 60 + minutesFromStart; + const hours = Math.floor(totalMinutes / 60); + const minutes = Math.round(totalMinutes % 60); + + const newStartTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + + // Calculate end time based on duration + const duration = draggedTask.estimatedDuration || 30; + const endTotalMinutes = totalMinutes + duration; + const endHours = Math.floor(endTotalMinutes / 60); + const endMins = Math.round(endTotalMinutes % 60); + const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + + await todosStore.updateTodo(draggedTask.id, { + scheduledDate: taskDragTargetDay ? format(taskDragTargetDay, 'yyyy-MM-dd') : undefined, + scheduledStartTime: newStartTime, + scheduledEndTime: newEndTime, + }); + + isTaskDragging = false; + draggedTask = null; + taskDragTargetDay = null; + hasMoved = false; + } + + // ========== Task Resize ========== + + function handleTaskResizeStart(task: Task, edge: 'top' | 'bottom', e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + isTaskResizing = true; + resizeTask = task; + taskResizeEdge = edge; + hasMoved = false; + + // Initialize preview position + if (task.scheduledStartTime) { + const [h, m] = task.scheduledStartTime.split(':').map(Number); + const startMinutes = h * 60 + m - firstVisibleHour * 60; + taskResizePreviewTop = (startMinutes / (totalVisibleHours * 60)) * 100; + } + + const duration = task.estimatedDuration || 30; + taskResizePreviewHeight = (duration / (totalVisibleHours * 60)) * 100; + + document.addEventListener('pointermove', handleTaskResizeMove); + document.addEventListener('pointerup', handleTaskResizeEnd); + } + + function handleTaskResizeMove(e: PointerEvent) { + if (!isTaskResizing || !resizeTask) return; + hasMoved = true; + + const daysEl = daysContainerEl; + if (!daysEl) return; + + const targetColumn = daysEl.querySelector('.day-column'); + if (!targetColumn) return; + + const rect = targetColumn.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + + if (taskResizeEdge === 'top') { + // Adjust start time, keep end fixed + const originalEndPercent = taskResizePreviewTop + taskResizePreviewHeight; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + taskResizePreviewTop = (snappedMinutes / (totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, originalEndPercent - taskResizePreviewTop); + } else { + // Adjust end time, keep start fixed + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + const newBottom = (snappedMinutes / (totalVisibleHours * 60)) * 100; + taskResizePreviewHeight = Math.max(2, newBottom - taskResizePreviewTop); + } + } + + async function handleTaskResizeEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleTaskResizeMove); + document.removeEventListener('pointerup', handleTaskResizeEnd); + + if (!isTaskResizing || !resizeTask || !hasMoved) { + isTaskResizing = false; + resizeTask = null; + return; + } + + // Calculate new times from position + const startMinutes = + (taskResizePreviewTop / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; + const endMinutes = + ((taskResizePreviewTop + taskResizePreviewHeight) / 100) * (totalVisibleHours * 60) + + firstVisibleHour * 60; + + const startHours = Math.floor(startMinutes / 60); + const startMins = Math.round(startMinutes % 60); + const endHours = Math.floor(endMinutes / 60); + const endMins = Math.round(endMinutes % 60); + + const newStartTime = `${startHours.toString().padStart(2, '0')}:${startMins.toString().padStart(2, '0')}`; + const newEndTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + const newDuration = Math.round(endMinutes - startMinutes); + + await todosStore.updateTodo(resizeTask.id, { + scheduledStartTime: newStartTime, + scheduledEndTime: newEndTime, + estimatedDuration: newDuration, + }); + + isTaskResizing = false; + resizeTask = null; + hasMoved = false; + } + + // ========== Sidebar Task Drop ========== + let sidebarDropTarget = $state<{ day: Date; y: number } | null>(null); + + function handleSidebarDragOver(e: DragEvent, day: Date) { + e.preventDefault(); + if (!e.dataTransfer) return; + + // Check if this is a sidebar task drag + const types = e.dataTransfer.types; + if (!types.includes('application/json')) return; + + e.dataTransfer.dropEffect = 'move'; + sidebarDropTarget = { day, y: e.clientY }; + } + + function handleSidebarDragLeave(e: DragEvent) { + // Only clear if leaving the column entirely + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget?.closest('.day-column')) { + sidebarDropTarget = null; + } + } + + async function handleSidebarDrop(e: DragEvent, day: Date) { + e.preventDefault(); + sidebarDropTarget = null; + + if (!e.dataTransfer) return; + + const jsonData = e.dataTransfer.getData('application/json'); + if (!jsonData) return; + + try { + const data = JSON.parse(jsonData); + if (data.type !== 'sidebar-task') return; + + // Calculate drop time from Y position + const dayColumn = (e.target as HTMLElement).closest('.day-column'); + if (!dayColumn) return; + + const rect = dayColumn.getBoundingClientRect(); + const relativeY = e.clientY - rect.top; + const percentY = Math.max(0, Math.min(100, (relativeY / rect.height) * 100)); + + const minutesPerPercent = (totalVisibleHours * 60) / 100; + const rawMinutes = percentY * minutesPerPercent; + const snappedMinutes = Math.round(rawMinutes / MINUTES_PER_SLOT) * MINUTES_PER_SLOT; + const totalMinutes = firstVisibleHour * 60 + snappedMinutes; + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const startTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + + // Calculate end time + const duration = data.estimatedDuration || 30; + const endMinutes = totalMinutes + duration; + const endHours = Math.floor(endMinutes / 60); + const endMins = endMinutes % 60; + const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`; + + // Update the task with scheduled time + await todosStore.updateTodo(data.taskId, { + scheduledDate: format(day, 'yyyy-MM-dd'), + scheduledStartTime: startTime, + scheduledEndTime: endTime, + estimatedDuration: duration, + }); + } catch (err) { + console.error('Failed to parse drop data:', err); + } + } + // ========== Keyboard Handling ========== function handleKeyDown(e: KeyboardEvent) { @@ -459,6 +827,20 @@ resizeOriginalEnd = null; hasMoved = false; } + // Cancel task drag/resize + if (isTaskDragging || isTaskResizing) { + e.preventDefault(); + document.removeEventListener('pointermove', handleTaskDragMove); + document.removeEventListener('pointerup', handleTaskDragEnd); + document.removeEventListener('pointermove', handleTaskResizeMove); + document.removeEventListener('pointerup', handleTaskResizeEnd); + isTaskDragging = false; + draggedTask = null; + taskDragTargetDay = null; + isTaskResizing = false; + resizeTask = null; + hasMoved = false; + } } } @@ -473,7 +855,8 @@ {#if settingsStore.showWeekNumbers}
- KW {weekNumber} + {$_('views.weekNumber')} + {weekNumber}
{/if} @@ -482,7 +865,7 @@
{#if settingsStore.showWeekNumbers} - KW {weekNumber} + {$_('views.weekNumber')} {weekNumber} {/if}
{#each days as day} @@ -490,6 +873,8 @@ {#each getHeaderAllDayEventsForDay(day) as event}
{/if} - - {#if todosStore.serviceAvailable} -
-
- {#each days as day} -
- -
- {/each} -
- {/if} -
@@ -538,7 +911,15 @@
{#each days as day, dayIndex} -
+ +
handleSidebarDragOver(e, day)} + ondragleave={handleSidebarDragLeave} + ondrop={(e) => handleSidebarDrop(e, day)} + > {#each hours as hour} + + {#if showStatusDropdown === attendee.email} +
+ {#each statusOptions as option (option.value)} + + {/each} +
+ {/if} +
+ + + +
+ {/each} +
+ {/if} + + + +
+ + diff --git a/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte index 4ec2ee327..3f802dc68 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte @@ -145,8 +145,8 @@ - -
- - {#if showCustomThemes && customThemesStore} - - {/if} - {#if showModeSelector && onModeChange}
@@ -207,154 +124,22 @@
{/if} - - {#if !showCustomThemes || activeTab === 'themes'} - -
-

- {t.currentTheme} -

- -
- {:else if activeTab === 'custom' && customThemesStore} - -
-
-

Meine Themes

- {#if onCreateTheme} - - {/if} -
- - {#if customThemesStore.loading} -
Lade...
- {:else if customThemesStore.customThemes.length === 0} -
- -

Noch keine eigenen Themes

-

- Erstelle dein erstes eigenes Theme mit individuellen Farben. -

- {#if onCreateTheme} - - {/if} -
- {:else} -
- {#each customThemesStore.customThemes as theme (theme.id)} -
- -
-
-
-
-
-
-
-
- {theme.emoji} -
-

{theme.name}

- {#if theme.description} -

{theme.description}

- {/if} -
- {#if theme.isPublished} - - VerΓΆffentlicht - - {/if} -
-
- - {#if onEditTheme} - - {/if} - -
-
-
- {/each} -
- {/if} -
- {:else if activeTab === 'community'} - -
-
- -

Community Themes

-

- Entdecke Themes, die von anderen Nutzern erstellt wurden. -

- {#if onCommunityThemes} - - {/if} -
-
- {/if} + +
+

+ {t.currentTheme} +

+ +
{#if showA11ySettings && a11yStore} diff --git a/packages/shared-theme/src/custom-themes-store.svelte.ts b/packages/shared-theme/src/custom-themes-store.svelte.ts deleted file mode 100644 index 888c6a11a..000000000 --- a/packages/shared-theme/src/custom-themes-store.svelte.ts +++ /dev/null @@ -1,506 +0,0 @@ -import type { - CustomTheme, - CommunityTheme, - CreateCustomThemeInput, - UpdateCustomThemeInput, - PublishThemeInput, - CommunityThemeQuery, - PaginatedCommunityThemes, - CustomThemesStore, - CustomThemesStoreConfig, - ThemeColors, - EffectiveMode, -} from './types'; -import { isBrowser } from './utils'; - -/** - * Apply a custom theme's colors to the document as CSS variables - */ -function applyCustomThemeToDocument( - colors: ThemeColors, - effectiveMode: EffectiveMode = 'light' -): void { - if (!isBrowser()) return; - - const root = document.documentElement; - - // Apply all color variables - Object.entries(colors).forEach(([key, value]) => { - // Convert camelCase to kebab-case - const cssVar = key.replace(/([A-Z])/g, '-$1').toLowerCase(); - root.style.setProperty(`--${cssVar}`, value); - }); - - // Set mode class - root.classList.remove('light', 'dark'); - root.classList.add(effectiveMode); - - // Mark as custom theme - root.setAttribute('data-custom-theme', 'true'); -} - -/** - * Clear custom theme and revert to standard theme - */ -function clearCustomThemeFromDocument(): void { - if (!isBrowser()) return; - - const root = document.documentElement; - - // Remove custom theme marker - root.removeAttribute('data-custom-theme'); - - // Clear inline styles (CSS vars will fall back to theme variant) - root.style.cssText = ''; -} - -/** - * Create a custom themes store for managing user's custom themes and community themes - * - * @example - * ```typescript - * import { createCustomThemesStore } from '@manacore/shared-theme'; - * import { authStore } from '$lib/stores/auth.svelte'; - * - * export const customThemesStore = createCustomThemesStore({ - * authUrl: import.meta.env.PUBLIC_AUTH_URL, - * getAccessToken: () => authStore.getAccessToken(), - * }); - * ``` - */ -export function createCustomThemesStore(config: CustomThemesStoreConfig): CustomThemesStore { - const { authUrl, getAccessToken } = config; - - // State - let customThemes = $state([]); - let communityThemes = $state([]); - let favorites = $state([]); - let downloaded = $state([]); - let pagination = $state({ page: 1, totalPages: 1, total: 0 }); - let loading = $state(false); - let error = $state(null); - - // Track currently applied custom theme - let appliedThemeId = $state(null); - - /** - * Make an authenticated API request - */ - async function apiRequest(endpoint: string, options: RequestInit = {}): Promise { - const token = await getAccessToken(); - if (!token) { - throw new Error('Not authenticated'); - } - - const url = `${authUrl}${endpoint}`; - const response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - ...options.headers, - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Request failed: ${response.status}`); - } - - // Handle 204 No Content - if (response.status === 204) { - return undefined as T; - } - - return response.json(); - } - - /** - * Make a public API request (no auth required) - */ - async function publicApiRequest(endpoint: string, options: RequestInit = {}): Promise { - const url = `${authUrl}${endpoint}`; - const token = await getAccessToken(); - - const headers: Record = { - 'Content-Type': 'application/json', - }; - - // Add auth if available (for user-specific data like favorites) - if (token) { - headers.Authorization = `Bearer ${token}`; - } - - const response = await fetch(url, { - ...options, - headers: { - ...headers, - ...options.headers, - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `Request failed: ${response.status}`); - } - - return response.json(); - } - - // ==================== Custom Theme Operations ==================== - - /** - * Load user's custom themes - */ - async function loadCustomThemes(): Promise { - loading = true; - error = null; - - try { - customThemes = await apiRequest('/api/v1/themes'); - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load themes'; - throw err; - } finally { - loading = false; - } - } - - /** - * Create a new custom theme - */ - async function createTheme(input: CreateCustomThemeInput): Promise { - loading = true; - error = null; - - try { - const theme = await apiRequest('/api/v1/themes', { - method: 'POST', - body: JSON.stringify(input), - }); - customThemes = [...customThemes, theme]; - return theme; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to create theme'; - throw err; - } finally { - loading = false; - } - } - - /** - * Update an existing custom theme - */ - async function updateTheme(id: string, input: UpdateCustomThemeInput): Promise { - loading = true; - error = null; - - try { - const theme = await apiRequest(`/api/v1/themes/${id}`, { - method: 'PATCH', - body: JSON.stringify(input), - }); - customThemes = customThemes.map((t) => (t.id === id ? theme : t)); - return theme; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to update theme'; - throw err; - } finally { - loading = false; - } - } - - /** - * Delete a custom theme - */ - async function deleteTheme(id: string): Promise { - loading = true; - error = null; - - try { - await apiRequest(`/api/v1/themes/${id}`, { - method: 'DELETE', - }); - customThemes = customThemes.filter((t) => t.id !== id); - - // Clear applied theme if it was the deleted one - if (appliedThemeId === id) { - clearCustomTheme(); - } - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to delete theme'; - throw err; - } finally { - loading = false; - } - } - - /** - * Publish a custom theme to the community - */ - async function publishTheme(id: string, input?: PublishThemeInput): Promise { - loading = true; - error = null; - - try { - const communityTheme = await apiRequest(`/api/v1/themes/${id}/publish`, { - method: 'POST', - body: JSON.stringify(input || {}), - }); - - // Update the custom theme's isPublished status - customThemes = customThemes.map((t) => (t.id === id ? { ...t, isPublished: true } : t)); - - return communityTheme; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to publish theme'; - throw err; - } finally { - loading = false; - } - } - - // ==================== Community Theme Operations ==================== - - /** - * Browse community themes with filtering/sorting - */ - async function browseCommunity(query?: CommunityThemeQuery): Promise { - loading = true; - error = null; - - try { - const params = new URLSearchParams(); - if (query?.page) params.set('page', String(query.page)); - if (query?.limit) params.set('limit', String(query.limit)); - if (query?.sort) params.set('sort', query.sort); - if (query?.search) params.set('search', query.search); - if (query?.authorId) params.set('authorId', query.authorId); - if (query?.featuredOnly) params.set('featuredOnly', 'true'); - if (query?.tags?.length) { - query.tags.forEach((tag) => params.append('tags', tag)); - } - - const queryString = params.toString(); - const endpoint = `/api/v1/community-themes${queryString ? `?${queryString}` : ''}`; - - const result = await publicApiRequest(endpoint); - communityThemes = result.themes; - pagination = { - page: result.page, - totalPages: result.totalPages, - total: result.total, - }; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to browse community themes'; - throw err; - } finally { - loading = false; - } - } - - /** - * Download/install a community theme - */ - async function downloadTheme(id: string): Promise { - loading = true; - error = null; - - try { - const theme = await apiRequest(`/api/v1/community-themes/${id}/download`, { - method: 'POST', - }); - - // Update download status in community themes list - communityThemes = communityThemes.map((t) => - t.id === id ? { ...t, isDownloaded: true, downloadCount: theme.downloadCount } : t - ); - - // Add to downloaded list if not already there - if (!downloaded.some((t) => t.id === id)) { - downloaded = [...downloaded, theme]; - } - - return theme; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to download theme'; - throw err; - } finally { - loading = false; - } - } - - /** - * Rate a community theme - */ - async function rateTheme( - id: string, - rating: number - ): Promise<{ averageRating: number; ratingCount: number }> { - error = null; - - try { - const result = await apiRequest<{ averageRating: number; ratingCount: number }>( - `/api/v1/community-themes/${id}/rate`, - { - method: 'POST', - body: JSON.stringify({ rating }), - } - ); - - // Update rating in community themes list - communityThemes = communityThemes.map((t) => - t.id === id - ? { - ...t, - averageRating: result.averageRating, - ratingCount: result.ratingCount, - userRating: rating, - } - : t - ); - - return result; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to rate theme'; - throw err; - } - } - - /** - * Toggle favorite status for a community theme - */ - async function toggleFavorite(id: string): Promise<{ isFavorited: boolean }> { - error = null; - - try { - const result = await apiRequest<{ isFavorited: boolean }>( - `/api/v1/community-themes/${id}/favorite`, - { method: 'POST' } - ); - - // Update favorite status in community themes list - communityThemes = communityThemes.map((t) => - t.id === id ? { ...t, isFavorited: result.isFavorited } : t - ); - - // Update favorites list - if (result.isFavorited) { - const theme = communityThemes.find((t) => t.id === id); - if (theme && !favorites.some((t) => t.id === id)) { - favorites = [...favorites, { ...theme, isFavorited: true }]; - } - } else { - favorites = favorites.filter((t) => t.id !== id); - } - - return result; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to toggle favorite'; - throw err; - } - } - - /** - * Load user's favorite themes - */ - async function loadFavorites(): Promise { - loading = true; - error = null; - - try { - favorites = await apiRequest('/api/v1/community-themes/favorites'); - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load favorites'; - throw err; - } finally { - loading = false; - } - } - - /** - * Load user's downloaded themes - */ - async function loadDownloaded(): Promise { - loading = true; - error = null; - - try { - downloaded = await apiRequest('/api/v1/community-themes/downloaded'); - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load downloaded themes'; - throw err; - } finally { - loading = false; - } - } - - // ==================== Apply Theme ==================== - - /** - * Apply a custom or community theme to the document - */ - function applyCustomTheme(theme: CustomTheme | CommunityTheme): void { - // Determine effective mode from system or stored preference - const effectiveMode: EffectiveMode = isBrowser() - ? window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light' - : 'light'; - - const colors = effectiveMode === 'dark' ? theme.darkColors : theme.lightColors; - applyCustomThemeToDocument(colors as ThemeColors, effectiveMode); - appliedThemeId = theme.id; - } - - /** - * Clear the applied custom theme and revert to standard theme - */ - function clearCustomTheme(): void { - clearCustomThemeFromDocument(); - appliedThemeId = null; - } - - return { - get customThemes() { - return customThemes; - }, - get communityThemes() { - return communityThemes; - }, - get favorites() { - return favorites; - }, - get downloaded() { - return downloaded; - }, - get pagination() { - return pagination; - }, - get loading() { - return loading; - }, - get error() { - return error; - }, - - // Custom theme operations - loadCustomThemes, - createTheme, - updateTheme, - deleteTheme, - publishTheme, - - // Community theme operations - browseCommunity, - downloadTheme, - rateTheme, - toggleFavorite, - loadFavorites, - loadDownloaded, - - // Apply theme - applyCustomTheme, - clearCustomTheme, - }; -} diff --git a/packages/shared-theme/src/index.ts b/packages/shared-theme/src/index.ts index 9a5e51864..0a3881ab5 100644 --- a/packages/shared-theme/src/index.ts +++ b/packages/shared-theme/src/index.ts @@ -28,18 +28,6 @@ export type { StartPageConfig, WeekStartDay, GeneralSettings, - // Custom & Community Themes Types - ThemeColorsInput, - CustomTheme, - CreateCustomThemeInput, - UpdateCustomThemeInput, - CommunityTheme, - CommunityThemeQuery, - PaginatedCommunityThemes, - PublishThemeInput, - ThemeEditorState, - CustomThemesStore, - CustomThemesStoreConfig, } from './types'; // User Settings Constants @@ -48,9 +36,6 @@ export { DEFAULT_GLOBAL_SETTINGS, DEFAULT_GENERAL_SETTINGS } from './types'; // Theme Variant Categories export { DEFAULT_THEME_VARIANTS, EXTENDED_THEME_VARIANTS } from './types'; -// Custom Theme Constants -export { MAIN_THEME_COLORS, EXTENDED_THEME_COLORS, THEME_COLOR_LABELS } from './types'; - // Constants export { THEME_VARIANTS, @@ -81,9 +66,6 @@ export { createA11yStore } from './a11y-store.svelte'; // User Settings Store export { createUserSettingsStore } from './user-settings-store.svelte'; -// Custom Themes Store -export { createCustomThemesStore } from './custom-themes-store.svelte'; - // Utils export { isBrowser, diff --git a/packages/shared-theme/src/types.ts b/packages/shared-theme/src/types.ts index 964771bec..6f7949ebf 100644 --- a/packages/shared-theme/src/types.ts +++ b/packages/shared-theme/src/types.ts @@ -302,12 +302,46 @@ export interface AppOverride { theme?: Partial; } +/** + * Device type for device-specific settings + */ +export type DeviceType = 'desktop' | 'mobile' | 'tablet'; + +/** + * Device-specific app settings + */ +export interface DeviceAppSettings { + deviceName: string; + deviceType: DeviceType; + lastSeen: string; + apps: Record>; +} + +/** + * Device info for listing + */ +export interface DeviceInfo { + deviceId: string; + deviceName: string; + deviceType: DeviceType; + lastSeen: string; + appCount: number; +} + /** * Full user settings response from API */ export interface UserSettingsResponse { globalSettings: GlobalSettings; appOverrides: Record; + deviceSettings: Record; +} + +/** + * Devices list response + */ +export interface DevicesListResponse { + devices: DeviceInfo[]; } /** @@ -325,7 +359,7 @@ export const DEFAULT_GENERAL_SETTINGS: GeneralSettings = { * Default global settings */ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { - nav: { desktopPosition: 'top', sidebarCollapsed: false, hiddenNavItems: {} }, + nav: { desktopPosition: 'bottom', sidebarCollapsed: false, hiddenNavItems: {} }, theme: { mode: 'system', colorScheme: 'ocean', pinnedThemes: [] }, locale: 'de', general: DEFAULT_GENERAL_SETTINGS, @@ -353,6 +387,12 @@ export interface UserSettingsStore { readonly syncing: boolean; /** Whether settings are loaded */ readonly loaded: boolean; + /** Current device ID */ + readonly deviceId: string; + /** All device settings */ + readonly deviceSettings: Record; + /** Current device's app settings */ + readonly currentDeviceAppSettings: Record; /** Load settings from server */ load: () => Promise; @@ -372,6 +412,14 @@ export interface UserSettingsStore { toggleNavItemVisibility: (appId: string, href: string) => Promise; /** Set hidden nav items for an app */ setHiddenNavItems: (appId: string, hiddenHrefs: string[]) => Promise; + /** Update device-specific app settings */ + updateDeviceAppSettings: (settings: Record) => Promise; + /** Get device-specific app settings */ + getDeviceAppSettings: () => Record; + /** List all devices */ + getDevices: () => Promise; + /** Remove a device */ + removeDevice: (deviceId: string) => Promise; } /** @@ -384,261 +432,8 @@ export interface UserSettingsStoreConfig { authUrl: string; /** Function to get current access token */ getAccessToken: () => Promise; + /** Optional device name (auto-detected if not provided) */ + deviceName?: string; + /** Optional device type (auto-detected if not provided) */ + deviceType?: DeviceType; } - -// ============================================================================ -// Custom & Community Themes Types -// ============================================================================ - -/** - * Partial theme colors for API DTOs (some fields optional) - */ -export interface ThemeColorsInput { - primary: HSLValue; - primaryForeground?: HSLValue; - background: HSLValue; - foreground: HSLValue; - surface: HSLValue; - surfaceHover?: HSLValue; - surfaceElevated?: HSLValue; - muted?: HSLValue; - mutedForeground?: HSLValue; - border?: HSLValue; - borderStrong?: HSLValue; - secondary?: HSLValue; - secondaryForeground?: HSLValue; - input?: HSLValue; - ring?: HSLValue; - error: HSLValue; - success: HSLValue; - warning: HSLValue; -} - -/** - * User-created custom theme - */ -export interface CustomTheme { - id: string; - userId: string; - name: string; - description?: string; - emoji: string; - icon: string; - lightColors: ThemeColors; - darkColors: ThemeColors; - baseVariant?: ThemeVariant; - isPublished: boolean; - createdAt: Date; - updatedAt: Date; -} - -/** - * Input for creating a new custom theme - */ -export interface CreateCustomThemeInput { - name: string; - description?: string; - emoji?: string; - icon?: string; - lightColors: ThemeColorsInput; - darkColors: ThemeColorsInput; - baseVariant?: ThemeVariant; -} - -/** - * Input for updating a custom theme - */ -export interface UpdateCustomThemeInput { - name?: string; - description?: string; - emoji?: string; - icon?: string; - lightColors?: ThemeColorsInput; - darkColors?: ThemeColorsInput; - baseVariant?: ThemeVariant; -} - -/** - * Community theme shared publicly - */ -export interface CommunityTheme { - id: string; - authorId?: string; - authorName?: string; - name: string; - description?: string; - emoji: string; - icon: string; - lightColors: ThemeColors; - darkColors: ThemeColors; - baseVariant?: ThemeVariant; - downloadCount: number; - averageRating: number; - ratingCount: number; - status: 'pending' | 'approved' | 'rejected' | 'featured'; - isFeatured: boolean; - tags: string[]; - createdAt: Date; - publishedAt?: Date; - /** User-specific fields (when authenticated) */ - isFavorited?: boolean; - isDownloaded?: boolean; - userRating?: number; -} - -/** - * Query parameters for browsing community themes - */ -export interface CommunityThemeQuery { - page?: number; - limit?: number; - sort?: 'popular' | 'recent' | 'rating' | 'downloads'; - search?: string; - tags?: string[]; - authorId?: string; - featuredOnly?: boolean; -} - -/** - * Paginated response for community themes - */ -export interface PaginatedCommunityThemes { - themes: CommunityTheme[]; - total: number; - page: number; - limit: number; - totalPages: number; -} - -/** - * Input for publishing a theme to the community - */ -export interface PublishThemeInput { - tags?: string[]; - description?: string; -} - -/** - * Theme editor state for UI - */ -export interface ThemeEditorState { - /** Theme being edited */ - theme: Partial; - /** Currently editing light or dark colors */ - editingMode: EffectiveMode; - /** Currently selected color key */ - selectedColorKey: keyof ThemeColors | null; - /** Is preview mode active */ - isPreviewing: boolean; - /** Has unsaved changes */ - isDirty: boolean; -} - -/** - * Custom themes store interface - */ -export interface CustomThemesStore { - /** User's custom themes */ - readonly customThemes: CustomTheme[]; - /** Community themes (from current query) */ - readonly communityThemes: CommunityTheme[]; - /** User's favorited themes */ - readonly favorites: CommunityTheme[]; - /** User's downloaded themes */ - readonly downloaded: CommunityTheme[]; - /** Pagination info */ - readonly pagination: { page: number; totalPages: number; total: number }; - /** Loading state */ - readonly loading: boolean; - /** Error state */ - readonly error: string | null; - - // Custom theme operations - loadCustomThemes: () => Promise; - createTheme: (input: CreateCustomThemeInput) => Promise; - updateTheme: (id: string, input: UpdateCustomThemeInput) => Promise; - deleteTheme: (id: string) => Promise; - publishTheme: (id: string, input?: PublishThemeInput) => Promise; - - // Community theme operations - browseCommunity: (query?: CommunityThemeQuery) => Promise; - downloadTheme: (id: string) => Promise; - rateTheme: ( - id: string, - rating: number - ) => Promise<{ averageRating: number; ratingCount: number }>; - toggleFavorite: (id: string) => Promise<{ isFavorited: boolean }>; - loadFavorites: () => Promise; - loadDownloaded: () => Promise; - - // Apply theme - applyCustomTheme: (theme: CustomTheme | CommunityTheme) => void; - clearCustomTheme: () => void; -} - -/** - * Custom themes store configuration - */ -export interface CustomThemesStoreConfig { - /** Auth service base URL */ - authUrl: string; - /** Function to get current access token */ - getAccessToken: () => Promise; - /** Theme store to apply custom themes to */ - themeStore?: ThemeStore; -} - -/** - * Main colors for the simplified editor view - * These are the 7 most important colors users typically want to customize - */ -export const MAIN_THEME_COLORS: (keyof ThemeColors)[] = [ - 'primary', - 'background', - 'surface', - 'foreground', - 'error', - 'success', - 'warning', -]; - -/** - * Extended/advanced colors (collapsed by default in editor) - */ -export const EXTENDED_THEME_COLORS: (keyof ThemeColors)[] = [ - 'primaryForeground', - 'secondary', - 'secondaryForeground', - 'surfaceHover', - 'surfaceElevated', - 'muted', - 'mutedForeground', - 'border', - 'borderStrong', - 'input', - 'ring', -]; - -/** - * Color labels for the editor UI - */ -export const THEME_COLOR_LABELS: Record = { - primary: 'Primary', - primaryForeground: 'Primary Text', - secondary: 'Secondary', - secondaryForeground: 'Secondary Text', - background: 'Background', - foreground: 'Text', - surface: 'Surface', - surfaceHover: 'Surface Hover', - surfaceElevated: 'Elevated Surface', - muted: 'Muted', - mutedForeground: 'Muted Text', - border: 'Border', - borderStrong: 'Border Strong', - error: 'Error', - success: 'Success', - warning: 'Warning', - input: 'Input', - ring: 'Focus Ring', -}; diff --git a/packages/shared-theme/src/user-settings-store.svelte.ts b/packages/shared-theme/src/user-settings-store.svelte.ts index c2ecabd16..eef8de7ea 100644 --- a/packages/shared-theme/src/user-settings-store.svelte.ts +++ b/packages/shared-theme/src/user-settings-store.svelte.ts @@ -7,12 +7,74 @@ import type { ThemeSettings, UserSettingsResponse, GeneralSettings, + DeviceAppSettings, + DeviceInfo, + DeviceType, + DevicesListResponse, } from './types'; import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_GENERAL_SETTINGS } from './types'; import { isBrowser } from './utils'; import { getStartPage as getStartPageFromConfig } from './app-routes'; const STORAGE_KEY_PREFIX = 'manacore-user-settings'; +const DEVICE_ID_KEY = 'manacore-device-id'; + +/** + * Generate a unique device ID + */ +function generateDeviceId(): string { + return 'dev_' + crypto.randomUUID().replace(/-/g, '').substring(0, 16); +} + +/** + * Get or create device ID from localStorage + */ +function getOrCreateDeviceId(): string { + if (!isBrowser()) return 'server'; + try { + let deviceId = localStorage.getItem(DEVICE_ID_KEY); + if (!deviceId) { + deviceId = generateDeviceId(); + localStorage.setItem(DEVICE_ID_KEY, deviceId); + } + return deviceId; + } catch { + return generateDeviceId(); + } +} + +/** + * Detect device type based on user agent and screen size + */ +function detectDeviceType(): DeviceType { + if (!isBrowser()) return 'desktop'; + const ua = navigator.userAgent.toLowerCase(); + const isMobile = /mobile|iphone|ipod|android.*mobile|windows phone/i.test(ua); + const isTablet = /tablet|ipad|android(?!.*mobile)/i.test(ua); + if (isTablet) return 'tablet'; + if (isMobile) return 'mobile'; + return 'desktop'; +} + +/** + * Detect device name based on user agent + */ +function detectDeviceName(): string { + if (!isBrowser()) return 'Server'; + const ua = navigator.userAgent; + // Try to extract device/browser info + if (/iPhone/.test(ua)) return 'iPhone'; + if (/iPad/.test(ua)) return 'iPad'; + if (/Android/.test(ua)) { + const match = ua.match(/Android.*;\s*([^;)]+)/); + if (match) return match[1].trim(); + return 'Android GerΓ€t'; + } + if (/Mac/.test(ua)) return 'Mac'; + if (/Windows/.test(ua)) return 'Windows PC'; + if (/Linux/.test(ua)) return 'Linux PC'; + return 'Unbekanntes GerΓ€t'; +} /** * Create a User Settings store for your app @@ -41,12 +103,18 @@ const STORAGE_KEY_PREFIX = 'manacore-user-settings'; * ``` */ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSettingsStore { - const { appId, authUrl, getAccessToken } = config; + const { appId, authUrl, getAccessToken, deviceName, deviceType } = config; const storageKey = `${STORAGE_KEY_PREFIX}-${appId}`; + // Device info (initialized once) + const deviceId = getOrCreateDeviceId(); + const detectedDeviceType = deviceType || detectDeviceType(); + const detectedDeviceName = deviceName || detectDeviceName(); + // State let globalSettings = $state({ ...DEFAULT_GLOBAL_SETTINGS }); let appOverrides = $state>({}); + let deviceSettings = $state>({}); let syncing = $state(false); let loaded = $state(false); @@ -88,6 +156,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe JSON.stringify({ globalSettings, appOverrides, + deviceSettings, timestamp: Date.now(), }) ); @@ -111,6 +180,9 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe if (data.appOverrides) { appOverrides = data.appOverrides; } + if (data.deviceSettings) { + deviceSettings = data.deviceSettings; + } return true; } } catch (e) { @@ -165,6 +237,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; + deviceSettings = data.deviceSettings || {}; saveToStorage(); loaded = true; } @@ -205,6 +278,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; + deviceSettings = data.deviceSettings || {}; saveToStorage(); } else { // Rollback on failure @@ -242,6 +316,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; + deviceSettings = data.deviceSettings || {}; saveToStorage(); } else { // Rollback on failure @@ -303,6 +378,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe if (data?.success) { globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; appOverrides = data.appOverrides || {}; + deviceSettings = data.deviceSettings || {}; saveToStorage(); } else { // Rollback on failure @@ -354,6 +430,108 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe } as Partial); } + // ============================================================================ + // Device Settings Functions + // ============================================================================ + + /** + * Update device-specific app settings for current device + */ + async function updateDeviceAppSettings(settings: Record): Promise { + // Optimistic update + const previousDeviceSettings = { ...deviceSettings }; + const existingDevice = deviceSettings[deviceId] || { + deviceName: detectedDeviceName, + deviceType: detectedDeviceType, + lastSeen: new Date().toISOString(), + apps: {}, + }; + + deviceSettings = { + ...deviceSettings, + [deviceId]: { + ...existingDevice, + lastSeen: new Date().toISOString(), + apps: { + ...existingDevice.apps, + [appId]: { + ...(existingDevice.apps?.[appId] || {}), + ...settings, + }, + }, + }, + }; + saveToStorage(); + + syncing = true; + try { + const data = await apiRequest( + 'PATCH', + `/device/${deviceId}/${appId}`, + { + deviceName: detectedDeviceName, + deviceType: detectedDeviceType, + settings, + } + ); + + if (data?.success) { + globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; + appOverrides = data.appOverrides || {}; + deviceSettings = data.deviceSettings || {}; + saveToStorage(); + } else { + // Rollback on failure + deviceSettings = previousDeviceSettings; + saveToStorage(); + } + } finally { + syncing = false; + } + } + + /** + * Get device-specific app settings for current device + */ + function getDeviceAppSettings(): Record { + const device = deviceSettings[deviceId]; + if (!device?.apps?.[appId]) return {}; + return device.apps[appId]; + } + + /** + * Get list of all devices + */ + async function getDevices(): Promise { + const data = await apiRequest('GET', '/devices'); + if (data?.success) { + return data.devices; + } + return []; + } + + /** + * Remove a device + */ + async function removeDevice(targetDeviceId: string): Promise { + syncing = true; + try { + const data = await apiRequest( + 'DELETE', + `/device/${targetDeviceId}` + ); + + if (data?.success) { + globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings }; + appOverrides = data.appOverrides || {}; + deviceSettings = data.deviceSettings || {}; + saveToStorage(); + } + } finally { + syncing = false; + } + } + return { get nav() { return nav; @@ -382,6 +560,17 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe get loaded() { return loaded; }, + get deviceId() { + return deviceId; + }, + get deviceSettings() { + return deviceSettings; + }, + get currentDeviceAppSettings() { + const device = deviceSettings[deviceId]; + if (!device?.apps?.[appId]) return {}; + return device.apps[appId]; + }, load, updateGlobal, @@ -392,5 +581,9 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe getHiddenNavItemsForApp, toggleNavItemVisibility, setHiddenNavItems, + updateDeviceAppSettings, + getDeviceAppSettings, + getDevices, + removeDevice, }; } diff --git a/packages/shared-types/src/contact.ts b/packages/shared-types/src/contact.ts new file mode 100644 index 000000000..06ea5efb9 --- /dev/null +++ b/packages/shared-types/src/contact.ts @@ -0,0 +1,80 @@ +/** + * Contact-related types for cross-app integration + * + * These types are used when referencing contacts from the Contacts app + * in other apps like Todo and Calendar. + */ + +/** + * Reference to a contact with cached display data. + * Used for offline display when Contacts API is unavailable. + */ +export interface ContactReference { + /** Contact ID from Contacts app */ + contactId: string; + /** Cached display name */ + displayName: string; + /** Cached email */ + email?: string; + /** Cached photo URL */ + photoUrl?: string; + /** Cached company name */ + company?: string; + /** ISO timestamp when data was fetched (for cache invalidation) */ + fetchedAt: string; +} + +/** + * Summary of a contact from the Contacts API. + * Contains essential fields for display in selectors and lists. + */ +export interface ContactSummary { + id: string; + displayName: string; + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + company?: string; + photoUrl?: string; +} + +/** + * Manual contact entry (when contact doesn't exist in Contacts app). + * Used for calendar attendees who aren't in the user's contacts. + */ +export interface ManualContactEntry { + /** Email address (required for manual entries) */ + email: string; + /** Display name (optional) */ + name?: string; + /** Indicates this is a manual entry, not from Contacts app */ + isManual: true; +} + +/** + * Union type for contact references that can be either + * a real contact or a manual entry. + */ +export type ContactOrManual = ContactReference | ManualContactEntry; + +/** + * Helper to check if a contact entry is manual + */ +export function isManualContact(contact: ContactOrManual): contact is ManualContactEntry { + return 'isManual' in contact && contact.isManual === true; +} + +/** + * Helper to create a ContactReference from a ContactSummary + */ +export function createContactReference(contact: ContactSummary): ContactReference { + return { + contactId: contact.id, + displayName: contact.displayName, + email: contact.email, + photoUrl: contact.photoUrl, + company: contact.company, + fetchedAt: new Date().toISOString(), + }; +} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 36e08a07b..dd2f4de13 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -16,6 +16,9 @@ export * from './ui'; // Common utility types export * from './common'; +// Contact types for cross-app integration +export * from './contact'; + // API types export interface User { id: string; diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index f366d440f..e4b5665ae 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -38,6 +38,7 @@ "@manacore/shared-branding": "workspace:*", "@manacore/shared-icons": "workspace:*", "@manacore/shared-theme": "workspace:*", + "@manacore/shared-types": "workspace:*", "d3-force": "^3.0.0", "d3-selection": "^3.0.0", "d3-transition": "^3.0.0", diff --git a/packages/shared-ui/src/atoms/Card.svelte b/packages/shared-ui/src/atoms/Card.svelte index 34514cf22..0b07d31c2 100644 --- a/packages/shared-ui/src/atoms/Card.svelte +++ b/packages/shared-ui/src/atoms/Card.svelte @@ -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 { diff --git a/packages/shared-ui/src/command-bar/CommandBar.svelte b/packages/shared-ui/src/command-bar/CommandBar.svelte index 623e1f3c0..1130c62a1 100644 --- a/packages/shared-ui/src/command-bar/CommandBar.svelte +++ b/packages/shared-ui/src/command-bar/CommandBar.svelte @@ -102,7 +102,7 @@ let creating = $state(false); let selectedIndex = $state(0); let searchTimeout: ReturnType; - 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/index.ts b/packages/shared-ui/src/index.ts index 9e3262b05..1915e966c 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -39,6 +39,9 @@ export { // Feedback export { EmptyState } from './molecules'; +// Contacts +export { ContactAvatar, ContactBadge, ContactSelector } from './molecules'; + // Layout export { ModalFooter, DataCard, PageHeader, KeyboardShortcutsPanel } from './molecules'; @@ -76,6 +79,12 @@ export { SidebarSection, PillNavigation, PillDropdown, + PillTabGroup, + PillTimeRangeSelector, + PillViewSwitcher, + PillToolbar, + PillToolbarButton, + PillToolbarDivider, } from './navigation'; export type { NavItem, @@ -87,6 +96,7 @@ export type { PillDropdownItem, PillNavElement, PillNavigationProps, + PillTabOption, } from './navigation'; // Settings @@ -104,9 +114,13 @@ export { GlobalSettingsSection, } from './settings'; -// Command Bar +// Command Bar (deprecated - use QuickInputBar) export { CommandBar } from './command-bar'; -export type { CommandBarItem, QuickAction, CreatePreview } from './command-bar'; +export type { CommandBarItem } from './command-bar'; + +// Input Bar +export { InputBar, QuickInputBar } from './quick-input'; +export type { QuickInputItem, QuickAction, CreatePreview } from './quick-input'; // Pages export { default as AppsPage } from './pages/AppsPage.svelte'; 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); +
+ import { User } from '@manacore/shared-icons'; + + interface Props { + /** Photo URL */ + photoUrl?: string | null; + /** Display name (for initials fallback) */ + name?: string; + /** Size in pixels */ + size?: 'xs' | 'sm' | 'md' | 'lg'; + /** Custom class */ + class?: string; + } + + let { photoUrl, name = '', size = 'md', class: className = '' }: Props = $props(); + + const sizeClasses = { + xs: 'w-5 h-5 text-[10px]', + sm: 'w-6 h-6 text-xs', + md: 'w-8 h-8 text-sm', + lg: 'w-10 h-10 text-base', + }; + + const iconSizes = { + xs: 10, + sm: 12, + md: 16, + lg: 20, + }; + + // Generate initials from name + const initials = $derived.by(() => { + if (!name) return ''; + const parts = name.trim().split(/\s+/); + if (parts.length === 1) { + return parts[0].charAt(0).toUpperCase(); + } + return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); + }); + + // Generate a consistent background color based on the name + const bgColor = $derived.by(() => { + if (!name) return 'bg-gray-400'; + const colors = [ + 'bg-violet-500', + 'bg-blue-500', + 'bg-cyan-500', + 'bg-teal-500', + 'bg-green-500', + 'bg-amber-500', + 'bg-orange-500', + 'bg-rose-500', + 'bg-pink-500', + 'bg-indigo-500', + ]; + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return colors[Math.abs(hash) % colors.length]; + }); + + +{#if photoUrl} + {name +{:else if initials} +
+ {initials} +
+{:else} +
+ +
+{/if} diff --git a/packages/shared-ui/src/molecules/contacts/ContactBadge.svelte b/packages/shared-ui/src/molecules/contacts/ContactBadge.svelte new file mode 100644 index 000000000..edc486731 --- /dev/null +++ b/packages/shared-ui/src/molecules/contacts/ContactBadge.svelte @@ -0,0 +1,185 @@ + + + + + + + {displayName} + {#if showEmail && email && email !== displayName} + {email} + {/if} + + + {#if removable} + + {/if} + + + diff --git a/packages/shared-ui/src/molecules/contacts/ContactSelector.svelte b/packages/shared-ui/src/molecules/contacts/ContactSelector.svelte new file mode 100644 index 000000000..40aadac06 --- /dev/null +++ b/packages/shared-ui/src/molecules/contacts/ContactSelector.svelte @@ -0,0 +1,711 @@ + + + + +
+ +
+ {#each selectedContacts as contact, index (index)} + handleRemoveContact(index)} /> + {/each} + + {#if canAddMore && !disabled} + + {/if} +
+ + + {#if isOpen} + + {/if} +
+ + diff --git a/packages/shared-ui/src/molecules/contacts/index.ts b/packages/shared-ui/src/molecules/contacts/index.ts new file mode 100644 index 000000000..b4f8c5628 --- /dev/null +++ b/packages/shared-ui/src/molecules/contacts/index.ts @@ -0,0 +1,4 @@ +// Contact selection and display components +export { default as ContactAvatar } from './ContactAvatar.svelte'; +export { default as ContactBadge } from './ContactBadge.svelte'; +export { default as ContactSelector } from './ContactSelector.svelte'; diff --git a/packages/shared-ui/src/molecules/index.ts b/packages/shared-ui/src/molecules/index.ts index 53aba560c..1a7f23173 100644 --- a/packages/shared-ui/src/molecules/index.ts +++ b/packages/shared-ui/src/molecules/index.ts @@ -39,6 +39,9 @@ export { // Feedback components export { EmptyState } from './feedback'; +// Contact components +export { ContactAvatar, ContactBadge, ContactSelector } from './contacts'; + // Layout components export { default as ModalFooter } from './ModalFooter.svelte'; export { default as DataCard } from './DataCard.svelte'; diff --git a/packages/shared-ui/src/molecules/tags/TagBadge.svelte b/packages/shared-ui/src/molecules/tags/TagBadge.svelte index ce0a95bc4..c39fa505b 100644 --- a/packages/shared-ui/src/molecules/tags/TagBadge.svelte +++ b/packages/shared-ui/src/molecules/tags/TagBadge.svelte @@ -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 @@ } - - -
+{#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)} + {:else} - + {#if item.showSplitButton && item.onSplitClick} + {/if} - +
{#if item.submenu && item.submenu.length > 0 && openSubmenuId === item.id}