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..eafbc7f5f --- /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: T): 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 91e1fa9d6..fe5dad53b 100644 --- a/apps/calendar/apps/web/src/lib/api/client.ts +++ b/apps/calendar/apps/web/src/lib/api/client.ts @@ -2,66 +2,22 @@ * API Client for Calendar Backend */ -import { browser } from '$app/environment'; import { env } from '$env/dynamic/public'; +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; - - let authToken = token; - if (!authToken && browser) { - authToken = localStorage.getItem('@auth/appToken') || undefined; - } - - try { - const headers: Record = {}; - - // Don't set Content-Type for FormData - browser sets it automatically with boundary - if (!isFormData) { - headers['Content-Type'] = 'application/json'; - } - - if (authToken) { - headers['Authorization'] = `Bearer ${authToken}`; - } - - const response = await fetch(`${API_BASE}/api/v1${endpoint}`, { - method, - headers, - body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - return { - data: null, - error: new Error(errorData.message || `API error: ${response.status}`), - }; - } - - // Handle empty responses (204 No Content) - if (response.status === 204) { - return { data: null, error: null }; - } - - const data = await response.json(); - return { data, error: null }; - } catch (error) { - return { - data: null, - error: error instanceof Error ? error : new Error('Unknown error'), - }; - } +): Promise> { + return calendarClient.fetchApi(endpoint, options); } + +// Re-export types for backwards compatibility +export type { FetchOptions, ApiResult }; diff --git a/apps/calendar/apps/web/src/lib/api/todos.ts b/apps/calendar/apps/web/src/lib/api/todos.ts index fa649d829..6f11e576c 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) // ============================================ @@ -150,78 +155,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 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 @@ - Kalender + {$_('app.name')}
@@ -106,7 +121,7 @@ @@ -141,7 +156,7 @@ -
- - {#if showQuickCreate} - - {/if} - - - {#if modalEventId} - + + {#if showQuickOverlay} + {#key overlayKey} + + {/key} {/if}