Front-page enrolment / sign-in + character screen (auth slice 2)
The splash now requires a signed-in investigator: a rudimentary character screen (display name + handle + password, with a placeholder avatar) enrols or signs in, then reveals the case picker. GET /api/auth/me gates the front door; the signed-in investigator's name/avatar and a sign-out show on the splash. /node and ?resume paths are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+67
-8
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { CutsceneHost, DialoguePlayer, MeritHost, type PlaythroughState } from './narrative'
|
||||
|
||||
type MysterySummary = { slug: string; title: string }
|
||||
type Player = { id: string; handle: string; displayName: string; avatarUrl: string | null }
|
||||
|
||||
// The game's front door and campaign runtime. Shows the splash when there's no
|
||||
// active playthrough; otherwise walks the story graph — cutscene and dialogue
|
||||
@@ -14,6 +15,15 @@ export function Play() {
|
||||
const [splash, setSplash] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [status, setStatus] = useState('')
|
||||
const [player, setPlayer] = useState<Player | null>(null)
|
||||
const [authChecked, setAuthChecked] = useState(false)
|
||||
|
||||
const openFrontDoor = useCallback(async () => {
|
||||
const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')])
|
||||
if (cases.ok) setMysteries(await cases.json())
|
||||
if (current.ok && current.status !== 204) setResumable(await current.json())
|
||||
setSplash(true); setStatus('SELECT A CASE FILE')
|
||||
}, [])
|
||||
|
||||
const apply = useCallback((next: PlaythroughState | null) => {
|
||||
if (!next) { setSplash(true); return }
|
||||
@@ -48,17 +58,19 @@ export function Play() {
|
||||
if (cancelled) return
|
||||
if (res.ok && res.status !== 204) { apply(await res.json()); return }
|
||||
}
|
||||
// The bare root is always the front door: list the playable cases, and offer
|
||||
// Resume on the one already in progress rather than auto-resuming into it.
|
||||
const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')])
|
||||
// The bare root is the front door — but you must be signed in first.
|
||||
const me = await fetch('/api/auth/me')
|
||||
if (cancelled) return
|
||||
if (cases.ok) setMysteries(await cases.json())
|
||||
if (current.ok && current.status !== 204) setResumable(await current.json())
|
||||
setSplash(true)
|
||||
setStatus('SELECT A CASE FILE')
|
||||
setAuthChecked(true)
|
||||
if (me.status !== 200) return // not signed in -> the enrolment / sign-in screen
|
||||
setPlayer((await me.json()).user)
|
||||
await openFrontDoor()
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [apply])
|
||||
}, [apply, openFrontDoor])
|
||||
|
||||
const onAuthed = (user: Player) => { setPlayer(user); void openFrontDoor() }
|
||||
const signOut = async () => { await fetch('/api/auth/logout', { method: 'POST' }); window.location.assign('/') }
|
||||
|
||||
const newGame = async (slug: string) => {
|
||||
setBusy(true)
|
||||
@@ -77,10 +89,14 @@ export function Play() {
|
||||
if (res.ok) apply(await res.json())
|
||||
}
|
||||
|
||||
// Front door requires a signed-in investigator (path A: GUPI-issued account).
|
||||
if (!splash && !state && authChecked && !player) return <AuthScreen onAuthed={onAuthed} />
|
||||
|
||||
if (splash) return <div className="splash">
|
||||
<div className="splash-plate">
|
||||
<div className="seal">GU</div>
|
||||
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
||||
{player && <p className="splash-investigator"><span className="splash-avatar" title="Avatar coming soon">{player.displayName.slice(0, 1).toUpperCase()}</span>{player.displayName}<button className="splash-signout" onClick={signOut}>sign out</button></p>}
|
||||
<p className="splash-sub">Glitch University · Case Files</p>
|
||||
<div className="splash-cases">
|
||||
{mysteries.map(mystery => {
|
||||
@@ -107,3 +123,46 @@ export function Play() {
|
||||
onCapture={(text, utteranceId) => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/notebook`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, utteranceId }) }) }} />
|
||||
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
||||
}
|
||||
|
||||
// Rudimentary character screen: enrol (display name + handle + password) or sign in.
|
||||
// The avatar is a placeholder (initial) for now; extend later.
|
||||
function AuthScreen({ onAuthed }: { onAuthed: (user: Player) => void }) {
|
||||
const [mode, setMode] = useState<'register' | 'login'>('register')
|
||||
const [handle, setHandle] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const url = mode === 'register' ? '/api/auth/register' : '/api/auth/login'
|
||||
const body = mode === 'register' ? { handle, password, displayName } : { handle, password }
|
||||
const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) { setError(data.error || 'Something went wrong'); return }
|
||||
onAuthed(data.user)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <div className="splash">
|
||||
<div className="splash-plate auth-plate">
|
||||
<div className="seal">GU</div>
|
||||
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
||||
<p className="splash-sub">Glitch University · {mode === 'register' ? 'Enrolment' : 'Sign in'}</p>
|
||||
<div className="auth-tabs">
|
||||
<button className={mode === 'register' ? 'on' : ''} onClick={() => setMode('register')}>New investigator</button>
|
||||
<button className={mode === 'login' ? 'on' : ''} onClick={() => setMode('login')}>Sign in</button>
|
||||
</div>
|
||||
{mode === 'register' && <div className="auth-avatar" title="Avatar coming soon">{(displayName.trim() || '?').slice(0, 1).toUpperCase()}</div>}
|
||||
<div className="auth-fields">
|
||||
{mode === 'register' && <label>Display name<input value={displayName} onChange={event => setDisplayName(event.target.value)} placeholder="Investigator name" onKeyDown={e => e.key === 'Enter' && submit()} /></label>}
|
||||
<label>Handle<input value={handle} onChange={event => setHandle(event.target.value.toLowerCase())} placeholder="handle" autoCapitalize="off" autoCorrect="off" spellCheck={false} onKeyDown={e => e.key === 'Enter' && submit()} /></label>
|
||||
<label>Password<input type="password" value={password} onChange={event => setPassword(event.target.value)} onKeyDown={e => e.key === 'Enter' && submit()} /></label>
|
||||
</div>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
<button className="splash-button primary" disabled={busy} onClick={submit}>{busy ? 'PLEASE WAIT…' : mode === 'register' ? 'ENROL ▸' : 'SIGN IN ▸'}</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user