Compare commits
2 commits
2139696f3f
...
7dca1a5da7
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dca1a5da7 | |||
|
|
29faa18731 |
11 changed files with 549 additions and 18 deletions
106
apps/server/src/handlers.ts
Normal file
106
apps/server/src/handlers.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Handlers Socket.IO du cycle lobby. Enregistrés par socket à la connexion.
|
||||
|
||||
import type { Socket } from "socket.io"
|
||||
import type {
|
||||
ClientToServerEvents,
|
||||
ErrorPayload,
|
||||
InterServerEvents,
|
||||
ServerToClientEvents,
|
||||
SocketData,
|
||||
} from "@nerdware/shared"
|
||||
import type { IoServer } from "./socket"
|
||||
import { RoomError, RoomManager, type ServerRoom } from "./rooms"
|
||||
|
||||
type AppSocket = Socket<
|
||||
ClientToServerEvents,
|
||||
ServerToClientEvents,
|
||||
InterServerEvents,
|
||||
SocketData
|
||||
>
|
||||
|
||||
const MAX_NAME_LENGTH = 24
|
||||
|
||||
function cleanName(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") {
|
||||
return null
|
||||
}
|
||||
const name = raw.trim().slice(0, MAX_NAME_LENGTH)
|
||||
return name.length > 0 ? name : null
|
||||
}
|
||||
|
||||
function toErrorPayload(err: unknown): ErrorPayload {
|
||||
if (err instanceof RoomError) {
|
||||
return { code: err.code, message: err.message }
|
||||
}
|
||||
return { code: "INTERNAL", message: "Une erreur est survenue." }
|
||||
}
|
||||
|
||||
export function registerRoomHandlers(
|
||||
io: IoServer,
|
||||
socket: AppSocket,
|
||||
rooms: RoomManager
|
||||
): void {
|
||||
const broadcastState = (room: ServerRoom) => {
|
||||
io.to(room.code).emit("room:state", rooms.toSnapshot(room))
|
||||
}
|
||||
|
||||
socket.on("room:create", (payload, ack) => {
|
||||
const name = cleanName(payload?.playerName)
|
||||
if (!name) {
|
||||
ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { room, player } = rooms.create(name, socket.id)
|
||||
socket.data.playerId = player.id
|
||||
socket.data.roomCode = room.code
|
||||
socket.join(room.code)
|
||||
ack({ ok: true, data: { roomCode: room.code, playerId: player.id } })
|
||||
broadcastState(room)
|
||||
} catch (err) {
|
||||
ack({ ok: false, error: toErrorPayload(err) })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("room:join", (payload, ack) => {
|
||||
const name = cleanName(payload?.playerName)
|
||||
const code = typeof payload?.roomCode === "string" ? payload.roomCode.trim().toUpperCase() : ""
|
||||
if (!name) {
|
||||
ack({ ok: false, error: { code: "INVALID_NAME", message: "Pseudo invalide." } })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { room, player } = rooms.join(code, name, socket.id)
|
||||
socket.data.playerId = player.id
|
||||
socket.data.roomCode = room.code
|
||||
socket.join(room.code)
|
||||
ack({ ok: true, data: { roomCode: room.code, playerId: player.id } })
|
||||
broadcastState(room)
|
||||
} catch (err) {
|
||||
ack({ ok: false, error: toErrorPayload(err) })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("lobby:updateSettings", (payload) => {
|
||||
const code = socket.data.roomCode
|
||||
const room = code ? rooms.get(code) : undefined
|
||||
if (!room || room.hostId !== socket.data.playerId) {
|
||||
return
|
||||
}
|
||||
room.settings = {
|
||||
blindtestMode: payload.blindtestMode,
|
||||
roundDuration: payload.roundDuration,
|
||||
rounds: payload.rounds,
|
||||
}
|
||||
broadcastState(room)
|
||||
})
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
const affected = rooms.handleDisconnect(socket.id)
|
||||
if (!affected) {
|
||||
return
|
||||
}
|
||||
io.to(affected.room.code).emit("player:left", { playerId: affected.playerId })
|
||||
broadcastState(affected.room)
|
||||
})
|
||||
}
|
||||
145
apps/server/src/rooms.ts
Normal file
145
apps/server/src/rooms.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// Gestion des rooms en mémoire. Source de vérité côté serveur.
|
||||
// Pas de Redis en V1 : une simple Map suffit pour jouer entre potes.
|
||||
|
||||
import { randomUUID } from "node:crypto"
|
||||
import {
|
||||
DEFAULT_ROOM_SETTINGS,
|
||||
type RoomSettings,
|
||||
type RoomSnapshot,
|
||||
type RoomStatus,
|
||||
} from "@nerdware/shared"
|
||||
|
||||
/** Joueur côté serveur : porte le socketId, jamais exposé au client. */
|
||||
export interface ServerPlayer {
|
||||
id: string
|
||||
name: string
|
||||
connected: boolean
|
||||
socketId: string | null
|
||||
}
|
||||
|
||||
/** Room côté serveur : état runtime complet (le snapshot public en dérive). */
|
||||
export interface ServerRoom {
|
||||
code: string
|
||||
status: RoomStatus
|
||||
hostId: string
|
||||
players: Map<string, ServerPlayer>
|
||||
settings: RoomSettings
|
||||
scores: Map<string, number>
|
||||
currentRound: number
|
||||
}
|
||||
|
||||
/** Codes lisibles, sans caractères ambigus (0/O, 1/I/L). */
|
||||
const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
const CODE_LENGTH = 4
|
||||
|
||||
export class RoomManager {
|
||||
private rooms = new Map<string, ServerRoom>()
|
||||
|
||||
/** Crée une room et y place le créateur comme hôte. */
|
||||
create(playerName: string, socketId: string): { room: ServerRoom; player: ServerPlayer } {
|
||||
const code = this.generateCode()
|
||||
const player = this.makePlayer(playerName, socketId)
|
||||
const room: ServerRoom = {
|
||||
code,
|
||||
status: "lobby",
|
||||
hostId: player.id,
|
||||
players: new Map([[player.id, player]]),
|
||||
settings: { ...DEFAULT_ROOM_SETTINGS },
|
||||
scores: new Map([[player.id, 0]]),
|
||||
currentRound: -1,
|
||||
}
|
||||
this.rooms.set(code, room)
|
||||
return { room, player }
|
||||
}
|
||||
|
||||
/** Ajoute un joueur à une room existante. Lève une erreur si introuvable ou fermée. */
|
||||
join(
|
||||
code: string,
|
||||
playerName: string,
|
||||
socketId: string
|
||||
): { room: ServerRoom; player: ServerPlayer } {
|
||||
const room = this.rooms.get(code)
|
||||
if (!room) {
|
||||
throw new RoomError("ROOM_NOT_FOUND", "Cette room n'existe pas.")
|
||||
}
|
||||
if (room.status !== "lobby") {
|
||||
throw new RoomError("ROOM_IN_PROGRESS", "La partie a déjà commencé.")
|
||||
}
|
||||
const player = this.makePlayer(playerName, socketId)
|
||||
room.players.set(player.id, player)
|
||||
room.scores.set(player.id, 0)
|
||||
return { room, player }
|
||||
}
|
||||
|
||||
get(code: string): ServerRoom | undefined {
|
||||
return this.rooms.get(code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque le joueur d'un socket comme déconnecté.
|
||||
* Retourne la room affectée et le playerId, ou null si non rattaché.
|
||||
* La room et le joueur restent en mémoire pour permettre un futur rejoin.
|
||||
*/
|
||||
handleDisconnect(socketId: string): { room: ServerRoom; playerId: string } | null {
|
||||
for (const room of this.rooms.values()) {
|
||||
for (const player of room.players.values()) {
|
||||
if (player.socketId === socketId) {
|
||||
player.connected = false
|
||||
player.socketId = null
|
||||
return { room, playerId: player.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Projette la room vers la vue publique diffusée aux clients (sans secrets). */
|
||||
toSnapshot(room: ServerRoom): RoomSnapshot {
|
||||
return {
|
||||
code: room.code,
|
||||
status: room.status,
|
||||
hostId: room.hostId,
|
||||
players: [...room.players.values()].map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
connected: p.connected,
|
||||
})),
|
||||
settings: room.settings,
|
||||
scores: [...room.scores.entries()].map(([playerId, score]) => ({
|
||||
playerId,
|
||||
score,
|
||||
})),
|
||||
currentRound: room.currentRound,
|
||||
totalRounds: room.settings.rounds.length,
|
||||
}
|
||||
}
|
||||
|
||||
private makePlayer(name: string, socketId: string): ServerPlayer {
|
||||
return { id: randomUUID(), name, connected: true, socketId }
|
||||
}
|
||||
|
||||
private generateCode(): string {
|
||||
// Très peu de collisions à cette échelle ; on retire quand même par sûreté.
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
let code = ""
|
||||
for (let i = 0; i < CODE_LENGTH; i++) {
|
||||
code += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)]
|
||||
}
|
||||
if (!this.rooms.has(code)) {
|
||||
return code
|
||||
}
|
||||
}
|
||||
throw new RoomError("CODE_EXHAUSTED", "Impossible de générer un code de room.")
|
||||
}
|
||||
}
|
||||
|
||||
/** Erreur métier portant un code stable pour le client. */
|
||||
export class RoomError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = "RoomError"
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import type {
|
|||
SocketData,
|
||||
} from "@nerdware/shared"
|
||||
import { env } from "./env"
|
||||
import { registerRoomHandlers } from "./handlers"
|
||||
import { RoomManager } from "./rooms"
|
||||
|
||||
/** Serveur Socket.IO entièrement typé via les events partagés. */
|
||||
export type IoServer = Server<
|
||||
|
|
@ -16,10 +18,7 @@ export type IoServer = Server<
|
|||
SocketData
|
||||
>
|
||||
|
||||
/**
|
||||
* Crée et attache le serveur Socket.IO au serveur HTTP de Fastify.
|
||||
* La logique des rooms (room:create / room:join) arrive à l'étape 2.
|
||||
*/
|
||||
/** Crée et attache le serveur Socket.IO au serveur HTTP de Fastify. */
|
||||
export function createSocketServer(httpServer: HttpServer): IoServer {
|
||||
const io: IoServer = new Server(httpServer, {
|
||||
cors: {
|
||||
|
|
@ -28,8 +27,11 @@ export function createSocketServer(httpServer: HttpServer): IoServer {
|
|||
},
|
||||
})
|
||||
|
||||
const rooms = new RoomManager()
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
console.log(`[ws] connect ${socket.id}`)
|
||||
registerRoomHandlers(io, socket, rooms)
|
||||
socket.on("disconnect", (reason) => {
|
||||
console.log(`[ws] disconnect ${socket.id} (${reason})`)
|
||||
})
|
||||
|
|
|
|||
1
apps/web/.env.example
Normal file
1
apps/web/.env.example
Normal file
|
|
@ -0,0 +1 @@
|
|||
VITE_SERVER_URL=http://localhost:3001
|
||||
|
|
@ -12,10 +12,14 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nerdware/shared": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"lucide-react": "^1.17.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
"react-dom": "^19.2.6",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"wouter": "^3.7.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10",
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { Button } from "@workspace/ui/components/button"
|
||||
import { Route, Switch } from "wouter"
|
||||
import { HomePage } from "@/pages/home"
|
||||
import { LobbyPage } from "@/pages/lobby"
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<div className="flex min-h-svh p-6">
|
||||
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
|
||||
<div>
|
||||
<h1 className="font-medium">Project ready!</h1>
|
||||
<p>You may now add components and start building.</p>
|
||||
<p>We've already added the button component for you.</p>
|
||||
<Button className="mt-2">Button</Button>
|
||||
</div>
|
||||
<div className="text-muted-foreground font-mono text-xs">
|
||||
(Press <kbd>d</kbd> to toggle dark mode)
|
||||
</div>
|
||||
</div>
|
||||
<Switch>
|
||||
<Route path="/" component={HomePage} />
|
||||
<Route path="/room/:code">
|
||||
{(params) => <LobbyPage code={params.code.toUpperCase()} />}
|
||||
</Route>
|
||||
<Route>
|
||||
<div className="flex min-h-svh items-center justify-center p-6">
|
||||
<p className="text-muted-foreground text-sm">Page introuvable.</p>
|
||||
</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
13
apps/web/src/lib/socket.ts
Normal file
13
apps/web/src/lib/socket.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { io, type Socket } from "socket.io-client"
|
||||
import type {
|
||||
ClientToServerEvents,
|
||||
ServerToClientEvents,
|
||||
} from "@nerdware/shared"
|
||||
|
||||
/** Socket typé : <events reçus, events émis>. Désync = erreur de compile. */
|
||||
export type AppSocket = Socket<ServerToClientEvents, ClientToServerEvents>
|
||||
|
||||
const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001"
|
||||
|
||||
// Une seule connexion partagée pour toute l'app (reconnexion auto par défaut).
|
||||
export const socket: AppSocket = io(SERVER_URL, { autoConnect: true })
|
||||
106
apps/web/src/pages/home.tsx
Normal file
106
apps/web/src/pages/home.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { useState } from "react"
|
||||
import { useLocation } from "wouter"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { useRoomStore } from "@/store/room"
|
||||
|
||||
const inputClass =
|
||||
"border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
|
||||
export function HomePage() {
|
||||
const [, navigate] = useLocation()
|
||||
const createRoom = useRoomStore((s) => s.createRoom)
|
||||
const joinRoom = useRoomStore((s) => s.joinRoom)
|
||||
const connected = useRoomStore((s) => s.connected)
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [code, setCode] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const canSubmit = name.trim().length > 0 && connected && !busy
|
||||
|
||||
async function handleCreate() {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const { roomCode } = await createRoom(name.trim())
|
||||
navigate(`/room/${roomCode}`)
|
||||
} catch (err) {
|
||||
setError((err as { message?: string }).message ?? "Erreur")
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJoin() {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const { roomCode } = await joinRoom(code.trim().toUpperCase(), name.trim())
|
||||
navigate(`/room/${roomCode}`)
|
||||
} catch (err) {
|
||||
setError((err as { message?: string }).message ?? "Erreur")
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center p-6">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<header className="text-center">
|
||||
<h1 className="font-heading text-3xl font-bold tracking-tight">
|
||||
NerdWare
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Party game culture geek
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder="Ton pseudo"
|
||||
value={name}
|
||||
maxLength={24}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
|
||||
<Button disabled={!canSubmit} onClick={handleCreate}>
|
||||
Créer une room
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground text-xs">ou rejoindre</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className={`${inputClass} uppercase`}
|
||||
placeholder="Code"
|
||||
value={code}
|
||||
maxLength={4}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!canSubmit || code.trim().length === 0}
|
||||
onClick={handleJoin}
|
||||
>
|
||||
Rejoindre
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!connected && (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
Connexion au serveur…
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-destructive text-center text-sm">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
apps/web/src/pages/lobby.tsx
Normal file
71
apps/web/src/pages/lobby.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { Link } from "wouter"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { useRoomStore } from "@/store/room"
|
||||
|
||||
export function LobbyPage({ code }: { code: string }) {
|
||||
const snapshot = useRoomStore((s) => s.snapshot)
|
||||
const playerId = useRoomStore((s) => s.playerId)
|
||||
const connected = useRoomStore((s) => s.connected)
|
||||
|
||||
// Pas de snapshot pour ce code (accès direct / refresh) : on renvoie à l'accueil.
|
||||
if (!snapshot || snapshot.code !== code) {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Room introuvable ou session perdue.
|
||||
</p>
|
||||
<Link href="/">
|
||||
<Button variant="secondary">Retour à l'accueil</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh items-center justify-center p-6">
|
||||
<div className="flex w-full max-w-md flex-col gap-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs uppercase">Code room</p>
|
||||
<p className="font-heading text-3xl font-bold tracking-widest">
|
||||
{snapshot.code}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`size-2.5 rounded-full ${connected ? "bg-green-500" : "bg-red-500"}`}
|
||||
title={connected ? "Connecté" : "Déconnecté"}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-muted-foreground text-sm font-medium">
|
||||
Joueurs ({snapshot.players.length})
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{snapshot.players.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<span className={p.connected ? "" : "opacity-40"}>
|
||||
{p.name}
|
||||
{p.id === playerId && (
|
||||
<span className="text-muted-foreground"> (toi)</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{p.id === snapshot.hostId ? "Hôte" : ""}
|
||||
{!p.connected && " · hors ligne"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
En attente du lancement de la partie par l'hôte…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
apps/web/src/store/room.ts
Normal file
63
apps/web/src/store/room.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { create } from "zustand"
|
||||
import type {
|
||||
ErrorPayload,
|
||||
RoomCreatedPayload,
|
||||
RoomSnapshot,
|
||||
} from "@nerdware/shared"
|
||||
import { socket } from "@/lib/socket"
|
||||
|
||||
interface RoomState {
|
||||
connected: boolean
|
||||
/** Identité locale, persistée pour reconnaître "moi" dans le snapshot. */
|
||||
playerId: string | null
|
||||
roomCode: string | null
|
||||
snapshot: RoomSnapshot | null
|
||||
lastError: ErrorPayload | null
|
||||
createRoom: (playerName: string) => Promise<RoomCreatedPayload>
|
||||
joinRoom: (roomCode: string, playerName: string) => Promise<RoomCreatedPayload>
|
||||
clearError: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useRoomStore = create<RoomState>((set) => ({
|
||||
connected: socket.connected,
|
||||
playerId: null,
|
||||
roomCode: null,
|
||||
snapshot: null,
|
||||
lastError: null,
|
||||
|
||||
createRoom: (playerName) =>
|
||||
new Promise((resolve, reject) => {
|
||||
socket.emit("room:create", { playerName }, (res) => {
|
||||
if (res.ok) {
|
||||
set({ playerId: res.data.playerId, roomCode: res.data.roomCode })
|
||||
resolve(res.data)
|
||||
} else {
|
||||
set({ lastError: res.error })
|
||||
reject(res.error)
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
joinRoom: (roomCode, playerName) =>
|
||||
new Promise((resolve, reject) => {
|
||||
socket.emit("room:join", { roomCode, playerName }, (res) => {
|
||||
if (res.ok) {
|
||||
set({ playerId: res.data.playerId, roomCode: res.data.roomCode })
|
||||
resolve(res.data)
|
||||
} else {
|
||||
set({ lastError: res.error })
|
||||
reject(res.error)
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
clearError: () => set({ lastError: null }),
|
||||
reset: () => set({ roomCode: null, snapshot: null, lastError: null }),
|
||||
}))
|
||||
|
||||
// Listeners socket → on pousse l'état serveur dans le store (source de vérité).
|
||||
socket.on("connect", () => useRoomStore.setState({ connected: true }))
|
||||
socket.on("disconnect", () => useRoomStore.setState({ connected: false }))
|
||||
socket.on("room:state", (snapshot) => useRoomStore.setState({ snapshot }))
|
||||
socket.on("error", (error) => useRoomStore.setState({ lastError: error }))
|
||||
20
bun.lock
20
bun.lock
|
|
@ -35,10 +35,14 @@
|
|||
"name": "web",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@nerdware/shared": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"lucide-react": "^1.17.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"wouter": "^3.7.1",
|
||||
"zustand": "^5.0.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10",
|
||||
|
|
@ -752,6 +756,8 @@
|
|||
|
||||
"engine.io": ["engine.io@6.6.8", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1" } }, "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g=="],
|
||||
|
||||
"engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="],
|
||||
|
||||
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
|
||||
|
|
@ -1068,6 +1074,8 @@
|
|||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="],
|
||||
|
|
@ -1194,6 +1202,8 @@
|
|||
|
||||
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
|
||||
|
||||
"regexparam": ["regexparam@3.0.0", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
|
@ -1260,6 +1270,8 @@
|
|||
|
||||
"socket.io-adapter": ["socket.io-adapter@2.5.7", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.20.1" } }, "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg=="],
|
||||
|
||||
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
|
||||
|
||||
"socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||
|
|
@ -1354,6 +1366,8 @@
|
|||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
|
||||
|
|
@ -1370,6 +1384,8 @@
|
|||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"wouter": ["wouter@3.10.0", "", { "dependencies": { "mitt": "^3.0.1", "regexparam": "^3.0.0", "use-sync-external-store": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-zTfddD80zc2/J5l8JKcdvzOK6AwP0kpyHEI3DxRN2bn8U1oJPnrSVm8v+X3WwDamvLAOxTO7ZvkxkpRWlyeJ1Q=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
|
@ -1378,6 +1394,8 @@
|
|||
|
||||
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
|
||||
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
|
@ -1400,6 +1418,8 @@
|
|||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
"zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue