diff --git a/apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx b/apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx new file mode 100644 index 0000000..73c0252 --- /dev/null +++ b/apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { CalendarSettingsPanel } from '@/components/settings/calendar-settings-panel'; + +/** + * Settings > Dashboard > Kalender page (D-11). + * Renders CalendarSettingsPanel for managing calendar sources. + */ +export default function CalendarSettingsPage() { + const t = useTranslations('settings'); + + return ( +
+

+ {t('categoryCalendar')} +

+ +
+ ); +} diff --git a/apps/web/src/components/settings/calendar-settings-panel.tsx b/apps/web/src/components/settings/calendar-settings-panel.tsx new file mode 100644 index 0000000..88e7f5a --- /dev/null +++ b/apps/web/src/components/settings/calendar-settings-panel.tsx @@ -0,0 +1,389 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { + fetchSources, + addSource, + updateSource, + deleteSource, + testSource, +} from '@/lib/calendar-api'; +import type { CalendarSource, CreateSourcePayload } from '@/lib/calendar-api'; +import { CalendarSourceForm } from './calendar-source-form'; + +/** + * Type badge label map. + */ +const TYPE_LABELS: Record = { + caldav: 'CalDAV', + ics: 'ICS', + exchange: 'Exchange', +}; + +/** + * Calendar Settings Panel (D-08, D-09, D-11). + * + * Manages calendar sources: list with visibility toggle (CAL-02), + * add/edit forms, connection test, delete with confirmation dialog. + */ +export function CalendarSettingsPanel() { + const t = useTranslations('widgets'); + const ts = useTranslations('settings'); + + const [sources, setSources] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showAddForm, setShowAddForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [testResults, setTestResults] = useState>({}); + + // Load sources on mount + useEffect(() => { + fetchSources() + .then(setSources) + .catch(() => { + // Silent fail — show empty state + }) + .finally(() => setIsLoading(false)); + }, []); + + // Toggle visibility (CAL-02) + const handleVisibilityToggle = useCallback( + async (source: CalendarSource) => { + const newIsVisible = !source.isVisible; + // Optimistic update + setSources((prev) => + prev.map((s) => + s.id === source.id ? { ...s, isVisible: newIsVisible } : s, + ), + ); + try { + await updateSource(source.id, { isVisible: newIsVisible }); + } catch { + // Revert on error + setSources((prev) => + prev.map((s) => + s.id === source.id ? { ...s, isVisible: source.isVisible } : s, + ), + ); + } + }, + [], + ); + + // Add new source + const handleAddSource = useCallback(async (payload: CreateSourcePayload) => { + setIsSaving(true); + try { + const created = await addSource(payload); + setSources((prev) => [...prev, created]); + setShowAddForm(false); + + // Auto-test connection after save + setTestResults((prev) => ({ ...prev, [created.id]: 'testing' })); + try { + const result = await testSource(created.id); + setTestResults((prev) => ({ + ...prev, + [created.id]: result.success ? 'success' : 'error', + })); + } catch { + setTestResults((prev) => ({ ...prev, [created.id]: 'error' })); + } + } catch { + // Error handled silently + } finally { + setIsSaving(false); + } + }, []); + + // Delete source + const handleDeleteSource = useCallback(async (id: string) => { + try { + await deleteSource(id); + setSources((prev) => prev.filter((s) => s.id !== id)); + setDeletingId(null); + } catch { + // Error handled silently + } + }, []); + + // Loading state + if (isLoading) { + return ( +
Loading...
+ ); + } + + return ( +
+ {/* Source list */} + {sources.length === 0 && !showAddForm && ( +

+ {t('calendar.sourceEmpty')} +

+ )} + + {sources.length > 0 && ( +
+ {sources.map((source) => ( +
+ {/* Color dot */} +
+ ))} +
+ )} + + {/* Edit form (below the source being edited) */} + {editingId && ( +
+

+ Edit Source +

+ { + const s = sources.find((src) => src.id === editingId); + return s + ? { + id: s.id, + name: s.name, + type: s.type, + url: s.url, + username: s.username ?? '', + exchangeMode: s.exchangeMode ?? undefined, + color: s.color ?? undefined, + } + : undefined; + })()} + onSave={async (payload) => { + setIsSaving(true); + try { + const updated = await updateSource(editingId, payload); + setSources((prev) => + prev.map((s) => (s.id === editingId ? updated : s)), + ); + setEditingId(null); + } catch { + // Error handled silently + } finally { + setIsSaving(false); + } + }} + onCancel={() => setEditingId(null)} + isSaving={isSaving} + /> +
+ )} + + {/* Add source button */} + {!showAddForm && ( + + )} + + {/* Add source form */} + {showAddForm && ( +
+

+ {t('calendar.addSource')} +

+ setShowAddForm(false)} + isSaving={isSaving} + /> +
+ )} + + {/* Delete confirmation dialog (UI-SPEC destructive pattern) */} + {deletingId && ( +
{ + if (e.target === e.currentTarget) setDeletingId(null); + }} + > +
+

+ {ts('sourceDeleteCta')} +

+

+ {ts('sourceDeleteConfirm')} +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/settings/calendar-source-form.tsx b/apps/web/src/components/settings/calendar-source-form.tsx new file mode 100644 index 0000000..339e320 --- /dev/null +++ b/apps/web/src/components/settings/calendar-source-form.tsx @@ -0,0 +1,303 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import type { CreateSourcePayload } from '@/lib/calendar-api'; + +/** + * 8-color palette for calendar sources (UI-SPEC). + * OKLCH values mapped to hex for the color input. + */ +const SOURCE_COLOR_PALETTE = [ + { label: 'Red', value: '#c44040' }, + { label: 'Green', value: '#40a060' }, + { label: 'Blue', value: '#4060c4' }, + { label: 'Purple', value: '#8040c4' }, + { label: 'Orange', value: '#c49040' }, + { label: 'Teal', value: '#409090' }, + { label: 'Pink', value: '#c44080' }, + { label: 'Gray', value: '#808080' }, +]; + +/** + * Source types available for selection. + */ +const SOURCE_TYPES = [ + { value: 'caldav', label: 'CalDAV' }, + { value: 'ics', label: 'ICS' }, + { value: 'exchange', label: 'Exchange' }, +] as const; + +/** + * Exchange mode options (RESEARCH open question 2). + */ +const EXCHANGE_MODES = [ + { value: 'graph', label: 'Exchange Online (Graph)' }, + { value: 'ews', label: 'Exchange Server (EWS)' }, +] as const; + +interface CalendarSourceFormProps { + onSave: (payload: CreateSourcePayload) => Promise; + onCancel: () => void; + /** Initial values for edit mode */ + initialValues?: Partial & { id?: string }; + isSaving?: boolean; +} + +/** + * Calendar source form for adding or editing a calendar source (D-08, D-09). + * + * Fields: Name (required), Type (select, required), URL (required, https validation), + * Exchange mode (only for Exchange), Username (hidden for ICS), Password (hidden for ICS), + * Color (8-color palette). + * + * T-05-14: Client-side https-only validation before submit. + */ +export function CalendarSourceForm({ + onSave, + onCancel, + initialValues, + isSaving = false, +}: CalendarSourceFormProps) { + const t = useTranslations('widgets'); + + const [name, setName] = useState(initialValues?.name ?? ''); + const [type, setType] = useState(initialValues?.type ?? ''); + const [url, setUrl] = useState(initialValues?.url ?? ''); + const [username, setUsername] = useState(initialValues?.username ?? ''); + const [password, setPassword] = useState(initialValues?.password ?? ''); + const [exchangeMode, setExchangeMode] = useState( + initialValues?.exchangeMode ?? 'graph', + ); + const [color, setColor] = useState( + initialValues?.color ?? SOURCE_COLOR_PALETTE[0].value, + ); + const [urlError, setUrlError] = useState(''); + + const isICS = type === 'ics'; + const isExchange = type === 'exchange'; + + // Validate URL is https (T-05-14) + const validateUrl = useCallback((value: string): boolean => { + if (!value) return false; + try { + const parsed = new URL(value); + if (parsed.protocol !== 'https:') { + setUrlError('URL must use HTTPS protocol'); + return false; + } + setUrlError(''); + return true; + } catch { + setUrlError('Invalid URL format'); + return false; + } + }, []); + + // Form is valid when name, type, and url are filled and url is https + const isFormValid = + name.trim() !== '' && + type !== '' && + url.trim() !== '' && + !urlError && + validateUrl(url); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateUrl(url)) return; + + const payload: CreateSourcePayload = { + name: name.trim(), + type: type as 'caldav' | 'ics' | 'exchange', + url: url.trim(), + color, + }; + + // Only include credentials for non-ICS types + if (!isICS) { + if (username.trim()) payload.username = username.trim(); + if (password) payload.password = password; + } + + // Include exchangeMode for Exchange type + if (isExchange) { + payload.exchangeMode = exchangeMode; + } + + await onSave(payload); + }, + [name, type, url, username, password, exchangeMode, color, isICS, isExchange, onSave, validateUrl], + ); + + return ( +
+ {/* Name */} +
+ + setName(e.target.value)} + /> +
+ + {/* Type */} +
+ + +
+ + {/* Exchange Mode (only when type is exchange) */} + {isExchange && ( +
+ + +
+ )} + + {/* URL */} +
+ + { + setUrl(e.target.value); + if (e.target.value) validateUrl(e.target.value); + else setUrlError(''); + }} + onBlur={() => { + if (url) validateUrl(url); + }} + /> + {urlError && ( +

{urlError}

+ )} +
+ + {/* Username (hidden for ICS) */} + {!isICS && ( +
+ + setUsername(e.target.value)} + /> +
+ )} + + {/* Password (hidden for ICS) */} + {!isICS && ( +
+ + setPassword(e.target.value)} + /> +
+ )} + + {/* Color */} +
+ +
+ {SOURCE_COLOR_PALETTE.map((c) => ( +
+
+ + {/* Actions */} +
+ + +
+
+ ); +}