b406a9c5f7
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
430 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|