480 lines
16 KiB
TypeScript
480 lines
16 KiB
TypeScript
import React from "react";
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Sheet,
|
|
SheetTrigger,
|
|
SheetContent,
|
|
SheetTitle,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
} from '@/components/ui/sheet';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle } from 'lucide-react';
|
|
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
|
import { LegalMarkdown } from './legal-markdown';
|
|
import { useLocale, type LocaleContextValue } from '../i18n/LocaleContext';
|
|
import { useTranslation } from '../i18n/useTranslation';
|
|
import type { LocaleCode } from '../i18n/messages';
|
|
|
|
const legalPages = [
|
|
{ slug: 'impressum', translationKey: 'settings.legal.section.impressum' },
|
|
{ slug: 'datenschutz', translationKey: 'settings.legal.section.privacy' },
|
|
{ slug: 'agb', translationKey: 'settings.legal.section.terms' },
|
|
] as const;
|
|
|
|
type ViewState =
|
|
| { mode: 'home' }
|
|
| {
|
|
mode: 'legal';
|
|
slug: (typeof legalPages)[number]['slug'];
|
|
translationKey: (typeof legalPages)[number]['translationKey'];
|
|
};
|
|
|
|
type LegalDocumentState =
|
|
| { phase: 'idle'; title: string; body: string }
|
|
| { phase: 'loading'; title: string; body: string }
|
|
| { phase: 'ready'; title: string; body: string }
|
|
| { phase: 'error'; title: string; body: string };
|
|
|
|
type NameStatus = 'idle' | 'saved';
|
|
|
|
export function SettingsSheet() {
|
|
const [open, setOpen] = React.useState(false);
|
|
const [view, setView] = React.useState<ViewState>({ mode: 'home' });
|
|
const identity = useOptionalGuestIdentity();
|
|
const localeContext = useLocale();
|
|
const { t } = useTranslation();
|
|
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
|
|
const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle');
|
|
const [savingName, setSavingName] = React.useState(false);
|
|
const isLegal = view.mode === 'legal';
|
|
const legalDocument = useLegalDocument(isLegal ? view.slug : null, localeContext.locale);
|
|
|
|
React.useEffect(() => {
|
|
if (open && identity?.hydrated) {
|
|
setNameDraft(identity.name ?? '');
|
|
setNameStatus('idle');
|
|
}
|
|
}, [open, identity?.hydrated, identity?.name]);
|
|
|
|
const handleBack = React.useCallback(() => {
|
|
setView({ mode: 'home' });
|
|
}, []);
|
|
|
|
const handleOpenLegal = React.useCallback(
|
|
(
|
|
slug: (typeof legalPages)[number]['slug'],
|
|
translationKey: (typeof legalPages)[number]['translationKey'],
|
|
) => {
|
|
setView({ mode: 'legal', slug, translationKey });
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleOpenChange = React.useCallback((next: boolean) => {
|
|
setOpen(next);
|
|
if (!next) {
|
|
setView({ mode: 'home' });
|
|
setNameStatus('idle');
|
|
}
|
|
}, []);
|
|
|
|
const canSaveName = Boolean(
|
|
identity?.hydrated && nameDraft.trim() && nameDraft.trim() !== (identity?.name ?? '')
|
|
);
|
|
|
|
const handleSaveName = React.useCallback(() => {
|
|
if (!identity || !canSaveName) {
|
|
return;
|
|
}
|
|
setSavingName(true);
|
|
try {
|
|
identity.setName(nameDraft);
|
|
setNameStatus('saved');
|
|
window.setTimeout(() => setNameStatus('idle'), 2000);
|
|
} finally {
|
|
setSavingName(false);
|
|
}
|
|
}, [identity, nameDraft, canSaveName]);
|
|
|
|
const handleResetName = React.useCallback(() => {
|
|
if (!identity) return;
|
|
identity.clearName();
|
|
setNameDraft('');
|
|
setNameStatus('idle');
|
|
}, [identity]);
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={handleOpenChange}>
|
|
<SheetTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
|
|
<Settings className="h-5 w-5" />
|
|
<span className="sr-only">{t('settings.sheet.openLabel')}</span>
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent side="right" className="sm:max-w-md">
|
|
<div className="flex h-full flex-col">
|
|
<header className="border-b bg-background px-6 py-4">
|
|
{isLegal ? (
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-9 w-9"
|
|
onClick={handleBack}
|
|
>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
<span className="sr-only">{t('settings.sheet.backLabel')}</span>
|
|
</Button>
|
|
<div className="min-w-0">
|
|
<SheetTitle className="truncate">
|
|
{legalDocument.phase === 'ready' && legalDocument.title
|
|
? legalDocument.title
|
|
: t(view.translationKey)}
|
|
</SheetTitle>
|
|
<SheetDescription>
|
|
{legalDocument.phase === 'loading'
|
|
? t('common.actions.loading')
|
|
: t('settings.sheet.legalDescription')}
|
|
</SheetDescription>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<SheetTitle>{t('settings.title')}</SheetTitle>
|
|
<SheetDescription>{t('settings.subtitle')}</SheetDescription>
|
|
</div>
|
|
)}
|
|
</header>
|
|
|
|
<main className="flex-1 overflow-y-auto px-6 py-4">
|
|
{isLegal ? (
|
|
<LegalView
|
|
document={legalDocument}
|
|
onClose={() => handleOpenChange(false)}
|
|
translationKey={view.mode === 'legal' ? view.translationKey : null}
|
|
/>
|
|
) : (
|
|
<HomeView
|
|
identity={identity}
|
|
nameDraft={nameDraft}
|
|
onNameChange={setNameDraft}
|
|
onSaveName={handleSaveName}
|
|
onResetName={handleResetName}
|
|
canSaveName={canSaveName}
|
|
savingName={savingName}
|
|
nameStatus={nameStatus}
|
|
localeContext={localeContext}
|
|
onOpenLegal={handleOpenLegal}
|
|
/>
|
|
)}
|
|
</main>
|
|
|
|
<SheetFooter className="border-t bg-muted/40 px-6 py-3 text-xs text-muted-foreground">
|
|
<div>{t('settings.footer.notice')}</div>
|
|
</SheetFooter>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
function LegalView({
|
|
document,
|
|
onClose,
|
|
translationKey,
|
|
}: {
|
|
document: LegalDocumentState;
|
|
onClose: () => void;
|
|
translationKey: string | null;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
if (document.phase === 'error') {
|
|
return (
|
|
<div className="space-y-4">
|
|
<Alert variant="destructive">
|
|
<AlertDescription>
|
|
{t('settings.legal.error')}
|
|
</AlertDescription>
|
|
</Alert>
|
|
<Button variant="secondary" onClick={onClose}>
|
|
{t('common.actions.close')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (document.phase === 'loading' || document.phase === 'idle') {
|
|
return <div className="text-sm text-muted-foreground">{t('settings.legal.loading')}</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{document.title || t(translationKey ?? 'settings.legal.fallbackTitle')}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="prose prose-sm max-w-none dark:prose-invert">
|
|
<LegalMarkdown markdown={document.body} />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface HomeViewProps {
|
|
identity: ReturnType<typeof useOptionalGuestIdentity>;
|
|
nameDraft: string;
|
|
onNameChange: (value: string) => void;
|
|
onSaveName: () => void;
|
|
onResetName: () => void;
|
|
canSaveName: boolean;
|
|
savingName: boolean;
|
|
nameStatus: NameStatus;
|
|
localeContext: LocaleContextValue;
|
|
onOpenLegal: (
|
|
slug: (typeof legalPages)[number]['slug'],
|
|
translationKey: (typeof legalPages)[number]['translationKey'],
|
|
) => void;
|
|
}
|
|
|
|
function HomeView({
|
|
identity,
|
|
nameDraft,
|
|
onNameChange,
|
|
onSaveName,
|
|
onResetName,
|
|
canSaveName,
|
|
savingName,
|
|
nameStatus,
|
|
localeContext,
|
|
onOpenLegal,
|
|
}: HomeViewProps) {
|
|
const { t } = useTranslation();
|
|
const legalLinks = React.useMemo(
|
|
() =>
|
|
legalPages.map((page) => ({
|
|
slug: page.slug,
|
|
translationKey: page.translationKey,
|
|
label: t(page.translationKey),
|
|
})),
|
|
[t],
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle>{t('settings.language.title')}</CardTitle>
|
|
<CardDescription>{t('settings.language.description')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{localeContext.availableLocales.map((option) => {
|
|
const isActive = localeContext.locale === option.code;
|
|
return (
|
|
<Button
|
|
key={option.code}
|
|
type="button"
|
|
variant={isActive ? 'default' : 'outline'}
|
|
className={`flex h-12 flex-col justify-center gap-1 rounded-lg border text-sm ${
|
|
isActive ? 'bg-pink-500 text-white hover:bg-pink-600' : 'bg-background'
|
|
}`}
|
|
onClick={() => localeContext.setLocale(option.code)}
|
|
aria-pressed={isActive}
|
|
disabled={!localeContext.hydrated}
|
|
>
|
|
<span aria-hidden className="text-lg leading-none">{option.flag}</span>
|
|
<span className="font-medium">{t(`settings.language.option.${option.code}`)}</span>
|
|
{isActive && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="border border-white/40 bg-white/10 text-[10px] uppercase tracking-wide text-white"
|
|
>
|
|
{t('settings.language.activeBadge')}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{identity && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle>{t('settings.name.title')}</CardTitle>
|
|
<CardDescription>{t('settings.name.description')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-pink-100 text-pink-600">
|
|
<UserCircle className="h-6 w-6" />
|
|
</div>
|
|
<div className="flex-1 space-y-2">
|
|
<Label htmlFor="guest-name" className="text-sm font-medium">
|
|
{t('settings.name.label')}
|
|
</Label>
|
|
<Input
|
|
id="guest-name"
|
|
value={nameDraft}
|
|
placeholder={t('settings.name.placeholder')}
|
|
onChange={(event) => onNameChange(event.target.value)}
|
|
autoComplete="name"
|
|
disabled={!identity.hydrated || savingName}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button onClick={onSaveName} disabled={!canSaveName || savingName}>
|
|
{savingName ? t('settings.name.saving') : t('settings.name.save')}
|
|
</Button>
|
|
<Button type="button" variant="ghost" onClick={onResetName} disabled={savingName}>
|
|
{t('settings.name.reset')}
|
|
</Button>
|
|
{nameStatus === 'saved' && (
|
|
<span className="text-xs text-muted-foreground">{t('settings.name.saved')}</span>
|
|
)}
|
|
{!identity.hydrated && (
|
|
<span className="text-xs text-muted-foreground">{t('settings.name.loading')}</span>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
<FileText className="h-4 w-4 text-pink-500" />
|
|
{t('settings.legal.title')}
|
|
</div>
|
|
</CardTitle>
|
|
<CardDescription>{t('settings.legal.description')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{legalLinks.map((page) => (
|
|
<Button
|
|
key={page.slug}
|
|
variant="ghost"
|
|
className="w-full justify-between px-3"
|
|
onClick={() => onOpenLegal(page.slug, page.translationKey)}
|
|
>
|
|
<span className="text-left text-sm">{page.label}</span>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('settings.cache.title')}</CardTitle>
|
|
<CardDescription>{t('settings.cache.description')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<ClearCacheButton />
|
|
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
|
<RefreshCcw className="mt-0.5 h-3.5 w-3.5" />
|
|
<span>{t('settings.cache.note')}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useLegalDocument(slug: string | null, locale: LocaleCode): LegalDocumentState {
|
|
const [state, setState] = React.useState<LegalDocumentState>({
|
|
phase: 'idle',
|
|
title: '',
|
|
body: '',
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
if (!slug) {
|
|
setState({ phase: 'idle', title: '', body: '' });
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
setState({ phase: 'loading', title: '', body: '' });
|
|
|
|
const langParam = encodeURIComponent(locale);
|
|
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=${langParam}`, {
|
|
headers: { 'Cache-Control': 'no-store' },
|
|
signal: controller.signal,
|
|
})
|
|
.then(async (res) => {
|
|
if (!res.ok) {
|
|
throw new Error('failed');
|
|
}
|
|
const payload = await res.json();
|
|
setState({
|
|
phase: 'ready',
|
|
title: payload.title ?? '',
|
|
body: payload.body_markdown ?? '',
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
console.error('Failed to load legal page', error);
|
|
setState({ phase: 'error', title: '', body: '' });
|
|
});
|
|
|
|
return () => controller.abort();
|
|
}, [slug, locale]);
|
|
|
|
return state;
|
|
}
|
|
|
|
function ClearCacheButton() {
|
|
const [busy, setBusy] = React.useState(false);
|
|
const [done, setDone] = React.useState(false);
|
|
const { t } = useTranslation();
|
|
|
|
async function clearAll() {
|
|
setBusy(true);
|
|
setDone(false);
|
|
try {
|
|
if ('caches' in window) {
|
|
const keys = await caches.keys();
|
|
await Promise.all(keys.map((key) => caches.delete(key)));
|
|
}
|
|
if ('indexedDB' in window) {
|
|
try {
|
|
await new Promise((resolve) => {
|
|
const request = indexedDB.deleteDatabase('upload-queue');
|
|
request.onsuccess = () => resolve(null);
|
|
request.onerror = () => resolve(null);
|
|
});
|
|
} catch (error) {
|
|
console.warn('IndexedDB cleanup failed', error);
|
|
}
|
|
}
|
|
setDone(true);
|
|
} finally {
|
|
setBusy(false);
|
|
window.setTimeout(() => setDone(false), 2500);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<Button variant="secondary" onClick={clearAll} disabled={busy} className="w-full">
|
|
{busy ? t('settings.cache.clearing') : t('settings.cache.clear')}
|
|
</Button>
|
|
{done && <div className="text-xs text-muted-foreground">{t('settings.cache.cleared')}</div>}
|
|
</div>
|
|
);
|
|
}
|