Implement SolidJS web app routes and UI scaffolding
parent
5b43db77bf
commit
06d6e42cf9
@ -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 }
|
||||||
|
}
|
||||||
@ -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 {
|
:root {
|
||||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
color-scheme: dark;
|
||||||
line-height: 1.5;
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji',
|
||||||
font-weight: 400;
|
'Segoe UI Emoji';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
html,
|
||||||
box-sizing: border-box;
|
body {
|
||||||
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
max-width: 1280px;
|
min-height: 100%;
|
||||||
margin: 0 auto;
|
}
|
||||||
padding: 2rem;
|
|
||||||
|
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
|
export { default as AttemptDots } from './AttemptDots'
|
||||||
// Components will be added as they are developed
|
export { default as ScoreChip } from './ScoreChip'
|
||||||
export {}
|
export { default as TimerChip } from './TimerChip'
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue