diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..fbc5608 --- /dev/null +++ b/frontend/src/components/SettingsPanel.tsx @@ -0,0 +1,675 @@ +import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { IconSunFilled, IconMoonFilled, IconDeviceDesktop, IconEyeFilled, IconTextSize, IconX, + IconCookieFilled, IconKeyFilled, IconKeyboardFilled, IconShieldFilled, IconLockFilled, + IconWorldFilled, IconTrashFilled, IconDownloadFilled, IconLogout2, IconArrowMoveRight, + IconUserFilled, IconSettingsFilled, IconReceiptFilled, IconDeviceSpeakerFilled, + IconAccessibleFilled } from '@tabler/icons-react' +import * as narrator from '../narrator' +import { motion, AnimatePresence } from 'motion/react' +import { useStore } from '../store' +import * as api from '../api' +import type { PasskeyItem, ReceiptPayload } from '../api' +import { applySettings } from '../settingsApply' +import Select from './ui/Select' +import { useFocusTrap } from '../hooks/useFocusTrap' + +interface Settings { + theme: 'system' | 'light' | 'dark' + colorblind: 'none' | 'protanopia' | 'deuteranopia' | 'tritanopia' + textScale: number + cookieLifetime: number + hideFromLeaderboards: boolean + privateProfile: boolean + federatedContentFilter: 'all' | 'approved' | 'local' + blockedInstances: string[] + anonymousSolveMode: boolean + autoPurgeEnabled: boolean + autoPurgeDays: number + noTrackMode: boolean + anonymizeTimes: boolean + sessionBinding: boolean + torFriendly: boolean + encryptSolveData: boolean + ephemeralSession: boolean +} + +const defaultSettings: Settings = { + theme: 'system', colorblind: 'none', textScale: 100, cookieLifetime: 2592000, + hideFromLeaderboards: false, privateProfile: false, federatedContentFilter: 'all', + blockedInstances: [], anonymousSolveMode: false, autoPurgeEnabled: false, + autoPurgeDays: 90, noTrackMode: false, anonymizeTimes: false, + sessionBinding: false, torFriendly: false, encryptSolveData: false, ephemeralSession: false, +} + +const LIFETIME_KEYS: { value: number; key: string }[] = [ + { value: 0, key: 'settings.session.thisSession' }, { value: 86400, key: 'settings.session.oneDay' }, + { value: 604800, key: 'settings.session.oneWeek' }, { value: 2592000, key: 'settings.session.thirtyDays' }, + { value: 7776000, key: 'settings.session.ninetyDays' }, { value: 31536000, key: 'settings.session.oneYear' }, +] + +type PrivacyPreset = 'open' | 'private' | 'fortress' +type Tab = 'profile' | 'display' | 'accessibility' | 'session' | 'visibility' | 'data' | 'federation' | 'account' + +const TABS: { id: Tab; labelKey: string; icon: typeof IconUserFilled }[] = [ + { id: 'profile', labelKey: 'settings.tabs.profile', icon: IconUserFilled }, + { id: 'display', labelKey: 'settings.tabs.display', icon: IconSunFilled }, + { id: 'accessibility', labelKey: 'settings.tabs.accessibility', icon: IconAccessibleFilled }, + { id: 'session', labelKey: 'settings.tabs.session', icon: IconCookieFilled }, + { id: 'visibility', labelKey: 'settings.tabs.visibility', icon: IconEyeFilled }, + { id: 'data', labelKey: 'settings.tabs.data', icon: IconLockFilled }, + { id: 'federation', labelKey: 'settings.tabs.federation', icon: IconWorldFilled }, + { id: 'account', labelKey: 'settings.tabs.account', icon: IconSettingsFilled }, +] + +function applyPreset(preset: PrivacyPreset): Partial { + switch (preset) { + case 'open': return { hideFromLeaderboards: false, privateProfile: false, federatedContentFilter: 'all', anonymousSolveMode: false, autoPurgeEnabled: false, noTrackMode: false, anonymizeTimes: false, sessionBinding: false, encryptSolveData: false, ephemeralSession: false } + case 'private': return { hideFromLeaderboards: true, privateProfile: true, federatedContentFilter: 'local', anonymousSolveMode: true, autoPurgeEnabled: false, noTrackMode: false, anonymizeTimes: false, sessionBinding: false } + case 'fortress': return { hideFromLeaderboards: true, privateProfile: true, federatedContentFilter: 'local', anonymousSolveMode: true, autoPurgeEnabled: true, autoPurgeDays: 30, noTrackMode: true, anonymizeTimes: true, sessionBinding: true, encryptSolveData: true } + } +} + +function detectPreset(s: Settings): PrivacyPreset | null { + if (!s.hideFromLeaderboards && !s.privateProfile && s.federatedContentFilter === 'all' && !s.anonymousSolveMode && !s.noTrackMode) return 'open' + if (s.hideFromLeaderboards && s.privateProfile && s.noTrackMode && s.autoPurgeEnabled && s.sessionBinding && s.encryptSolveData) return 'fortress' + if (s.hideFromLeaderboards && s.privateProfile && s.anonymousSolveMode) return 'private' + return null +} + +function Toggle({ checked, onChange, label, hint, disabled, disabledHint }: { checked: boolean; onChange: (v: boolean) => void; label: string; hint?: string; disabled?: boolean; disabledHint?: string }) { + return ( + + ) +} + +function NarratorSettingsBlock() { + const { t } = useTranslation() + const narratorEnabled = useStore(s => s.narratorEnabled) + const narratorSettings = useStore(s => s.narratorSettings) + const setNarratorEnabled = useStore(s => s.setNarratorEnabled) + const setNarratorSettings = useStore(s => s.setNarratorSettings) + const [voices, setVoices] = useState([]) + const [pendingRate, setPendingRate] = useState(narratorSettings.rate) + const [pendingPitch, setPendingPitch] = useState(narratorSettings.pitch) + + useEffect(() => { + if (!narrator.isSupported()) return + const loadVoices = () => setVoices(narrator.getVoices()) + loadVoices() + if (window.speechSynthesis.onvoiceschanged !== undefined) { + window.speechSynthesis.onvoiceschanged = loadVoices + } + }, []) + + if (!narrator.isSupported()) return ( +

{t('settings.narrator.notSupported')}

+ ) + + return ( +
+ setNarratorEnabled(v)} + label={t('settings.narrator.narratorMode')} /> + + + {narratorEnabled && ( + +
+
+ + setPendingRate(Number(e.target.value))} + onMouseUp={() => setNarratorSettings({ rate: pendingRate })} + onTouchEnd={() => setNarratorSettings({ rate: pendingRate })} /> +
+ +
+ + setPendingPitch(Number(e.target.value))} + onMouseUp={() => setNarratorSettings({ pitch: pendingPitch })} + onTouchEnd={() => setNarratorSettings({ pitch: pendingPitch })} /> +
+
+ + {voices.length > 0 && ( +
+ + { const s = String(v); if (s === 'brief' || s === 'normal' || s === 'verbose') setNarratorSettings({ verbosity: s }) }} + options={[ + { value: 'brief', label: t('settings.narrator.brief') }, + { value: 'normal', label: t('settings.narrator.normal') }, + { value: 'verbose', label: t('settings.narrator.verbose') }, + ]} /> +
+ + setNarratorSettings({ sounds: v })} + label={t('settings.narrator.soundEffects')} /> +
+ )} +
+
+ ) +} + +export default function SettingsPanel({ onClose }: { onClose: () => void }) { + const trapRef = useFocusTrap(true) + const { t, i18n } = useTranslation() + const [settings, setSettings] = useState(defaultSettings) + const [pendingScale, setPendingScale] = useState(100) + const inputMode = useStore(s => s.inputMode) + const setInputMode = useStore(s => s.setInputMode) + const [passkeys, setPasskeys] = useState([]) + const [registering, setRegistering] = useState(false) + const [passkeyError, setPasskeyError] = useState('') + const [deleteConfirm, setDeleteConfirm] = useState(false) + const [newBlockedDomain, setNewBlockedDomain] = useState('') + const [tab, setTab] = useState('profile') + const [migrateDomain, setMigrateDomain] = useState('') + const [migrating, setMigrating] = useState(false) + const [migrateMsg, setMigrateMsg] = useState('') + const [displayName, setDisplayName] = useState('') + const [claimInput, setClaimInput] = useState('') + const [claimStatus, setClaimStatus] = useState<{ ok: boolean; msg: string } | null>(null) + const [claimingManual, setClaimingManual] = useState(false) + + const supportsPasskey = !!window.PublicKeyCredential + const hasPasskey = useStore(s => s.hasPasskey) + const activePreset = detectPreset(settings) + + + useEffect(() => { + api.fetchSettings().then(s => { + const merged = { ...defaultSettings, ...s } + setSettings(merged); setPendingScale(merged.textScale); applySettings(merged) + if (s.inputMode === 'auto' || s.inputMode === 'keyboard' || s.inputMode === 'stylus') setInputMode(s.inputMode) + }) + api.fetchPasskeys().then(setPasskeys) + api.fetchMe().then(me => setDisplayName(me.display_name || '')).catch(() => {}) + }, []) + + const update = (partial: Partial) => { + const next = { ...settings, ...partial }; setSettings(next); applySettings(next); api.saveSettings(next) + } + + const addPasskey = async () => { + setRegistering(true); setPasskeyError('') + try { await api.registerPasskey(); setPasskeys(await api.fetchPasskeys()) } + catch (e) { setPasskeyError(e instanceof Error ? e.message : 'Failed') } + setRegistering(false) + } + + const addBlockedDomain = () => { + const d = newBlockedDomain.trim().toLowerCase() + if (d && !settings.blockedInstances.includes(d)) update({ blockedInstances: [...settings.blockedInstances, d] }) + setNewBlockedDomain('') + } + + return ( + + e.stopPropagation()} + role="dialog" aria-modal="true" aria-label={t('settings.title')} + initial={{ opacity: 0, scale: 0.95, y: 12 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.95, y: 12 }} + transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}> +
+

{t('settings.title')}

+ +
+ +
+
+ {TABS.map(tb => ( + + ))} +
+ +
+ + + + {/* ==================== PROFILE ==================== */} + {tab === 'profile' && (<> +
+ + setDisplayName(e.target.value)} + onBlur={() => { if (displayName.trim()) api.updateMe({ display_name: displayName.trim() }) }} + placeholder={t('settings.profile.placeholder')} maxLength={30} + autoComplete="nickname" /> +

{t('settings.profile.hint')}

+
+ +
+ +

+ {hasPasskey ? t('settings.profile.hasPasskey') : t('settings.profile.noPasskey')} +

+
+ )} + + {/* ==================== DISPLAY ==================== */} + {tab === 'display' && (<> +
+ +
+ {(['system', 'light', 'dark'] as const).map(th => ( + + ))} +
+
+ +
+ +
+ {(['auto', 'keyboard', 'stylus'] as const).map(m => ( + + ))} +
+
+ +
+ update({ torFriendly: v })} + label={t('settings.display.torFriendly')} hint={t('settings.display.torHint')} /> +
+ +
+ + { const c = String(v); if (c === 'none' || c === 'protanopia' || c === 'deuteranopia' || c === 'tritanopia') update({ colorblind: c }) }} options={[ + { value: 'none', label: t('settings.display.colorblindNone') }, + { value: 'protanopia', label: t('settings.display.protanopia') }, + { value: 'deuteranopia', label: t('settings.display.deuteranopia') }, + { value: 'tritanopia', label: t('settings.display.tritanopia') }, + ]} /> +
+ +
+ +
+ +

{t('settings.accessibility.textSizeHint')}

+ setPendingScale(Number(e.target.value))} + onMouseUp={() => update({ textScale: pendingScale })} + onTouchEnd={() => update({ textScale: pendingScale })} /> +
+ 80% + 100% + 200% +
+
+ )} + + {/* ==================== SESSION ==================== */} + {tab === 'session' && (<> +
+ +
+ {LIFETIME_KEYS.map(opt => ( + + ))} +
+

{t('settings.session.durationHint')}

+
+ +
+ + {passkeys.length > 0 ? ( +
+ {passkeys.map(pk => ( +
+ {pk.name} + {new Date(pk.created_at).toLocaleDateString()} +
+ ))} +
+ ) : ( +

{t('settings.session.noPasskeys')}

+ )} + {passkeyError &&

{passkeyError}

} + {supportsPasskey && ( + + )} + {!supportsPasskey &&

{t('settings.session.noSupport')}

} +
+ +
+ update({ sessionBinding: v })} + label={t('settings.session.sessionBinding')} hint={t('settings.session.sessionBindingHint')} + disabled={!hasPasskey} disabledHint={t('settings.session.requiresPasskey')} /> +
+ )} + + {/* ==================== VISIBILITY ==================== */} + {tab === 'visibility' && (<> +
+ +
+ {([ + { id: 'open', icon: IconWorldFilled, label: t('settings.visibility.open'), desc: t('settings.visibility.openDesc') }, + { id: 'private', icon: IconEyeFilled, label: t('settings.visibility.private'), desc: t('settings.visibility.privateDesc') }, + { id: 'fortress', icon: IconLockFilled, label: t('settings.visibility.fortress'), desc: t('settings.visibility.fortressDesc') }, + ] satisfies Array<{ id: PrivacyPreset; icon: typeof IconWorldFilled; label: string; desc: string }>).map(p => ( + + ))} +
+
+ +
+ + update({ hideFromLeaderboards: v })} + label={t('settings.visibility.hideLeaderboards')} hint={t('settings.visibility.hideLeaderboardsHint')} /> + update({ privateProfile: v })} + label={t('settings.visibility.privateProfile')} hint={t('settings.visibility.privateProfileHint')} /> + update({ anonymousSolveMode: v })} + label={t('settings.visibility.anonSolve')} hint={t('settings.visibility.anonSolveHint')} /> +
+ )} + + {/* ==================== DATA ==================== */} + {tab === 'data' && (<> +
+ + update({ noTrackMode: v })} + label={t('settings.data.noTrack')} hint={t('settings.data.noTrackHint')} /> + update({ anonymizeTimes: v })} + label={t('settings.data.anonTimes')} hint={t('settings.data.anonTimesHint')} /> + update({ encryptSolveData: v })} + label={t('settings.data.encrypt')} hint={t('settings.data.encryptHint')} + disabled={!hasPasskey} disabledHint={t('settings.session.requiresPasskey')} /> + update({ ephemeralSession: v })} + label={t('settings.data.ephemeral')} hint={t('settings.data.ephemeralHint')} + disabled={!hasPasskey} disabledHint={t('settings.session.requiresPasskey')} /> + update({ autoPurgeEnabled: v })} + label={t('settings.data.autoPurge')} + disabled={!hasPasskey} disabledHint={t('settings.session.requiresPasskey')} /> + {settings.autoPurgeEnabled && ( +
+ {t('settings.data.purgeAfter')} + { const f = String(v); if (f === 'all' || f === 'approved' || f === 'local') update({ federatedContentFilter: f }) }} options={[ + { value: 'all', label: t('settings.federation.showAll') }, + { value: 'approved', label: t('settings.federation.approvedOnly') }, + { value: 'local', label: t('settings.federation.localOnly') }, + ]} /> +
+ +
+ +
+ setNewBlockedDomain(e.target.value)} onKeyDown={e => e.key === 'Enter' && addBlockedDomain()} /> + +
+ {settings.blockedInstances.length > 0 && ( +
+ {settings.blockedInstances.map(d => ( + {d} + ))} +
+ )} +
+ + +
+
+ )} + + {/* ==================== ACCOUNT ==================== */} + {tab === 'account' && (<> +
+ +

+ {t('settings.account.dataHint')} +

+
+ + +
+
+ +
+ +

+ {t('settings.account.claimHint')} +

+
+ { setClaimInput(e.target.value); setClaimStatus(null) }} /> + +
+ {claimStatus && ( +

+ {claimStatus.msg} +

+ )} +
+ +
+ + {!hasPasskey ? ( +

{t('settings.session.requiresPasskey')}

+ ) : (<> +

+ {t('settings.account.moveHint')} +

+
+ setMigrateDomain(e.target.value)} + onKeyDown={e => e.key === 'Enter' && migrateDomain.trim() && !migrating && (() => { + setMigrating(true); setMigrateMsg('') + api.migrateAccount(migrateDomain.trim()).then(res => { + const blob = new Blob([JSON.stringify(res.export, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a'); a.href = url; a.download = 'cruciverb-export.json'; a.click() + URL.revokeObjectURL(url) + setMigrateMsg(t('settings.account.migrateSuccess')) + }).catch(e => setMigrateMsg(e instanceof Error ? e.message : t('settings.account.migrateFailed'))) + .finally(() => setMigrating(false)) + })()} /> + +
+ {migrateMsg &&

{migrateMsg}

} + )} +
+ +
+ +

+ {t('settings.account.signOutHint')} +

+ +
+ +
+ +

+ {t('settings.account.deleteHint')} +

+ {!deleteConfirm ? ( + + ) : ( +
+

{t('settings.account.confirmDelete')}

+
+ + +
+
+ )} +
+ )} + + + +
+
+
+ + ) +}