settings panel
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user