Loading...
+ );
+ }
+
+ return (
+
+ {/* Source list */}
+ {sources.length === 0 && !showAddForm && (
+
+ {t('calendar.sourceEmpty')}
+
+ )}
+
+ {sources.length > 0 && (
+
+ {sources.map((source) => (
+
+ {/* Color dot */}
+
+
+ {/* Name */}
+
+ {source.name}
+
+
+ {/* Type badge */}
+
+ {TYPE_LABELS[source.type] || source.type}
+
+
+ {/* Connection status indicator */}
+ {source.lastSyncError ? (
+
+
+
+ ) : testResults[source.id] === 'success' ? (
+
+
+
+ ) : testResults[source.id] === 'error' ? (
+
+
+
+ ) : null}
+
+ {/* Visibility toggle (CAL-02) */}
+
+
+ {/* Edit button */}
+
+
+ {/* Delete button */}
+
+
+ ))}
+
+ )}
+
+ {/* 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