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