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 {
|
||||
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…
Reference in New Issue