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 { 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 narratorSettings = useStore(s => s.narratorSettings) const setNarratorSettings = useStore(s => s.setNarratorSettings) return (
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')}

{importMsg &&

{importMsg}

}

{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')}

)}
)}
) }