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'
|
import { CutsceneHost, DialoguePlayer, MeritHost, type PlaythroughState } from './narrative'
|
||||||
|
|
||||||
type MysterySummary = { slug: string; title: string }
|
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
|
// 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
|
// active playthrough; otherwise walks the story graph — cutscene and dialogue
|
||||||
@@ -14,6 +15,15 @@ export function Play() {
|
|||||||
const [splash, setSplash] = useState(false)
|
const [splash, setSplash] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [status, setStatus] = useState('')
|
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) => {
|
const apply = useCallback((next: PlaythroughState | null) => {
|
||||||
if (!next) { setSplash(true); return }
|
if (!next) { setSplash(true); return }
|
||||||
@@ -48,17 +58,19 @@ export function Play() {
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (res.ok && res.status !== 204) { apply(await res.json()); 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
|
// The bare root is the front door — but you must be signed in first.
|
||||||
// Resume on the one already in progress rather than auto-resuming into it.
|
const me = await fetch('/api/auth/me')
|
||||||
const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')])
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (cases.ok) setMysteries(await cases.json())
|
setAuthChecked(true)
|
||||||
if (current.ok && current.status !== 204) setResumable(await current.json())
|
if (me.status !== 200) return // not signed in -> the enrolment / sign-in screen
|
||||||
setSplash(true)
|
setPlayer((await me.json()).user)
|
||||||
setStatus('SELECT A CASE FILE')
|
await openFrontDoor()
|
||||||
})()
|
})()
|
||||||
return () => { cancelled = true }
|
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) => {
|
const newGame = async (slug: string) => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
@@ -77,10 +89,14 @@ export function Play() {
|
|||||||
if (res.ok) apply(await res.json())
|
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">
|
if (splash) return <div className="splash">
|
||||||
<div className="splash-plate">
|
<div className="splash-plate">
|
||||||
<div className="seal">GU</div>
|
<div className="seal">GU</div>
|
||||||
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
<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>
|
<p className="splash-sub">Glitch University · Case Files</p>
|
||||||
<div className="splash-cases">
|
<div className="splash-cases">
|
||||||
{mysteries.map(mystery => {
|
{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 }) }) }} />
|
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>
|
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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -808,3 +808,21 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.dialogue-capture:hover { border-color: #cdea6a; color: #eafaa0; }
|
.dialogue-capture:hover { border-color: #cdea6a; color: #eafaa0; }
|
||||||
/* A note torn to the board reads as handwriting too. */
|
/* A note torn to the board reads as handwriting too. */
|
||||||
.evidence-card.note .card-content p { font-family: "Reenie Beanie", cursive; font-size: 19px; line-height: 1.05; color: #26356b; }
|
.evidence-card.note .card-content p { font-family: "Reenie Beanie", cursive; font-size: 19px; line-height: 1.05; color: #26356b; }
|
||||||
|
|
||||||
|
/* === Player auth + character screen (src/play.tsx) ================= */
|
||||||
|
.auth-plate { gap: 4px; }
|
||||||
|
.auth-tabs { display: flex; gap: 8px; margin: 22px 0 6px; }
|
||||||
|
.auth-tabs button { background: #0e2a24; border: 1px solid #3c5a52; color: #9bb0a9; font: 10px IBM Plex Mono, monospace; letter-spacing: .12em; padding: 7px 14px; cursor: pointer; }
|
||||||
|
.auth-tabs button.on { border-color: #d58a46; color: #f6ead9; }
|
||||||
|
.auth-avatar { width: 64px; height: 64px; margin: 12px 0 6px; display: grid; place-items: center; border: 2px dashed #6f8f85; border-radius: 50%; color: #cfe8df; font: 700 26px Special Elite, serif; background: #0a211d; }
|
||||||
|
.auth-fields { display: flex; flex-direction: column; gap: 12px; width: min(320px, 84vw); margin: 8px 0 4px; }
|
||||||
|
.auth-fields label { display: flex; flex-direction: column; gap: 5px; font: 9px IBM Plex Mono, monospace; letter-spacing: .18em; color: #7f9a92; text-transform: uppercase; }
|
||||||
|
.auth-fields input { background: #08201b; border: 1px solid #3c5a52; color: #e4e9e4; font: 14px IBM Plex Mono, monospace; padding: 9px 11px; outline: none; }
|
||||||
|
.auth-fields input:focus { border-color: #6f8f85; }
|
||||||
|
.auth-error { margin: 4px 0 0; color: #e88a4a; font: 11px IBM Plex Mono, monospace; }
|
||||||
|
.auth-plate .splash-button { margin-top: 14px; }
|
||||||
|
/* investigator identity on the case-file splash */
|
||||||
|
.splash-investigator { display: flex; align-items: center; gap: 10px; margin: 10px 0 0; color: #cfe8df; font: 13px IBM Plex Mono, monospace; letter-spacing: .1em; }
|
||||||
|
.splash-avatar { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid #6f8f85; border-radius: 50%; font: 700 13px Special Elite, serif; color: #e7b57e; }
|
||||||
|
.splash-signout { margin-left: 6px; background: none; border: none; color: #6f8f85; font: 10px IBM Plex Mono, monospace; letter-spacing: .1em; cursor: pointer; text-decoration: underline; }
|
||||||
|
.splash-signout:hover { color: #d58a46; }
|
||||||
|
|||||||
Reference in New Issue
Block a user