onboarding modal
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { motion } from 'motion/react'
|
||||
import { IconCookieFilled, IconKeyFilled, IconDeviceDesktopFilled, IconArrowRight, IconUpload, IconUserFilled } from '@tabler/icons-react'
|
||||
import * as api from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||
|
||||
const LIFETIME_KEYS = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
export default function OnboardingModal() {
|
||||
const trapRef = useFocusTrap(true)
|
||||
const { t } = useTranslation()
|
||||
const setShowOnboarding = useStore(s => s.setShowOnboarding)
|
||||
const [lifetime, setLifetime] = useState(2592000)
|
||||
const [username, setUsername] = useState('')
|
||||
const [registering, setRegistering] = useState(false)
|
||||
const [passkeyDone, setPasskeyDone] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [importResult, setImportResult] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
const supportsPasskey = !!window.PublicKeyCredential
|
||||
|
||||
const dismiss = async () => {
|
||||
const settings = await api.fetchSettings()
|
||||
await api.saveSettings({ ...settings, cookieLifetime: lifetime })
|
||||
if (username.trim()) {
|
||||
await api.updateMe({ display_name: username.trim() })
|
||||
}
|
||||
setShowOnboarding(false)
|
||||
}
|
||||
|
||||
const setupPasskey = async () => {
|
||||
setRegistering(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.registerPasskey()
|
||||
setPasskeyDone(true)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('onboarding.passkeyFailed'))
|
||||
}
|
||||
setRegistering(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div className="settings-overlay"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||
<motion.div ref={trapRef} className="onboarding-panel"
|
||||
role="dialog" aria-modal="true" aria-label={t('onboarding.welcomeTo')}
|
||||
initial={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
transition={{ duration: 0.25 }}>
|
||||
<h2>{t('onboarding.welcomeTo')}</h2>
|
||||
|
||||
<div className="onboard-section">
|
||||
<div className="onboard-icon"><IconUserFilled size={20} /></div>
|
||||
<div>
|
||||
<h4>{t('onboarding.chooseName')}</h4>
|
||||
<p>{t('onboarding.chooseNameDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="onboard-username"
|
||||
placeholder={t('common.anonymous')}
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
maxLength={30}
|
||||
autoComplete="nickname"
|
||||
/>
|
||||
|
||||
<div className="onboard-divider" />
|
||||
|
||||
<div className="onboard-section">
|
||||
<div className="onboard-icon"><IconCookieFilled size={20} /></div>
|
||||
<div>
|
||||
<h4>{t('onboarding.progressSaved')}</h4>
|
||||
<p>{t('onboarding.progressSavedDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="onboard-section">
|
||||
<label className="settings-label">{t('onboarding.rememberFor')}</label>
|
||||
<div className="settings-grid-3">
|
||||
{LIFETIME_KEYS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`btn btn-sm ${lifetime === opt.value ? 'btn-primary' : ''}`}
|
||||
onClick={() => setLifetime(opt.value)}
|
||||
>
|
||||
{t(opt.key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="onboard-divider" />
|
||||
|
||||
<div className="onboard-section">
|
||||
<div className="onboard-icon"><IconDeviceDesktopFilled size={20} /></div>
|
||||
<div>
|
||||
<h4>{t('onboarding.playAcrossDevices')}</h4>
|
||||
<p>{t('onboarding.playAcrossDevicesDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{supportsPasskey && !passkeyDone && (
|
||||
<button className="btn btn-sm" onClick={setupPasskey} disabled={registering} style={{ marginBottom: 8 }}>
|
||||
<IconKeyFilled size={14} style={{ marginRight: 4 }} />
|
||||
{registering ? t('onboarding.settingUp') : t('onboarding.setupPasskey')}
|
||||
</button>
|
||||
)}
|
||||
{passkeyDone && <span className="onboard-success" style={{ display: 'block', marginBottom: 8 }}>{t('onboarding.passkeySaved')}</span>}
|
||||
|
||||
{error && <div className="onboard-error" role="alert">{error}</div>}
|
||||
|
||||
<div className="onboard-divider" />
|
||||
|
||||
<div className="onboard-actions">
|
||||
<button className="btn btn-sm onboard-import-btn" disabled={importing} onClick={() => {
|
||||
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'
|
||||
input.onchange = async () => {
|
||||
if (!input.files?.length) return
|
||||
setImporting(true); setImportResult('')
|
||||
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(', ') })
|
||||
setImportResult(msg)
|
||||
} catch (e) { setImportResult(e instanceof Error ? e.message : t('onboarding.importFailed')) }
|
||||
setImporting(false)
|
||||
}; input.click()
|
||||
}}>
|
||||
<IconUpload size={14} style={{ marginRight: 6 }} />
|
||||
<span className="onboard-import-text">
|
||||
<span>{t('onboarding.comingFrom')}</span>
|
||||
<span>{t('onboarding.anotherInstance')}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button className="btn btn-primary onboard-start-btn" onClick={dismiss}>
|
||||
<span>{passkeyDone ? t('onboarding.done') : t('onboarding.startPlaying')}</span>
|
||||
<IconArrowRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{importResult && <p className="settings-hint" style={{ marginTop: 8 }}>{importResult}</p>}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user