feat(05-04): calendar settings page with source management and visibility toggle
- calendar-settings-panel.tsx: source list with color dots, type badges, visibility toggle (CAL-02), edit/delete actions, connection test auto-run, delete confirmation dialog - calendar-source-form.tsx: add/edit form with name/type/URL/credentials/color; Exchange-mode select for exchange type; username/password hidden for ICS; client-side https-only validation (T-05-14) - settings/dashboard/calendar/page.tsx: route page rendering CalendarSettingsPanel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-6 text-lg font-semibold text-foreground">
|
||||||
|
{t('categoryCalendar')}
|
||||||
|
</h1>
|
||||||
|
<CalendarSettingsPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<CalendarSource[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [testResults, setTestResults] = useState<Record<string, 'success' | 'error' | 'testing'>>({});
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Source list */}
|
||||||
|
{sources.length === 0 && !showAddForm && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('calendar.sourceEmpty')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sources.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sources.map((source) => (
|
||||||
|
<div
|
||||||
|
key={source.id}
|
||||||
|
className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3"
|
||||||
|
>
|
||||||
|
{/* Color dot */}
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: source.color || '#808080' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<span className="flex-1 text-sm font-medium text-foreground">
|
||||||
|
{source.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Type badge */}
|
||||||
|
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
|
{TYPE_LABELS[source.type] || source.type}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Connection status indicator */}
|
||||||
|
{source.lastSyncError ? (
|
||||||
|
<span
|
||||||
|
title={source.lastSyncError}
|
||||||
|
className="text-orange-500"
|
||||||
|
aria-label="Sync error"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
) : testResults[source.id] === 'success' ? (
|
||||||
|
<span className="text-green-500" aria-label="Connection OK">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
) : testResults[source.id] === 'error' ? (
|
||||||
|
<span className="text-orange-500" aria-label="Connection error">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15" />
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Visibility toggle (CAL-02) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={source.isVisible}
|
||||||
|
aria-label={`Toggle visibility for ${source.name}`}
|
||||||
|
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ${
|
||||||
|
source.isVisible ? 'bg-primary' : 'bg-muted'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleVisibilityToggle(source)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform duration-200 ${
|
||||||
|
source.isVisible ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Edit button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
setEditingId(editingId === source.id ? null : source.id)
|
||||||
|
}
|
||||||
|
aria-label={`Edit ${source.name}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
onClick={() => setDeletingId(source.id)}
|
||||||
|
aria-label={`Delete ${source.name}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit form (below the source being edited) */}
|
||||||
|
{editingId && (
|
||||||
|
<div className="rounded-md border border-border bg-card p-4">
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-foreground">
|
||||||
|
Edit Source
|
||||||
|
</h3>
|
||||||
|
<CalendarSourceForm
|
||||||
|
initialValues={(() => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add source button */}
|
||||||
|
{!showAddForm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{t('calendar.addSource')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add source form */}
|
||||||
|
{showAddForm && (
|
||||||
|
<div className="rounded-md border border-border bg-card p-4">
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-foreground">
|
||||||
|
{t('calendar.addSource')}
|
||||||
|
</h3>
|
||||||
|
<CalendarSourceForm
|
||||||
|
onSave={handleAddSource}
|
||||||
|
onCancel={() => setShowAddForm(false)}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog (UI-SPEC destructive pattern) */}
|
||||||
|
{deletingId && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setDeletingId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Confirm deletion"
|
||||||
|
>
|
||||||
|
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
||||||
|
{ts('sourceDeleteCta')}
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
{ts('sourceDeleteConfirm')}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeletingId(null)}
|
||||||
|
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
{ts('cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteSource(deletingId)}
|
||||||
|
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{ts('sourceDeleteCta')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
/** Initial values for edit mode */
|
||||||
|
initialValues?: Partial<CreateSourcePayload> & { 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<string>(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 (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="source-name" className="mb-1 block text-sm text-foreground">
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="source-name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="source-type" className="mb-1 block text-sm text-foreground">
|
||||||
|
Type *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="source-type"
|
||||||
|
required
|
||||||
|
className="h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">--</option>
|
||||||
|
{SOURCE_TYPES.map((st) => (
|
||||||
|
<option key={st.value} value={st.value}>
|
||||||
|
{st.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exchange Mode (only when type is exchange) */}
|
||||||
|
{isExchange && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="exchange-mode"
|
||||||
|
className="mb-1 block text-sm text-foreground"
|
||||||
|
>
|
||||||
|
Exchange Mode
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="exchange-mode"
|
||||||
|
className="h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||||
|
value={exchangeMode}
|
||||||
|
onChange={(e) => setExchangeMode(e.target.value)}
|
||||||
|
>
|
||||||
|
{EXCHANGE_MODES.map((em) => (
|
||||||
|
<option key={em.value} value={em.value}>
|
||||||
|
{em.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="source-url" className="mb-1 block text-sm text-foreground">
|
||||||
|
URL *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="source-url"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
placeholder="https://"
|
||||||
|
className={`h-9 w-full max-w-md rounded border px-3 text-sm text-foreground bg-background ${
|
||||||
|
urlError ? 'border-destructive' : 'border-border'
|
||||||
|
}`}
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUrl(e.target.value);
|
||||||
|
if (e.target.value) validateUrl(e.target.value);
|
||||||
|
else setUrlError('');
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (url) validateUrl(url);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{urlError && (
|
||||||
|
<p className="mt-1 text-xs text-destructive">{urlError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Username (hidden for ICS) */}
|
||||||
|
{!isICS && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="source-username"
|
||||||
|
className="mb-1 block text-sm text-foreground"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="source-username"
|
||||||
|
type="text"
|
||||||
|
className="h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Password (hidden for ICS) */}
|
||||||
|
{!isICS && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="source-password"
|
||||||
|
className="mb-1 block text-sm text-foreground"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="source-password"
|
||||||
|
type="password"
|
||||||
|
className="h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Color */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm text-foreground">Color</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{SOURCE_COLOR_PALETTE.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.value}
|
||||||
|
type="button"
|
||||||
|
title={c.label}
|
||||||
|
className={`h-7 w-7 rounded-full border-2 transition-all ${
|
||||||
|
color === c.value
|
||||||
|
? 'border-foreground scale-110'
|
||||||
|
: 'border-transparent hover:border-muted-foreground'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c.value }}
|
||||||
|
onClick={() => setColor(c.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
data-testid="source-form-submit"
|
||||||
|
disabled={!isFormValid || isSaving}
|
||||||
|
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
{t('calendar.addSource') === 'Add source' ? 'Cancel' : 'Abbrechen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user