Files
tessera-ctl/apps/web/src/components/settings/calendar-settings-panel.tsx
T
schalli b406a9c5f7 fix(quick-260921-jt4): drei Statussymbole in calendar-settings-panel tragen role="img" (D-03)
Die drei <span> mit aria-label ("Sync error"/"Connection OK"/
"Connection error") hatten die Rolle generic, die keine ARIA-Merkmale
traegt — die Beschriftung wurde stillschweigend verworfen. role="img"
ergaenzt (geprueft sauber, keine useSemanticElements-Neuausloesung).
Die Beschriftung ist hier der einzige Text des Symbols (svg bereits
aria-hidden) — Ursache behoben, nicht das Attribut gestrichen.

Erfolgsfall nutzt den vorhandenen Schluessel calendar.connectionSuccess
woertlich; fuer die beiden Fehlerfaelle neue kurze Schluessel
syncErrorLabel/connectionFailedLabel (der vorhandene connectionError
ist ein ganzer Hinweissatz, als Symbolbeschriftung zu lang). Die drei
fest verdrahteten englischen Texte damit aus der deutschen Oberflaeche.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 14:54:06 +02:00

430 lines
15 KiB
TypeScript

'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 [saveError, setSaveError] = useState<string | null>(null);
const [editSaveError, setEditSaveError] = useState<string | null>(null);
const [editSaveSuccess, setEditSaveSuccess] = 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);
setSaveError(null);
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 {
setSaveError(t('calendar.saveError') || 'Fehler beim Speichern');
} finally {
setIsSaving(false);
}
}, [t]);
// 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. role="img" (D-03): die
Beschriftung ist der EINZIGE Text dieser Symbole (das
<svg> darin ist bereits aria-hidden) — ohne Rolle ist ein
<span> "generic" und die Vorlesehilfe verwirft
aria-label stillschweigend. */}
{source.lastSyncError ? (
<span
title={source.lastSyncError}
className="text-orange-500"
role="img"
aria-label={t('calendar.syncErrorLabel')}
>
<svg
aria-hidden="true"
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"
role="img"
aria-label={t('calendar.connectionSuccess')}
>
<svg
aria-hidden="true"
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"
role="img"
aria-label={t('calendar.connectionFailedLabel')}
>
<svg
aria-hidden="true"
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);
setEditSaveSuccess(false);
setEditSaveError(null);
}}
aria-label={`Edit ${source.name}`}
>
<svg
aria-hidden="true"
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
aria-hidden="true"
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">
{editSaveError && (
<p className="mb-3 text-sm text-destructive">{editSaveError}</p>
)}
{editSaveSuccess && (
<p className="mb-3 text-sm text-green-600 dark:text-green-400">{t('calendar.saveSuccess') || 'Gespeichert'}</p>
)}
<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,
domain: s.domain ?? undefined,
color: s.color ?? undefined,
}
: undefined;
})()}
onSave={async (payload) => {
setIsSaving(true);
setEditSaveError(null);
setEditSaveSuccess(false);
try {
const updated = await updateSource(editingId, payload);
setSources((prev) =>
prev.map((s) => (s.id === editingId ? updated : s)),
);
setEditSaveSuccess(true);
} catch {
setEditSaveError(t('calendar.saveError') || 'Fehler beim Speichern');
} 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>
{saveError && (
<p className="mb-3 text-sm text-destructive">{saveError}</p>
)}
<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">
{/* Hintergrund — jetzt eine echte, benannte Schaltflaeche. Sie MUSS
abbrechen und darf unter keinen Umstaenden loeschen (T-JT4-04). */}
<button
type="button"
onClick={() => setDeletingId(null)}
aria-label={t('calendar.deleteDialogCancel')}
className="fixed inset-0 bg-black/50"
/>
<div
className="relative mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
role="alertdialog"
aria-modal="true"
aria-label={t('calendar.deleteDialogLabel')}
>
<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>
);
}