From 06d6e42cf9978ab9641c3c7a20e0aa6eada8912e Mon Sep 17 00:00:00 2001 From: EC2 Default User Date: Tue, 10 Feb 2026 09:42:49 +0000 Subject: [PATCH] Implement SolidJS web app routes and UI scaffolding --- frontend/apps/web/package.json | 6 +- frontend/apps/web/src/App.tsx | 17 ++- frontend/apps/web/src/components/AppShell.tsx | 50 ++++++++ frontend/apps/web/src/hooks/useTimer.ts | 35 +++++ frontend/apps/web/src/main.tsx | 1 + frontend/apps/web/src/routes/Game.tsx | 120 ++++++++++++++++++ frontend/apps/web/src/routes/Home.tsx | 70 ++++++++++ frontend/apps/web/src/routes/Leaderboard.tsx | 57 +++++++++ frontend/apps/web/src/routes/Profile.test.tsx | 11 ++ frontend/apps/web/src/routes/Profile.tsx | 56 ++++++++ frontend/apps/web/src/services/api.ts | 33 +++++ frontend/apps/web/src/services/validation.ts | 6 + frontend/apps/web/src/styles/global.css | 29 ++--- frontend/apps/web/src/ui/AttemptIndicator.tsx | 32 +++++ frontend/apps/web/src/ui/ScoreDisplay.tsx | 9 ++ frontend/apps/web/src/ui/Timer.tsx | 22 ++++ frontend/apps/web/vitest.config.ts | 7 + .../shared/ui-components/src/AttemptDots.tsx | 32 +++++ .../shared/ui-components/src/ScoreChip.tsx | 9 ++ .../shared/ui-components/src/TimerChip.tsx | 22 ++++ frontend/shared/ui-components/src/index.ts | 7 +- 21 files changed, 605 insertions(+), 26 deletions(-) create mode 100644 frontend/apps/web/src/components/AppShell.tsx create mode 100644 frontend/apps/web/src/hooks/useTimer.ts create mode 100644 frontend/apps/web/src/routes/Game.tsx create mode 100644 frontend/apps/web/src/routes/Home.tsx create mode 100644 frontend/apps/web/src/routes/Leaderboard.tsx create mode 100644 frontend/apps/web/src/routes/Profile.test.tsx create mode 100644 frontend/apps/web/src/routes/Profile.tsx create mode 100644 frontend/apps/web/src/services/api.ts create mode 100644 frontend/apps/web/src/services/validation.ts create mode 100644 frontend/apps/web/src/ui/AttemptIndicator.tsx create mode 100644 frontend/apps/web/src/ui/ScoreDisplay.tsx create mode 100644 frontend/apps/web/src/ui/Timer.tsx create mode 100644 frontend/apps/web/vitest.config.ts create mode 100644 frontend/shared/ui-components/src/AttemptDots.tsx create mode 100644 frontend/shared/ui-components/src/ScoreChip.tsx create mode 100644 frontend/shared/ui-components/src/TimerChip.tsx diff --git a/frontend/apps/web/package.json b/frontend/apps/web/package.json index 88c42a7..5abea82 100644 --- a/frontend/apps/web/package.json +++ b/frontend/apps/web/package.json @@ -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", diff --git a/frontend/apps/web/src/App.tsx b/frontend/apps/web/src/App.tsx index fb4c173..a885ea4 100644 --- a/frontend/apps/web/src/App.tsx +++ b/frontend/apps/web/src/App.tsx @@ -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 ( -
-

Know Foolery

-

Quiz game coming soon...

-
+ + + + + + ) } diff --git a/frontend/apps/web/src/components/AppShell.tsx b/frontend/apps/web/src/components/AppShell.tsx new file mode 100644 index 0000000..f57bcf3 --- /dev/null +++ b/frontend/apps/web/src/components/AppShell.tsx @@ -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 ( + + + + + Know Foolery + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default AppShell diff --git a/frontend/apps/web/src/hooks/useTimer.ts b/frontend/apps/web/src/hooks/useTimer.ts new file mode 100644 index 0000000..9cc9530 --- /dev/null +++ b/frontend/apps/web/src/hooks/useTimer.ts @@ -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 } +} diff --git a/frontend/apps/web/src/main.tsx b/frontend/apps/web/src/main.tsx index 39ce5d7..4a3ab1d 100644 --- a/frontend/apps/web/src/main.tsx +++ b/frontend/apps/web/src/main.tsx @@ -1,4 +1,5 @@ import { render } from 'solid-js/web' + import App from './App' import './styles/global.css' diff --git a/frontend/apps/web/src/routes/Game.tsx b/frontend/apps/web/src/routes/Game.tsx new file mode 100644 index 0000000..34e0fb7 --- /dev/null +++ b/frontend/apps/web/src/routes/Game.tsx @@ -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(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: c’est le seul nombre premier pair.') + } + + return ( + + + + + + Partie + + Thème : {question().theme} + + + + + + + + + + {question().text} + + + setAnswer(e.currentTarget.value)} + fullWidth + InputLabelProps={{ style: { color: '#cbd5e1' } }} + InputProps={{ style: { color: '#e5e7eb' } }} + onKeyDown={(e) => { + if (e.key === 'Enter') submit() + }} + /> + + + + + + + + + + + {message() && {message()}} + {attemptsLeft() === 0 && Plus d'essais.} + {isExpired() && Temps écoulé.} + + + + + + ) +} diff --git a/frontend/apps/web/src/routes/Home.tsx b/frontend/apps/web/src/routes/Home.tsx new file mode 100644 index 0000000..5506a61 --- /dev/null +++ b/frontend/apps/web/src/routes/Home.tsx @@ -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(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 ( + + + + + + Bienvenue + + + Entre ton pseudo et lance une partie. + + + setPlayerName(e.currentTarget.value)} + error={!!error()} + helperText={error() ?? '2–50 caractères'} + fullWidth + variant="outlined" + InputLabelProps={{ style: { color: '#cbd5e1' } }} + InputProps={{ style: { color: '#e5e7eb' } }} + /> + + + + + + + + + + ) +} diff --git a/frontend/apps/web/src/routes/Leaderboard.tsx b/frontend/apps/web/src/routes/Leaderboard.tsx new file mode 100644 index 0000000..d72f11e --- /dev/null +++ b/frontend/apps/web/src/routes/Leaderboard.tsx @@ -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 ( + + + + + Leaderboard + + + + + + + }> + 0} + fallback={Aucun score pour le moment.} + > + + + {(row, idx) => ( + + #{idx() + 1} + {row.player} + {row.score} + + )} + + + + + + + + + Note : cette page utilise l’API si configurée, sinon une version mock. + + + + ) +} diff --git a/frontend/apps/web/src/routes/Profile.test.tsx b/frontend/apps/web/src/routes/Profile.test.tsx new file mode 100644 index 0000000..a5180c1 --- /dev/null +++ b/frontend/apps/web/src/routes/Profile.test.tsx @@ -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(() => ) + expect(getByText('Profil')).toBeTruthy() + }) +}) diff --git a/frontend/apps/web/src/routes/Profile.tsx b/frontend/apps/web/src/routes/Profile.tsx new file mode 100644 index 0000000..c6cfd29 --- /dev/null +++ b/frontend/apps/web/src/routes/Profile.tsx @@ -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 ( + + + + + + Profil + + Paramètres locaux (placeholder). + + setName(e.currentTarget.value)} + fullWidth + InputLabelProps={{ style: { color: '#cbd5e1' } }} + InputProps={{ style: { color: '#e5e7eb' } }} + /> + + + + + + + + + + ) +} diff --git a/frontend/apps/web/src/services/api.ts b/frontend/apps/web/src/services/api.ts new file mode 100644 index 0000000..af012bf --- /dev/null +++ b/frontend/apps/web/src/services/api.ts @@ -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(path: string): Promise { + 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 { + // 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 ?? [] + }, +} diff --git a/frontend/apps/web/src/services/validation.ts b/frontend/apps/web/src/services/validation.ts new file mode 100644 index 0000000..05741d5 --- /dev/null +++ b/frontend/apps/web/src/services/validation.ts @@ -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') diff --git a/frontend/apps/web/src/styles/global.css b/frontend/apps/web/src/styles/global.css index 0537e29..92e5758 100644 --- a/frontend/apps/web/src/styles/global.css +++ b/frontend/apps/web/src/styles/global.css @@ -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; } diff --git a/frontend/apps/web/src/ui/AttemptIndicator.tsx b/frontend/apps/web/src/ui/AttemptIndicator.tsx new file mode 100644 index 0000000..1ebe8eb --- /dev/null +++ b/frontend/apps/web/src/ui/AttemptIndicator.tsx @@ -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 ( + + Essais + + {Array.from({ length: props.attemptsMax }).map((_, i) => ( + + ))} + + + {Math.max(0, props.attemptsMax - used())} restant(s) + + + ) +} + +export default AttemptIndicator diff --git a/frontend/apps/web/src/ui/ScoreDisplay.tsx b/frontend/apps/web/src/ui/ScoreDisplay.tsx new file mode 100644 index 0000000..5280ed3 --- /dev/null +++ b/frontend/apps/web/src/ui/ScoreDisplay.tsx @@ -0,0 +1,9 @@ +import type { Component } from 'solid-js' + +import Chip from '@suid/material/Chip' + +const ScoreDisplay: Component<{ score: number }> = (props) => { + return +} + +export default ScoreDisplay diff --git a/frontend/apps/web/src/ui/Timer.tsx b/frontend/apps/web/src/ui/Timer.tsx new file mode 100644 index 0000000..8320e67 --- /dev/null +++ b/frontend/apps/web/src/ui/Timer.tsx @@ -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 +} + +export default Timer diff --git a/frontend/apps/web/vitest.config.ts b/frontend/apps/web/vitest.config.ts new file mode 100644 index 0000000..68e3449 --- /dev/null +++ b/frontend/apps/web/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'jsdom', + }, +}) diff --git a/frontend/shared/ui-components/src/AttemptDots.tsx b/frontend/shared/ui-components/src/AttemptDots.tsx new file mode 100644 index 0000000..42517bf --- /dev/null +++ b/frontend/shared/ui-components/src/AttemptDots.tsx @@ -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 ( + + Essais + + {Array.from({ length: props.attemptsMax }).map((_, i) => ( + + ))} + + + {Math.max(0, props.attemptsMax - used())} restant(s) + + + ) +} + +export default AttemptDots diff --git a/frontend/shared/ui-components/src/ScoreChip.tsx b/frontend/shared/ui-components/src/ScoreChip.tsx new file mode 100644 index 0000000..69f6f71 --- /dev/null +++ b/frontend/shared/ui-components/src/ScoreChip.tsx @@ -0,0 +1,9 @@ +import type { Component } from 'solid-js' + +import Chip from '@suid/material/Chip' + +const ScoreChip: Component<{ score: number }> = (props) => { + return +} + +export default ScoreChip diff --git a/frontend/shared/ui-components/src/TimerChip.tsx b/frontend/shared/ui-components/src/TimerChip.tsx new file mode 100644 index 0000000..c1be83a --- /dev/null +++ b/frontend/shared/ui-components/src/TimerChip.tsx @@ -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 +} + +export default TimerChip diff --git a/frontend/shared/ui-components/src/index.ts b/frontend/shared/ui-components/src/index.ts index ee9afc2..43c5a79 100644 --- a/frontend/shared/ui-components/src/index.ts +++ b/frontend/shared/ui-components/src/index.ts @@ -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' +