feat: React app shell with global styles and state-based routing (Task 8)
- index.css: brand CSS custom properties, .btn, input styles, .app-shell layout - App.tsx: session token extraction from URL, /api/auth/me validation, 4-route shell - main.tsx: renders App with StrictMode Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import './index.css'
|
||||
|
||||
type Route = 'login' | 'onboarding' | 'game' | 'admin'
|
||||
|
||||
export default function App() {
|
||||
const [route, setRoute] = useState<Route>('login')
|
||||
const [sessionToken, setSessionToken] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// 1. Check for ?session=<token> in URL (from magic link redirect)
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const urlToken = params.get('session')
|
||||
if (urlToken) {
|
||||
localStorage.setItem('session', urlToken)
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
}
|
||||
|
||||
// 2. Try stored token
|
||||
const stored = urlToken || localStorage.getItem('session')
|
||||
if (!stored) { setLoading(false); return }
|
||||
|
||||
// 3. Validate token with /api/auth/me
|
||||
fetch('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${stored}` },
|
||||
})
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
setSessionToken(stored)
|
||||
// TODO in Task 9: check if onboarding complete, route accordingly
|
||||
setRoute('game')
|
||||
} else {
|
||||
localStorage.removeItem('session')
|
||||
}
|
||||
})
|
||||
.catch(() => localStorage.removeItem('session'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="app-shell" style={{ textAlign: 'center', paddingTop: '4rem' }}>
|
||||
<p style={{ fontFamily: 'var(--font-header)', color: 'var(--color-forest)' }}>
|
||||
Loading quest...
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
{route === 'login' && (
|
||||
<div>
|
||||
<h1 style={{ marginBottom: '1rem' }}>MorseQuest</h1>
|
||||
<p>Login page coming in Task 9.</p>
|
||||
<button className="btn" onClick={() => setRoute('game')}>
|
||||
Continue as Guest (dev)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{route === 'onboarding' && (
|
||||
<div>
|
||||
<h1>Onboarding</h1>
|
||||
<p>Mnemonic builder coming in Task 10.</p>
|
||||
</div>
|
||||
)}
|
||||
{route === 'game' && (
|
||||
<div>
|
||||
<h1>Practice</h1>
|
||||
<p>Game loop coming in Task 11.</p>
|
||||
<button className="btn" onClick={() => { localStorage.removeItem('session'); setRoute('login') }}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{route === 'admin' && (
|
||||
<div>
|
||||
<h1>Admin</h1>
|
||||
<p>Admin panel coming in Task 12.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/* CSS custom properties */
|
||||
:root {
|
||||
--color-gold: #D4AF37;
|
||||
--color-blue: #4169E1;
|
||||
--color-parchment: #F5E6D3;
|
||||
--color-forest: #3C2415;
|
||||
--color-brown: #654321;
|
||||
--font-header: 'Cinzel', serif;
|
||||
--font-body: 'Open Sans', sans-serif;
|
||||
--font-mono: 'Source Code Pro', monospace;
|
||||
}
|
||||
|
||||
/* Reset + base */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background: var(--color-parchment);
|
||||
color: var(--color-forest);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3 { font-family: var(--font-header); color: var(--color-forest); }
|
||||
code, .mono { font-family: var(--font-mono); }
|
||||
|
||||
/* Shared button style */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.7rem 1.6rem;
|
||||
background: var(--color-gold);
|
||||
color: var(--color-forest);
|
||||
border: 2px solid var(--color-forest);
|
||||
font-family: var(--font-header);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn:hover { background: var(--color-forest); color: var(--color-parchment); }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--color-gold);
|
||||
color: var(--color-forest);
|
||||
}
|
||||
|
||||
/* Input */
|
||||
input[type="text"], input[type="email"] {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 2px solid var(--color-brown);
|
||||
background: white;
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
color: var(--color-forest);
|
||||
}
|
||||
input:focus { outline: 2px solid var(--color-gold); outline-offset: 1px; }
|
||||
|
||||
/* Layout shell */
|
||||
.app-shell {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
+3
-1
@@ -1,8 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<div>MorseQuest loading...</div>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user