import { zodResolver } from '@hookform/resolvers/zod'; import { changePasswordInputSchema, updateProfileInputSchema } from '@dorfteich/shared'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../auth/auth-context'; import { Field, FormError, FormSuccess, applyFieldErrors } from '../components/forms'; import { SettingsLayout } from '../components/SettingsLayout'; import { useDataExport } from '../export/use-data-export'; import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; import { ApiTokensSection } from '../api-tokens/ApiTokensSection'; import { FeedTokensSection } from '../api-tokens/FeedTokensSection'; import { WatchesSection } from '../watches/WatchesSection'; import { useDocumentTitle } from '../lib/use-document-title'; import { SINGLE_KEY_SHORTCUTS_KEY } from '../lib/single-key-shortcuts'; import { usePersistentState } from '../lib/use-persistent-state'; import { applyTheme, THEME_MODE_KEY, type ThemeMode } from '../theme/theme'; interface SessionView { id: string; createdAt: string; lastSeenAt: string; userAgent: string | null; current: boolean; } export function SettingsPage(): React.JSX.Element { const { t } = useTranslation(); useDocumentTitle(t('settings:title')); return ( <>

{t('settings:title')}

); } /** Erscheinungsbild (issue #180): Hell/Dunkel/System — wie * InteractionSection eine lokale Geräte-Einstellung, kein Server-Zustand. */ function AppearanceSection(): React.JSX.Element { const { t } = useTranslation(); const [mode, setMode] = usePersistentState(THEME_MODE_KEY, 'system'); const choose = (value: ThemeMode): void => { setMode(value); applyTheme(value); }; const options: { value: ThemeMode; label: string }[] = [ { value: 'light', label: t('settings:appearance.light') }, { value: 'dark', label: t('settings:appearance.dark') }, { value: 'system', label: t('settings:appearance.system') }, ]; return (

{t('settings:appearance.title')}

{t('settings:appearance.legend')} {options.map((option) => ( ))}

{t('settings:appearance.hint')}

); } /** Bedienungs-Einstellungen (issue #170, WCAG 2.1.4): Einzeltasten-Kürzel * abschaltbar machen — lokale Geräte-Einstellung, kein Server-Zustand. */ function InteractionSection(): React.JSX.Element { const { t } = useTranslation(); const [disabled, setDisabled] = usePersistentState(SINGLE_KEY_SHORTCUTS_KEY, false); return (

{t('settings:interaction.title')}

{t('settings:interaction.disableSingleKeyHint')}

); } function DataExportSection(): React.JSX.Element { const { t, i18n } = useTranslation(); const { status, expiresAt, request, download } = useDataExport(); const formatTime = (iso: string): string => new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium', timeStyle: 'short' }).format( new Date(iso), ); return (

{t('settings:dataExport.title')}

{t('settings:dataExport.description')}

{status === 'error' && (

{t('settings:dataExport.failed')}

)} {status === 'rateLimited' && (

{t('settings:dataExport.rateLimited')}

)} {status === 'ready' ? ( <> {expiresAt && (

{t('settings:dataExport.expiresHint', { when: formatTime(expiresAt) })}

)} ) : ( )}
); } function ProfileSection(): React.JSX.Element { const { t, i18n } = useTranslation(); const { user, refresh } = useAuth(); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); const form = useForm<{ displayName?: string; locale?: 'de' | 'en'; autoWatchOwnPages?: boolean; autoWatchOnComment?: boolean; digestFrequency?: 'hourly' | 'daily' | 'off'; }>({ resolver: zodResolver(updateProfileInputSchema), values: { displayName: user?.displayName, locale: user?.locale, autoWatchOwnPages: user?.autoWatchOwnPages, autoWatchOnComment: user?.autoWatchOnComment, digestFrequency: user?.digestFrequency, }, }); const onSubmit = form.handleSubmit(async (input) => { setError(null); setSaved(false); try { await apiPatch('/users/me', input); // The new language applies immediately, before the refetch lands. if (input.locale) await i18n.changeLanguage(input.locale); await refresh(); setSaved(true); } catch (err) { setError(err); applyFieldErrors(err, (name, fieldError) => form.setError(name as 'displayName' | 'locale', fieldError), ); } }); return (

{t('settings:profile.title')}

); } function PasswordSection(): React.JSX.Element { const { t } = useTranslation(); const [error, setError] = useState(null); const [changed, setChanged] = useState(false); const form = useForm<{ currentPassword: string; newPassword: string }>({ resolver: zodResolver(changePasswordInputSchema), }); const onSubmit = form.handleSubmit(async (input) => { setError(null); setChanged(false); try { await apiPost('/users/me/change-password', input); form.reset(); setChanged(true); } catch (err) { setError(err); } }); return (

{t('settings:password.title')}

); } function SessionsSection(): React.JSX.Element { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); const sessions = useQuery({ queryKey: ['sessions'], queryFn: () => apiGet('/users/me/sessions'), }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['sessions'] }); const revoke = useMutation({ mutationFn: (id: string) => apiDelete(`/users/me/sessions/${id}`), onSuccess: invalidate, }); const revokeOthers = useMutation({ mutationFn: () => apiDelete('/users/me/sessions'), onSuccess: invalidate, }); const formatTime = (iso: string) => new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium', timeStyle: 'short' }).format( new Date(iso), ); const others = (sessions.data ?? []).filter((s) => !s.current); return (

{t('settings:sessions.title')}

{(sessions.data ?? []).map((session) => ( ))}
{t('settings:sessions.device')} {t('settings:sessions.created')} {t('settings:sessions.lastSeen')} {t('common:tableActions')}
{session.userAgent ?? '—'} {session.current && {t('settings:sessions.current')}} {formatTime(session.createdAt)} {formatTime(session.lastSeenAt)} {!session.current && ( )}
{others.length > 0 ? ( ) : (

{t('settings:sessions.empty')}

)}
); }