194 lines
5.8 KiB
TypeScript
194 lines
5.8 KiB
TypeScript
import { useState } from 'react'
|
|
import {
|
|
loginAccount,
|
|
registerAccount,
|
|
type AuthSession,
|
|
} from '../profile'
|
|
import {
|
|
createOfflineCharacter,
|
|
hasOfflineCharacter,
|
|
resumeOfflineCharacter,
|
|
selectOnlineMode,
|
|
} from '../gameRepository'
|
|
|
|
type Props = {
|
|
onAuthenticated: (session: AuthSession) => void
|
|
serverMessage?: string
|
|
}
|
|
|
|
export function AuthScreen({ onAuthenticated, serverMessage = '' }: Props) {
|
|
const [mode, setMode] = useState<'login' | 'register'>('login')
|
|
const [username, setUsername] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [characterName, setCharacterName] = useState('')
|
|
const [offlineName, setOfflineName] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
const [message, setMessage] = useState('')
|
|
const offlineCharacterExists = hasOfflineCharacter()
|
|
|
|
async function submit(event: React.FormEvent) {
|
|
event.preventDefault()
|
|
setBusy(true)
|
|
setMessage('')
|
|
try {
|
|
selectOnlineMode()
|
|
const session = mode === 'login'
|
|
? await loginAccount(username, password)
|
|
: await registerAccount(username, password, characterName)
|
|
onAuthenticated(session)
|
|
} catch (reason) {
|
|
setMessage(reason instanceof Error ? reason.message : 'Unable to authenticate.')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
function beginOffline() {
|
|
setMessage('')
|
|
try {
|
|
onAuthenticated(createOfflineCharacter(offlineName))
|
|
} catch (reason) {
|
|
setMessage(reason instanceof Error ? reason.message : 'Unable to create an offline character.')
|
|
}
|
|
}
|
|
|
|
function resumeOffline() {
|
|
const session = resumeOfflineCharacter()
|
|
if (session) onAuthenticated(session)
|
|
}
|
|
|
|
return (
|
|
<main className="auth-shell">
|
|
<section className="auth-panel">
|
|
<div className="auth-brand">
|
|
<p className="eyebrow">Healer RPG</p>
|
|
<h1>I want to Heal</h1>
|
|
<p>
|
|
Build your healer, master each dungeon, and compete for the most
|
|
efficient clears.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="auth-card">
|
|
<div className="auth-tabs">
|
|
<button
|
|
className={mode === 'login' ? 'selected' : ''}
|
|
onClick={() => {
|
|
setMode('login')
|
|
setMessage('')
|
|
}}
|
|
type="button"
|
|
>
|
|
Sign In
|
|
</button>
|
|
<button
|
|
className={mode === 'register' ? 'selected' : ''}
|
|
onClick={() => {
|
|
setMode('register')
|
|
setMessage('')
|
|
}}
|
|
type="button"
|
|
>
|
|
Create Account
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={submit}>
|
|
<label>
|
|
Username
|
|
<input
|
|
autoComplete="username"
|
|
maxLength={20}
|
|
minLength={3}
|
|
onChange={(event) => setUsername(event.target.value)}
|
|
pattern="[A-Za-z0-9_]+"
|
|
required
|
|
value={username}
|
|
/>
|
|
</label>
|
|
{mode === 'register' && (
|
|
<label>
|
|
Character Name
|
|
<input
|
|
autoComplete="nickname"
|
|
maxLength={20}
|
|
minLength={2}
|
|
onChange={(event) => setCharacterName(event.target.value)}
|
|
required
|
|
value={characterName}
|
|
/>
|
|
</label>
|
|
)}
|
|
<label>
|
|
Password
|
|
<input
|
|
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
|
maxLength={128}
|
|
minLength={10}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
required
|
|
type="password"
|
|
value={password}
|
|
/>
|
|
</label>
|
|
<button className="primary-button" disabled={busy} type="submit">
|
|
{busy
|
|
? 'Working...'
|
|
: mode === 'login'
|
|
? 'Enter Chronicle'
|
|
: 'Begin Adventure'}
|
|
</button>
|
|
</form>
|
|
|
|
<p className={`auth-message ${message ? 'error' : ''}`}>
|
|
{message || serverMessage || (
|
|
mode === 'register'
|
|
? 'The first account keeps the current local character and save.'
|
|
: 'Sign in to continue your character.'
|
|
)}
|
|
</p>
|
|
|
|
<div className="offline-divider"><span>or</span></div>
|
|
|
|
<section className="offline-entry">
|
|
<div>
|
|
<p className="eyebrow">Local Save</p>
|
|
<h2>Play Offline</h2>
|
|
<p>
|
|
No account or connection required. Offline progress stays on
|
|
this device and is excluded from online leaderboards.
|
|
</p>
|
|
</div>
|
|
{offlineCharacterExists && (
|
|
<button
|
|
className="offline-resume-button"
|
|
onClick={resumeOffline}
|
|
type="button"
|
|
>
|
|
Continue Offline Character
|
|
</button>
|
|
)}
|
|
<label>
|
|
{offlineCharacterExists ? 'New Character Name' : 'Character Name'}
|
|
<input
|
|
maxLength={20}
|
|
minLength={2}
|
|
onChange={(event) => setOfflineName(event.target.value)}
|
|
placeholder="Mira"
|
|
value={offlineName}
|
|
/>
|
|
</label>
|
|
<button
|
|
className="text-button offline-new-button"
|
|
onClick={beginOffline}
|
|
type="button"
|
|
>
|
|
{offlineCharacterExists ? 'Replace Offline Character' : 'Begin Offline Adventure'}
|
|
</button>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|