Implement SolidJS web app routes and UI scaffolding

master
EC2 Default User 1 month ago
parent 5b43db77bf
commit 06d6e42cf9

@ -7,17 +7,19 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest",
"test": "vitest --config vitest.config.ts",
"clean": "rm -rf dist node_modules/.vite"
},
"dependencies": {
"@solidjs/router": "^0.10.0",
"@suid/icons-material": "^0.7.0",
"@suid/material": "^0.16.0",
"solid-js": "^1.9.0"
"solid-js": "^1.9.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@solidjs/testing-library": "^0.8.0",
"jsdom": "^26.1.0",
"typescript": "^5.5.4",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.8.0",

@ -1,11 +1,20 @@
import { Route, Router } from '@solidjs/router'
import type { Component } from 'solid-js'
import AppShell from './components/AppShell'
import GameRoute from './routes/Game'
import HomeRoute from './routes/Home'
import LeaderboardRoute from './routes/Leaderboard'
import ProfileRoute from './routes/Profile'
const App: Component = () => {
return (
<div>
<h1>Know Foolery</h1>
<p>Quiz game coming soon...</p>
</div>
<Router root={AppShell}>
<Route path="/" component={HomeRoute} />
<Route path="/game" component={GameRoute} />
<Route path="/leaderboard" component={LeaderboardRoute} />
<Route path="/profile" component={ProfileRoute} />
</Router>
)
}

@ -0,0 +1,50 @@
import { A, Outlet } from '@solidjs/router'
import type { Component } from 'solid-js'
import AppBar from '@suid/material/AppBar'
import Box from '@suid/material/Box'
import Container from '@suid/material/Container'
import IconButton from '@suid/material/IconButton'
import Stack from '@suid/material/Stack'
import Toolbar from '@suid/material/Toolbar'
import Typography from '@suid/material/Typography'
import HomeIcon from '@suid/icons-material/Home'
import SportsEsportsIcon from '@suid/icons-material/SportsEsports'
import LeaderboardIcon from '@suid/icons-material/Leaderboard'
import PersonIcon from '@suid/icons-material/Person'
const AppShell: Component = () => {
return (
<Box sx={{ minHeight: '100vh', bgcolor: '#0b0e14', color: '#e6e6e6' }}>
<AppBar position="static" color="transparent" elevation={0}>
<Toolbar>
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 700 }}>
Know Foolery
</Typography>
<Stack direction="row" spacing={1}>
<IconButton component={A} href="/" aria-label="Home" color="inherit">
<HomeIcon />
</IconButton>
<IconButton component={A} href="/game" aria-label="Game" color="inherit">
<SportsEsportsIcon />
</IconButton>
<IconButton component={A} href="/leaderboard" aria-label="Leaderboard" color="inherit">
<LeaderboardIcon />
</IconButton>
<IconButton component={A} href="/profile" aria-label="Profile" color="inherit">
<PersonIcon />
</IconButton>
</Stack>
</Toolbar>
</AppBar>
<Container maxWidth="md" sx={{ py: 3 }}>
<Outlet />
</Container>
</Box>
)
}
export default AppShell

@ -0,0 +1,35 @@
import { createSignal, onCleanup } from 'solid-js'
export function useTimer(durationMs: number) {
const [remainingMs, setRemainingMs] = createSignal(durationMs)
const [isExpired, setIsExpired] = createSignal(false)
let t: number | undefined
let startedAt: number | undefined
const tick = () => {
if (startedAt == null) return
const elapsed = Date.now() - startedAt
const remain = Math.max(0, durationMs - elapsed)
setRemainingMs(remain)
if (remain === 0) setIsExpired(true)
}
const start = () => {
if (t != null) return
startedAt = Date.now()
tick()
t = window.setInterval(tick, 250)
}
const stop = () => {
if (t != null) {
window.clearInterval(t)
t = undefined
}
}
onCleanup(() => stop())
return { remainingMs, isExpired, start, stop }
}

@ -1,4 +1,5 @@
import { render } from 'solid-js/web'
import App from './App'
import './styles/global.css'

@ -0,0 +1,120 @@
import { createMemo, createSignal, onCleanup, onMount } from 'solid-js'
import Box from '@suid/material/Box'
import Button from '@suid/material/Button'
import Card from '@suid/material/Card'
import CardContent from '@suid/material/CardContent'
import Divider from '@suid/material/Divider'
import Stack from '@suid/material/Stack'
import TextField from '@suid/material/TextField'
import Typography from '@suid/material/Typography'
import AttemptIndicator from '../ui/AttemptIndicator'
import ScoreDisplay from '../ui/ScoreDisplay'
import Timer from '../ui/Timer'
import { useTimer } from '../hooks/useTimer'
export default function GameRoute() {
// Placeholder game state until backend exists.
const [question] = createSignal({ theme: 'Général', text: 'Quel est le plus petit nombre premier ?' })
const [answer, setAnswer] = createSignal('')
const [attempts, setAttempts] = createSignal(0)
const [hintUsed, setHintUsed] = createSignal(false)
const [score, setScore] = createSignal(0)
const [message, setMessage] = createSignal<string | null>(null)
const durationMs = 30 * 60 * 1000
const { remainingMs, isExpired, start } = useTimer(durationMs)
onMount(() => start())
onCleanup(() => {
// no-op; hook cleans itself
})
const attemptsLeft = createMemo(() => Math.max(0, 3 - attempts()))
const submit = () => {
if (isExpired()) {
setMessage('Session terminée (timeout).')
return
}
const normalized = answer().trim().toLowerCase()
const correct = normalized === '2' || normalized === 'deux'
setAttempts((a) => a + 1)
if (correct) {
const delta = hintUsed() ? 1 : 2
setScore((s) => s + delta)
setMessage(`Bonne réponse (+${delta}).`)
} else {
setMessage('Mauvaise réponse.')
}
setAnswer('')
}
const useHint = () => {
if (hintUsed()) return
setHintUsed(true)
setMessage('Indice: cest le seul nombre premier pair.')
}
return (
<Box>
<Stack spacing={2}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
Partie
</Typography>
<Typography sx={{ opacity: 0.8 }}>Thème : {question().theme}</Typography>
</Box>
<Timer remainingMs={remainingMs()} />
<ScoreDisplay score={score()} />
</Stack>
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Stack spacing={2}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
{question().text}
</Typography>
<TextField
label="Ta réponse"
value={answer()}
onInput={(e) => setAnswer(e.currentTarget.value)}
fullWidth
InputLabelProps={{ style: { color: '#cbd5e1' } }}
InputProps={{ style: { color: '#e5e7eb' } }}
onKeyDown={(e) => {
if (e.key === 'Enter') submit()
}}
/>
<AttemptIndicator attemptsUsed={attempts()} attemptsMax={3} />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="contained" disabled={attemptsLeft() === 0} onClick={submit}>
Envoyer
</Button>
<Button variant="outlined" disabled={hintUsed()} onClick={useHint}>
Indice (score réduit)
</Button>
</Stack>
<Divider />
{message() && <Typography sx={{ opacity: 0.9 }}>{message()}</Typography>}
{attemptsLeft() === 0 && <Typography color="warning.main">Plus d'essais.</Typography>}
{isExpired() && <Typography color="error.main">Temps écoulé.</Typography>}
</Stack>
</CardContent>
</Card>
</Stack>
</Box>
)
}

@ -0,0 +1,70 @@
import { useNavigate } from '@solidjs/router'
import { createSignal } from 'solid-js'
import Box from '@suid/material/Box'
import Button from '@suid/material/Button'
import Card from '@suid/material/Card'
import CardContent from '@suid/material/CardContent'
import Stack from '@suid/material/Stack'
import TextField from '@suid/material/TextField'
import Typography from '@suid/material/Typography'
import { playerNameSchema } from '../services/validation'
export default function HomeRoute() {
const navigate = useNavigate()
const [playerName, setPlayerName] = createSignal('')
const [error, setError] = createSignal<string | null>(null)
const start = () => {
const name = playerName().trim()
const parsed = playerNameSchema.safeParse(name)
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? 'Nom invalide')
return
}
setError(null)
// keep it simple: store in localStorage
localStorage.setItem('kf.playerName', name)
navigate('/game')
}
return (
<Box>
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Stack spacing={2}>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
Bienvenue
</Typography>
<Typography sx={{ opacity: 0.8 }}>
Entre ton pseudo et lance une partie.
</Typography>
<TextField
label="Nom de joueur"
value={playerName()}
onInput={(e) => setPlayerName(e.currentTarget.value)}
error={!!error()}
helperText={error() ?? '250 caractères'}
fullWidth
variant="outlined"
InputLabelProps={{ style: { color: '#cbd5e1' } }}
InputProps={{ style: { color: '#e5e7eb' } }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="contained" onClick={start}>
Démarrer la partie
</Button>
<Button variant="outlined" onClick={() => navigate('/leaderboard')}>
Voir le leaderboard
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
</Box>
)
}

@ -0,0 +1,57 @@
import { For, Show, createResource } from 'solid-js'
import Box from '@suid/material/Box'
import Button from '@suid/material/Button'
import Card from '@suid/material/Card'
import CardContent from '@suid/material/CardContent'
import CircularProgress from '@suid/material/CircularProgress'
import Stack from '@suid/material/Stack'
import Typography from '@suid/material/Typography'
import { leaderboardClient } from '../services/api'
export default function LeaderboardRoute() {
const [items, { refetch }] = createResource(async () => leaderboardClient.top10())
return (
<Box>
<Stack spacing={2}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">
<Typography variant="h4" sx={{ fontWeight: 800, flexGrow: 1 }}>
Leaderboard
</Typography>
<Button variant="outlined" onClick={() => refetch()}>
Rafraîchir
</Button>
</Stack>
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Show when={!items.loading} fallback={<CircularProgress />}>
<Show
when={(items() ?? []).length > 0}
fallback={<Typography sx={{ opacity: 0.8 }}>Aucun score pour le moment.</Typography>}
>
<Stack spacing={1}>
<For each={items() ?? []}>
{(row, idx) => (
<Stack direction="row" spacing={2} alignItems="center">
<Typography sx={{ width: '3ch', opacity: 0.8 }}>#{idx() + 1}</Typography>
<Typography sx={{ flexGrow: 1, fontWeight: 700 }}>{row.player}</Typography>
<Typography sx={{ opacity: 0.9 }}>{row.score}</Typography>
</Stack>
)}
</For>
</Stack>
</Show>
</Show>
</CardContent>
</Card>
<Typography sx={{ opacity: 0.6, fontSize: 12 }}>
Note : cette page utilise lAPI si configurée, sinon une version mock.
</Typography>
</Stack>
</Box>
)
}

@ -0,0 +1,11 @@
import { render } from '@solidjs/testing-library'
import { describe, expect, it } from 'vitest'
import ProfileRoute from './Profile'
describe('ProfileRoute', () => {
it('renders', () => {
const { getByText } = render(() => <ProfileRoute />)
expect(getByText('Profil')).toBeTruthy()
})
})

@ -0,0 +1,56 @@
import { createSignal } from 'solid-js'
import Box from '@suid/material/Box'
import Button from '@suid/material/Button'
import Card from '@suid/material/Card'
import CardContent from '@suid/material/CardContent'
import Stack from '@suid/material/Stack'
import TextField from '@suid/material/TextField'
import Typography from '@suid/material/Typography'
export default function ProfileRoute() {
const [name, setName] = createSignal(localStorage.getItem('kf.playerName') ?? '')
const save = () => {
localStorage.setItem('kf.playerName', name().trim())
}
return (
<Box>
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Stack spacing={2}>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
Profil
</Typography>
<Typography sx={{ opacity: 0.8 }}>Paramètres locaux (placeholder).</Typography>
<TextField
label="Nom de joueur"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
fullWidth
InputLabelProps={{ style: { color: '#cbd5e1' } }}
InputProps={{ style: { color: '#e5e7eb' } }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="contained" onClick={save}>
Enregistrer
</Button>
<Button
variant="outlined"
onClick={() => {
localStorage.removeItem('kf.playerName')
setName('')
}}
>
Effacer
</Button>
</Stack>
</Stack>
</CardContent>
</Card>
</Box>
)
}

@ -0,0 +1,33 @@
type LeaderboardRow = {
player: string
score: number
}
const baseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
async function json<T>(path: string): Promise<T> {
if (!baseUrl) {
throw new Error('API base URL not configured')
}
const res = await fetch(`${baseUrl}${path}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as T
}
export const leaderboardClient = {
async top10(): Promise<LeaderboardRow[]> {
// When the backend exists, this should call the leaderboard-service.
// For now, return a mock if API is not set.
if (!baseUrl) {
return [
{ player: 'Alice', score: 24 },
{ player: 'Bob', score: 22 },
{ player: 'Charlie', score: 20 },
]
}
// Example endpoint; adjust when gateway routes are finalized
const resp = await json<{ items?: LeaderboardRow[] }>(`/leaderboard/top10`)
return resp.items ?? []
},
}

@ -0,0 +1,6 @@
import { z } from 'zod'
export const playerNameSchema = z
.string()
.min(2, 'Le nom doit faire au moins 2 caractères')
.max(50, 'Le nom doit faire au plus 50 caractères')

@ -1,26 +1,21 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color-scheme: dark;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji',
'Segoe UI Emoji';
}
* {
box-sizing: border-box;
html,
body {
height: 100%;
margin: 0;
padding: 0;
}
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
min-height: 100%;
}
a {
color: inherit;
text-decoration: none;
}

@ -0,0 +1,32 @@
import type { Component } from 'solid-js'
import Box from '@suid/material/Box'
import Stack from '@suid/material/Stack'
import Typography from '@suid/material/Typography'
const AttemptIndicator: Component<{ attemptsUsed: number; attemptsMax: number }> = (props) => {
const used = () => Math.max(0, Math.min(props.attemptsUsed, props.attemptsMax))
return (
<Stack direction="row" spacing={2} alignItems="center">
<Typography sx={{ opacity: 0.8 }}>Essais</Typography>
<Stack direction="row" spacing={0.5}>
{Array.from({ length: props.attemptsMax }).map((_, i) => (
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: i < used() ? '#ef4444' : '#334155',
}}
/>
))}
</Stack>
<Typography sx={{ opacity: 0.7, fontSize: 12 }}>
{Math.max(0, props.attemptsMax - used())} restant(s)
</Typography>
</Stack>
)
}
export default AttemptIndicator

@ -0,0 +1,9 @@
import type { Component } from 'solid-js'
import Chip from '@suid/material/Chip'
const ScoreDisplay: Component<{ score: number }> = (props) => {
return <Chip label={`Score: ${props.score}`} color="primary" variant="outlined" />
}
export default ScoreDisplay

@ -0,0 +1,22 @@
import type { Component } from 'solid-js'
import Chip from '@suid/material/Chip'
function formatMs(ms: number) {
const total = Math.ceil(ms / 1000)
const m = Math.floor(total / 60)
const s = total % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
const Timer: Component<{ remainingMs: number }> = (props) => {
const color = () => {
if (props.remainingMs <= 10_000) return 'error'
if (props.remainingMs <= 60_000) return 'warning'
return 'default'
}
return <Chip label={formatMs(props.remainingMs)} color={color() as any} variant="outlined" />
}
export default Timer

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
},
})

@ -0,0 +1,32 @@
import type { Component } from 'solid-js'
import Box from '@suid/material/Box'
import Stack from '@suid/material/Stack'
import Typography from '@suid/material/Typography'
const AttemptDots: Component<{ attemptsUsed: number; attemptsMax: number }> = (props) => {
const used = () => Math.max(0, Math.min(props.attemptsUsed, props.attemptsMax))
return (
<Stack direction="row" spacing={2} alignItems="center">
<Typography sx={{ opacity: 0.8 }}>Essais</Typography>
<Stack direction="row" spacing={0.5}>
{Array.from({ length: props.attemptsMax }).map((_, i) => (
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: i < used() ? '#ef4444' : '#334155',
}}
/>
))}
</Stack>
<Typography sx={{ opacity: 0.7, fontSize: 12 }}>
{Math.max(0, props.attemptsMax - used())} restant(s)
</Typography>
</Stack>
)
}
export default AttemptDots

@ -0,0 +1,9 @@
import type { Component } from 'solid-js'
import Chip from '@suid/material/Chip'
const ScoreChip: Component<{ score: number }> = (props) => {
return <Chip label={`Score: ${props.score}`} color="primary" variant="outlined" />
}
export default ScoreChip

@ -0,0 +1,22 @@
import type { Component } from 'solid-js'
import Chip from '@suid/material/Chip'
function formatMs(ms: number) {
const total = Math.ceil(ms / 1000)
const m = Math.floor(total / 60)
const s = total % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
const TimerChip: Component<{ remainingMs: number }> = (props) => {
const color = () => {
if (props.remainingMs <= 10_000) return 'error'
if (props.remainingMs <= 60_000) return 'warning'
return 'default'
}
return <Chip label={formatMs(props.remainingMs)} color={color() as any} variant="outlined" />
}
export default TimerChip

@ -1,3 +1,4 @@
// UI Components barrel export
// Components will be added as they are developed
export {}
export { default as AttemptDots } from './AttemptDots'
export { default as ScoreChip } from './ScoreChip'
export { default as TimerChip } from './TimerChip'

Loading…
Cancel
Save