settings panel

This commit is contained in:
2026-01-15 10:17:18 +02:00
parent 0ca3d79cbc
commit 95f048584e
+675
View File
@@ -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<Settings> {
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 (
<label className={`privacy-toggle ${disabled ? 'privacy-toggle-disabled' : ''}`}>
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} disabled={disabled} />
<div>
<span>{label}</span>
{disabled && disabledHint ? <span className="privacy-toggle-hint">{disabledHint}</span> : hint && <span className="privacy-toggle-hint">{hint}</span>}
</div>
</label>
)
}
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<SpeechSynthesisVoice[]>([])
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 (
<p className="settings-hint">{t('settings.narrator.notSupported')}</p>
)
return (
<div className="settings-narrator-controls">
<Toggle checked={narratorEnabled} onChange={v => setNarratorEnabled(v)}
label={t('settings.narrator.narratorMode')} />
<AnimatePresence>
{narratorEnabled && (
<motion.div className="settings-narrator-detail"
initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.15 }}
style={{ overflow: 'hidden' }}>
<div className="settings-narrator-grid">
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.speechRate', { rate: pendingRate.toFixed(1) })}</label>
<input type="range" className="settings-range" min={0.5} max={2} step={0.1} value={pendingRate}
onChange={e => setPendingRate(Number(e.target.value))}
onMouseUp={() => setNarratorSettings({ rate: pendingRate })}
onTouchEnd={() => setNarratorSettings({ rate: pendingRate })} />
</div>
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.pitch', { pitch: pendingPitch.toFixed(1) })}</label>
<input type="range" className="settings-range" min={0.5} max={2} step={0.1} value={pendingPitch}
onChange={e => setPendingPitch(Number(e.target.value))}
onMouseUp={() => setNarratorSettings({ pitch: pendingPitch })}
onTouchEnd={() => setNarratorSettings({ pitch: pendingPitch })} />
</div>
</div>
{voices.length > 0 && (
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.voice')}</label>
<Select value={narratorSettings.voice}
onChange={v => setNarratorSettings({ voice: String(v) })}
options={[
{ value: '', label: t('settings.narrator.defaultVoice') },
...voices.map(v => ({ value: v.name, label: `${v.name} (${v.lang})` }))
]} />
</div>
)}
<div className="settings-narrator-item">
<label className="settings-sublabel">{t('settings.narrator.verbosity')}</label>
<Select value={narratorSettings.verbosity}
onChange={v => { 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') },
]} />
</div>
<Toggle checked={narratorSettings.sounds} onChange={v => setNarratorSettings({ sounds: v })}
label={t('settings.narrator.soundEffects')} />
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export default function SettingsPanel({ onClose }: { onClose: () => void }) {
const trapRef = useFocusTrap(true)
const { t, i18n } = useTranslation()
const [settings, setSettings] = useState<Settings>(defaultSettings)
const [pendingScale, setPendingScale] = useState(100)
const inputMode = useStore(s => s.inputMode)
const setInputMode = useStore(s => s.setInputMode)
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([])
const [registering, setRegistering] = useState(false)
const [passkeyError, setPasskeyError] = useState('')
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [newBlockedDomain, setNewBlockedDomain] = useState('')
const [tab, setTab] = useState<Tab>('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<Settings>) => {
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 (
<motion.div className="settings-overlay" onClick={onClose}
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}>
<motion.div ref={trapRef} className="settings-panel settings-panel-tabs" onClick={e => 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] }}>
<div className="settings-header">
<h3>{t('settings.title')}</h3>
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}><IconX size={16} aria-hidden="true" /></button>
</div>
<div className="settings-body">
<div className="settings-sidebar" role="tablist" aria-orientation="vertical">
{TABS.map(tb => (
<button key={tb.id}
className={`settings-sidebar-tab ${tab === tb.id ? 'settings-sidebar-tab-active' : ''}`}
onClick={() => setTab(tb.id)}
role="tab"
aria-selected={tab === tb.id}
aria-controls="settings-tabpanel"
id={`settings-tab-${tb.id}`}>
<tb.icon size={14} aria-hidden="true" />
{t(tb.labelKey)}
</button>
))}
</div>
<div className="settings-content" role="tabpanel" id="settings-tabpanel"
aria-labelledby={`settings-tab-${tab}`}>
<AnimatePresence mode="wait">
<motion.div key={tab}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.12 }}>
{/* ==================== PROFILE ==================== */}
{tab === 'profile' && (<>
<div className="settings-section">
<label className="settings-label">{t('settings.profile.displayName')}</label>
<input type="text" className="onboard-username" value={displayName}
onChange={e => setDisplayName(e.target.value)}
onBlur={() => { if (displayName.trim()) api.updateMe({ display_name: displayName.trim() }) }}
placeholder={t('settings.profile.placeholder')} maxLength={30}
autoComplete="nickname" />
<p className="settings-hint">{t('settings.profile.hint')}</p>
</div>
<div className="settings-section">
<label className="settings-label">{t('settings.profile.sessionLabel')}</label>
<p className="settings-hint">
{hasPasskey ? t('settings.profile.hasPasskey') : t('settings.profile.noPasskey')}
</p>
</div>
</>)}
{/* ==================== DISPLAY ==================== */}
{tab === 'display' && (<>
<div className="settings-section">
<label className="settings-label">{t('settings.display.theme')}</label>
<div className="settings-row">
{(['system', 'light', 'dark'] as const).map(th => (
<button key={th} className={`btn btn-sm ${settings.theme === th ? 'btn-primary' : ''}`} onClick={() => update({ theme: th })}>
{th === 'system' ? <><IconDeviceDesktop size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.system')}</> :
th === 'light' ? <><IconSunFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.light')}</> :
<><IconMoonFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.display.dark')}</>}
</button>
))}
</div>
</div>
<div className="settings-section">
<label className="settings-label"><IconKeyboardFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.display.inputMode')}</label>
<div className="settings-row">
{(['auto', 'keyboard', 'stylus'] as const).map(m => (
<button key={m} className={`btn btn-sm ${inputMode === m ? 'btn-primary' : ''}`}
onClick={() => { setInputMode(m); api.fetchSettings().then(s => api.saveSettings({ ...s, inputMode: m })) }}>
{m === 'auto' ? t('settings.display.inputAuto') : m === 'keyboard' ? t('settings.display.inputKeyboard') : t('settings.display.inputStylus')}
</button>
))}
</div>
</div>
<div className="settings-section">
<Toggle checked={settings.torFriendly || false} onChange={v => update({ torFriendly: v })}
label={t('settings.display.torFriendly')} hint={t('settings.display.torHint')} />
</div>
<div className="settings-section">
<label className="settings-label">{t('settings.display.language')}</label>
<Select value={i18n.language} onChange={v => i18n.changeLanguage(String(v))} options={
Object.keys(i18n.options.resources || {}).map(lng => ({ value: lng, label: lng.toUpperCase() }))
} />
</div>
<div className="settings-section">
<Toggle checked={false} onChange={() => {}}
label={t('settings.display.pushNotifications')}
hint={t('settings.display.pushHint')}
disabled={true}
disabledHint={t('settings.display.pushComingSoon')} />
</div>
</>)}
{/* ==================== ACCESSIBILITY ==================== */}
{tab === 'accessibility' && (<>
<div className="settings-section">
<label className="settings-label settings-section-heading">
<IconDeviceSpeakerFilled size={13} style={{ verticalAlign: -2, marginRight: 4 }} />
{t('settings.narrator.title')}
</label>
<p className="settings-hint">{t('settings.narrator.hint')}</p>
<NarratorSettingsBlock />
</div>
<div className="settings-divider" />
<div className="settings-section">
<label className="settings-label settings-section-heading">
<IconEyeFilled size={13} style={{ verticalAlign: -2, marginRight: 4 }} />
{t('settings.display.colorblind')}
</label>
<p className="settings-hint">{t('settings.accessibility.colorblindHint')}</p>
<Select value={settings.colorblind} onChange={v => { 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') },
]} />
</div>
<div className="settings-divider" />
<div className="settings-section">
<label className="settings-label settings-section-heading">
<IconTextSize size={13} style={{ verticalAlign: -2, marginRight: 4 }} />
{t('settings.display.textSize', { scale: pendingScale })}
</label>
<p className="settings-hint">{t('settings.accessibility.textSizeHint')}</p>
<input type="range" min={80} max={200} step={10} value={pendingScale}
className="settings-range"
onChange={e => setPendingScale(Number(e.target.value))}
onMouseUp={() => update({ textScale: pendingScale })}
onTouchEnd={() => update({ textScale: pendingScale })} />
<div className="settings-range-labels">
<span>80%</span>
<span>100%</span>
<span>200%</span>
</div>
</div>
</>)}
{/* ==================== SESSION ==================== */}
{tab === 'session' && (<>
<div className="settings-section">
<label className="settings-label"><IconCookieFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.session.duration')}</label>
<div className="settings-grid-3">
{LIFETIME_KEYS.map(opt => (
<button key={opt.value} className={`btn btn-sm ${settings.cookieLifetime === opt.value ? 'btn-primary' : ''}`}
onClick={() => update({ cookieLifetime: opt.value })}>{t(opt.key)}</button>
))}
</div>
<p className="settings-hint">{t('settings.session.durationHint')}</p>
</div>
<div className="settings-section">
<label className="settings-label"><IconKeyFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.session.passkeys')}</label>
{passkeys.length > 0 ? (
<div className="passkey-list">
{passkeys.map(pk => (
<div key={pk.id} className="passkey-item">
<span>{pk.name}</span>
<span className="passkey-date">{new Date(pk.created_at).toLocaleDateString()}</span>
</div>
))}
</div>
) : (
<p className="settings-hint">{t('settings.session.noPasskeys')}</p>
)}
{passkeyError && <p className="settings-hint" style={{ color: 'var(--color-error)' }}>{passkeyError}</p>}
{supportsPasskey && (
<button className="btn btn-sm" onClick={addPasskey} disabled={registering} style={{ marginTop: 8 }}>
<IconKeyFilled size={13} style={{ verticalAlign: -2 }} /> {registering ? t('settings.session.registering') : t('settings.session.addPasskey')}
</button>
)}
{!supportsPasskey && <p className="settings-hint">{t('settings.session.noSupport')}</p>}
</div>
<div className="settings-section">
<Toggle checked={settings.sessionBinding} onChange={v => update({ sessionBinding: v })}
label={t('settings.session.sessionBinding')} hint={t('settings.session.sessionBindingHint')}
disabled={!hasPasskey} disabledHint={t('settings.session.requiresPasskey')} />
</div>
</>)}
{/* ==================== VISIBILITY ==================== */}
{tab === 'visibility' && (<>
<div className="settings-section">
<label className="settings-label">{t('settings.visibility.quickSetup')}</label>
<div className="privacy-presets">
{([
{ 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 => (
<button key={p.id} className={`privacy-preset ${activePreset === p.id ? 'privacy-preset-active' : ''}`}
onClick={() => update(applyPreset(p.id))}>
<p.icon size={16} />
<strong>{p.label}</strong>
<span>{p.desc}</span>
</button>
))}
</div>
</div>
<div className="settings-section">
<label className="settings-label"><IconShieldFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.visibility.visibilityLabel')}</label>
<Toggle checked={settings.hideFromLeaderboards} onChange={v => update({ hideFromLeaderboards: v })}
label={t('settings.visibility.hideLeaderboards')} hint={t('settings.visibility.hideLeaderboardsHint')} />
<Toggle checked={settings.privateProfile} onChange={v => update({ privateProfile: v })}
label={t('settings.visibility.privateProfile')} hint={t('settings.visibility.privateProfileHint')} />
<Toggle checked={settings.anonymousSolveMode} onChange={v => update({ anonymousSolveMode: v })}
label={t('settings.visibility.anonSolve')} hint={t('settings.visibility.anonSolveHint')} />
</div>
</>)}
{/* ==================== DATA ==================== */}
{tab === 'data' && (<>
<div className="settings-section">
<label className="settings-label"><IconLockFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.data.title')}</label>
<Toggle checked={settings.noTrackMode} onChange={v => update({ noTrackMode: v })}
label={t('settings.data.noTrack')} hint={t('settings.data.noTrackHint')} />
<Toggle checked={settings.anonymizeTimes} onChange={v => update({ anonymizeTimes: v })}
label={t('settings.data.anonTimes')} hint={t('settings.data.anonTimesHint')} />
<Toggle checked={settings.encryptSolveData} onChange={v => update({ encryptSolveData: v })}
label={t('settings.data.encrypt')} hint={t('settings.data.encryptHint')}
disabled={!hasPasskey} disabledHint={t('settings.session.requiresPasskey')} />
<Toggle checked={settings.ephemeralSession} onChange={v => update({ ephemeralSession: v })}
label={t('settings.data.ephemeral')} hint={t('settings.data.ephemeralHint')}
disabled={!hasPasskey} disabledHint={t('settings.session.requiresPasskey')} />
<Toggle checked={settings.autoPurgeEnabled} onChange={v => update({ autoPurgeEnabled: v })}
label={t('settings.data.autoPurge')}
disabled={!hasPasskey} disabledHint={t('settings.session.requiresPasskey')} />
{settings.autoPurgeEnabled && (
<div className="privacy-purge-days">
<span>{t('settings.data.purgeAfter')}</span>
<Select value={settings.autoPurgeDays} onChange={v => update({ autoPurgeDays: Number(v) })} options={[
{ value: 7, label: t('settings.data.purgeDays.7') }, { value: 30, label: t('settings.data.purgeDays.30') },
{ value: 60, label: t('settings.data.purgeDays.60') }, { value: 90, label: t('settings.data.purgeDays.90') },
]} />
</div>
)}
</div>
</>)}
{/* ==================== FEDERATION ==================== */}
{tab === 'federation' && (<>
<div className="settings-section">
<label className="settings-label"><IconWorldFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.federation.fedContent')}</label>
<Select value={settings.federatedContentFilter} onChange={v => { 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') },
]} />
</div>
<div className="settings-section">
<label className="settings-label">{t('settings.federation.blockedInstances')}</label>
<div className="cc-submit-row">
<input type="text" placeholder="domain.example.com" value={newBlockedDomain}
aria-label={t('settings.federation.blockedInstances')}
onChange={e => setNewBlockedDomain(e.target.value)} onKeyDown={e => e.key === 'Enter' && addBlockedDomain()} />
<button className="btn btn-sm" onClick={addBlockedDomain} disabled={!newBlockedDomain.trim()}>{t('settings.federation.block')}</button>
</div>
{settings.blockedInstances.length > 0 && (
<div className="privacy-blocked-list">
{settings.blockedInstances.map(d => (
<span key={d} className="privacy-blocked-tag">{d}<button onClick={() => update({ blockedInstances: settings.blockedInstances.filter(x => x !== d) })}><IconX size={10} /></button></span>
))}
</div>
)}
<div className="settings-row" style={{ marginTop: 8 }}>
<button className="btn btn-sm" onClick={() => api.exportUserBlocklist()}>{t('settings.federation.exportBlocklist')}</button>
<button className="btn btn-sm" onClick={() => {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'
input.onchange = async () => {
if (!input.files?.length) return
try { await api.importUserBlocklist(JSON.parse(await input.files[0].text()))
api.fetchSettings().then(s => setSettings({ ...defaultSettings, ...s }))
} catch { /* ignored */ }
}; input.click()
}}>{t('settings.federation.importBlocklist')}</button>
</div>
</div>
</>)}
{/* ==================== ACCOUNT ==================== */}
{tab === 'account' && (<>
<div className="settings-section">
<label className="settings-label">{t('settings.account.yourData')}</label>
<p className="settings-hint" style={{ marginBottom: 12 }}>
{t('settings.account.dataHint')}
</p>
<div className="settings-row">
<button className="btn btn-sm" onClick={() => api.exportData()}>
<IconDownloadFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.account.export')}
</button>
<button className="btn btn-sm" onClick={() => {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'
input.onchange = async () => {
if (!input.files?.length) return
try {
const data = JSON.parse(await input.files[0].text())
const res = await api.importData(data)
let msg = t('onboarding.imported', { count: res.imported })
if (res.skipped?.length) msg += ' ' + t('onboarding.skipped', { items: res.skipped.join(', ') })
alert(msg)
window.location.reload()
} catch (e) { alert(e instanceof Error ? e.message : t('onboarding.importFailed')) }
}; input.click()
}}>
{t('settings.account.import')}
</button>
</div>
</div>
<div className="settings-section">
<label className="settings-label"><IconReceiptFilled size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.account.claimSolve')}</label>
<p className="settings-hint" style={{ marginBottom: 12 }}>
{t('settings.account.claimHint')}
</p>
<div className="receipt-claim-input">
<input type="text" placeholder="https://instance.com/api/receipts/... or JSON"
value={claimInput} onChange={e => { setClaimInput(e.target.value); setClaimStatus(null) }} />
<button className="btn btn-sm" disabled={!claimInput.trim() || claimingManual} onClick={async () => {
setClaimingManual(true); setClaimStatus(null)
try {
const val = claimInput.trim()
let receipt: ReceiptPayload
if (val.startsWith('{')) {
receipt = JSON.parse(val)
} else {
const match = val.match(/\/receipts\/([a-zA-Z0-9-]+)/)
if (!match) throw new Error('Could not parse receipt ID from URL')
receipt = await api.getReceipt(match[1])
}
await api.claimReceipt(receipt)
setClaimStatus({ ok: true, msg: t('settings.account.solveClaimed') })
setClaimInput('')
} catch (e) {
setClaimStatus({ ok: false, msg: e instanceof Error ? e.message : t('settings.account.failedToClaim') })
}
setClaimingManual(false)
}}>{claimingManual ? t('settings.account.claiming') : t('settings.account.claim')}</button>
</div>
{claimStatus && (
<p className={`receipt-claim-msg ${claimStatus.ok ? 'receipt-claim-msg-ok' : 'receipt-claim-msg-err'}`}>
{claimStatus.msg}
</p>
)}
</div>
<div className={`settings-section ${!hasPasskey ? 'settings-section-disabled' : ''}`}>
<label className="settings-label"><IconArrowMoveRight size={11} style={{ verticalAlign: -1, marginRight: 4 }} /> {t('settings.account.moveInstance')}</label>
{!hasPasskey ? (
<p className="settings-hint">{t('settings.session.requiresPasskey')}</p>
) : (<>
<p className="settings-hint" style={{ marginBottom: 12 }}>
{t('settings.account.moveHint')}
</p>
<div className="cc-submit-row">
<input type="text" placeholder="new-instance.example.com" value={migrateDomain}
onChange={e => 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))
})()} />
<button className="btn btn-sm" disabled={!migrateDomain.trim() || migrating} onClick={() => {
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))
}}>{migrating ? t('settings.account.migrating') : t('settings.account.migrate')}</button>
</div>
{migrateMsg && <p className="settings-hint" style={{ marginTop: 8 }}>{migrateMsg}</p>}
</>)}
</div>
<div className="settings-section">
<label className="settings-label">{t('settings.account.signOut')}</label>
<p className="settings-hint" style={{ marginBottom: 12 }}>
{t('settings.account.signOutHint')}
</p>
<button className="btn btn-sm" onClick={() => {
api.saveSettings({ ...settings, cookieLifetime: 0 })
document.cookie = 'cruciverb_session=; Path=/; Max-Age=0'
window.location.reload()
}}>
<IconLogout2 size={13} style={{ verticalAlign: -2 }} /> {t('settings.account.signOutEverywhere')}
</button>
</div>
<div className="settings-section settings-danger">
<label className="settings-label">{t('settings.account.deleteAccount')}</label>
<p className="settings-hint" style={{ marginBottom: 12 }}>
{t('settings.account.deleteHint')}
</p>
{!deleteConfirm ? (
<button className="btn btn-sm privacy-delete-btn" onClick={() => setDeleteConfirm(true)}>
<IconTrashFilled size={13} style={{ verticalAlign: -2 }} /> {t('settings.account.deleteAccount')}
</button>
) : (
<div className="privacy-delete-confirm" role="alertdialog" aria-label={t('settings.account.confirmDelete')}>
<p>{t('settings.account.confirmDelete')}</p>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm privacy-delete-btn" onClick={async () => { await api.deleteAccount(); window.location.reload() }}>{t('settings.account.yesDelete')}</button>
<button className="btn btn-sm" onClick={() => setDeleteConfirm(false)}>{t('party.cancel')}</button>
</div>
</div>
)}
</div>
</>)}
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
</motion.div>
)
}