rework pwa cc
Build and Publish Docker Image / build-and-push-image (push) Successful in 1m48s

This commit is contained in:
Zed
2026-07-11 02:01:08 +02:00
parent 9ae37f98ed
commit ce1db937f2
52 changed files with 5210 additions and 48 deletions
+14 -4
View File
@@ -21,14 +21,24 @@ jobs:
- name: Log in to the Container registry
run: echo "${{ secrets.DOCKER_TOKEN }}" | docker login ${{ env.REGISTRY }} -u "${{ gitea.actor }}" --password-stdin
# Build standard using docker CLI
- name: Build Docker image
# Frontend image (nginx + SPA)
- name: Build frontend Docker image
run: |
LOWERCASE_IMAGE=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
docker build -t ${{ env.REGISTRY }}/$LOWERCASE_IMAGE:latest .
# Push directly using docker push
- name: Push Docker image
- name: Push frontend Docker image
run: |
LOWERCASE_IMAGE=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
docker push ${{ env.REGISTRY }}/$LOWERCASE_IMAGE:latest
# Backend image (Express API)
- name: Build backend Docker image
run: |
LOWERCASE_IMAGE=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
docker build -t ${{ env.REGISTRY }}/$LOWERCASE_IMAGE-backend:latest ./server
- name: Push backend Docker image
run: |
LOWERCASE_IMAGE=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
docker push ${{ env.REGISTRY }}/$LOWERCASE_IMAGE-backend:latest
+34
View File
@@ -0,0 +1,34 @@
# Dependencies
node_modules/
# Build output
dist/
dist-ssr/
*.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor / OS
.vscode/*
!.vscode/extensions.json
.idea/
.DS_Store
Thumbs.db
# Vite / PWA generated
dev-dist/
# Backend embedded PGlite data
.pgdata/
server/.pgdata/
# Env
.env
.env.*
!.env.example
+36 -3
View File
@@ -9,6 +9,39 @@ services:
restart: unless-stopped
ports:
- "8080:80"
# Optionnel: si vous avez des logos dans un dossier externe sur votre machine hôte
# volumes:
# - ./games-assets:/usr/share/nginx/html/games-assets
depends_on:
- backend
backend:
build:
context: ./server
dockerfile: Dockerfile
container_name: skori-backend
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 3001
DATABASE_URL: postgres://skori:${POSTGRES_PASSWORD:-skori}@postgres:5432/skori
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16-alpine
container_name: skori-postgres
restart: unless-stopped
environment:
POSTGRES_USER: skori
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-skori}
POSTGRES_DB: skori
volumes:
- skori-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U skori -d skori"]
interval: 5s
timeout: 5s
retries: 10
volumes:
skori-pgdata:
+10
View File
@@ -4,6 +4,16 @@ server {
root /usr/share/nginx/html;
index index.html;
# Proxy des appels API vers le backend (même origine => cookie httpOnly OK)
location /api/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Important pour les Single Page Applications (React Router)
location / {
try_files $uri $uri/ /index.html;
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "board-score",
"name": "skori",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "board-score",
"name": "skori",
"version": "1.0.0",
"dependencies": {
"@hookform/resolvers": "^3.3.2",
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.pgdata
.env
npm-debug.log
+15
View File
@@ -0,0 +1,15 @@
# Port the API listens on
PORT=3001
# Postgres connection string. If omitted, the server falls back to an
# embedded PGlite database (stored in ./.pgdata) — handy for local dev.
# DATABASE_URL=postgres://skori:skori@postgres:5432/skori
# Secret used to sign JWT access tokens (CHANGE THIS in production)
JWT_SECRET=dev-secret-change-me
# Comma-separated list of allowed CORS origins (only needed for cross-origin dev)
# CORS_ORIGINS=http://localhost:5173
# Set to "production" to enable Secure cookies (requires HTTPS)
NODE_ENV=development
+25
View File
@@ -0,0 +1,25 @@
# ÉTAPE 1 : Build du backend TypeScript
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# ÉTAPE 2 : Image de production (deps de prod uniquement)
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3001
CMD ["node", "dist/index.js"]
+2006
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "skori-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"smoke": "tsx src/smoke-test.ts"
},
"dependencies": {
"@electric-sql/pglite": "^0.2.12",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"drizzle-orm": "^0.33.0",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.0",
"@types/pg": "^8.11.6",
"tsx": "^4.16.0",
"typescript": "^5.5.0"
}
}
+31
View File
@@ -0,0 +1,31 @@
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import { env } from "./config/env.js";
import { errorHandler } from "./middleware/errorHandler.js";
import { authRouter } from "./modules/auth/routes.js";
import { usersRouter } from "./modules/users/routes.js";
import { syncRouter } from "./modules/sync/routes.js";
export function createApp() {
const app = express();
app.use(express.json({ limit: "10mb" }));
app.use(cookieParser());
// Same-origin in production (served behind nginx). CORS only needed when the
// frontend dev server runs on a different origin than the API.
if (env.corsOrigins.length > 0) {
app.use(cors({ origin: env.corsOrigins, credentials: true }));
}
app.get("/api/v1/health", (_req, res) => res.json({ ok: true }));
app.use("/api/v1/auth", authRouter);
app.use("/api/v1", usersRouter);
app.use("/api/v1/sync", syncRouter);
app.use(errorHandler);
return app;
}
+17
View File
@@ -0,0 +1,17 @@
export const env = {
port: Number(process.env.PORT) || 3001,
databaseUrl: process.env.DATABASE_URL || "",
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-me",
isProduction: process.env.NODE_ENV === "production",
corsOrigins: (process.env.CORS_ORIGINS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
// Embedded PGlite data directory (used only when DATABASE_URL is empty)
pgliteDir: process.env.PGLITE_DIR || "./.pgdata",
};
// Token lifetimes
export const ACCESS_TOKEN_TTL = "15m";
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export const REFRESH_COOKIE_NAME = "skori_refresh";
+41
View File
@@ -0,0 +1,41 @@
import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
import { drizzle as drizzlePglite } from "drizzle-orm/pglite";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import pg from "pg";
import { PGlite } from "@electric-sql/pglite";
import * as schema from "./schema.js";
import { env } from "../config/env.js";
import { ensureSchema } from "./init.js";
// Both drivers expose the same query API for our schema; we normalise the
// exported type to the node-postgres flavour to keep call sites simple.
export type AppDatabase = NodePgDatabase<typeof schema>;
let dbInstance: AppDatabase | null = null;
export async function initDb(): Promise<AppDatabase> {
if (dbInstance) return dbInstance;
if (env.databaseUrl) {
const pool = new pg.Pool({ connectionString: env.databaseUrl });
dbInstance = drizzlePg(pool, { schema });
console.log("[db] using PostgreSQL (node-postgres)");
} else {
const client = new PGlite(env.pgliteDir);
await client.waitReady;
dbInstance = drizzlePglite(client, {
schema,
}) as unknown as AppDatabase;
console.log(`[db] using embedded PGlite (${env.pgliteDir})`);
}
await ensureSchema(dbInstance);
return dbInstance;
}
export function getDb(): AppDatabase {
if (!dbInstance) {
throw new Error("Database not initialised — call initDb() first");
}
return dbInstance;
}
+70
View File
@@ -0,0 +1,70 @@
import { sql } from "drizzle-orm";
import type { AppDatabase } from "./client.js";
// Idempotent schema creation. Runs on every boot; safe to run repeatedly.
// Kept as plain SQL so it works identically on node-postgres and PGlite,
// without a separate migration toolchain for this single-instance app.
const STATEMENTS = [
`CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY,
email text NOT NULL UNIQUE,
username text UNIQUE,
password_hash text NOT NULL,
display_name text NOT NULL,
avatar_url text,
created_at bigint NOT NULL,
updated_at bigint NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS refresh_tokens (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash text NOT NULL,
expires_at bigint NOT NULL,
revoked_at bigint,
created_at bigint NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS refresh_tokens_hash_idx ON refresh_tokens (token_hash)`,
`CREATE TABLE IF NOT EXISTS locations (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
address text,
created_at bigint NOT NULL,
updated_at bigint NOT NULL,
deleted_at bigint
)`,
`CREATE INDEX IF NOT EXISTS locations_owner_updated_idx ON locations (owner_id, updated_at)`,
`CREATE TABLE IF NOT EXISTS players (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
avatar text,
created_at bigint NOT NULL,
updated_at bigint NOT NULL,
deleted_at bigint
)`,
`CREATE INDEX IF NOT EXISTS players_owner_updated_idx ON players (owner_id, updated_at)`,
`CREATE TABLE IF NOT EXISTS sessions (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
game_id text NOT NULL,
date_start bigint NOT NULL,
date_end bigint,
players jsonb NOT NULL,
rounds jsonb NOT NULL,
status text NOT NULL,
options jsonb NOT NULL,
winner_ids jsonb,
location_id uuid,
created_at bigint NOT NULL,
updated_at bigint NOT NULL,
deleted_at bigint
)`,
`CREATE INDEX IF NOT EXISTS sessions_owner_updated_idx ON sessions (owner_id, updated_at)`,
];
export async function ensureSchema(db: AppDatabase) {
for (const statement of STATEMENTS) {
await db.execute(sql.raw(statement));
}
}
+103
View File
@@ -0,0 +1,103 @@
import {
pgTable,
uuid,
text,
bigint,
jsonb,
index,
} from "drizzle-orm/pg-core";
// Epoch-millis timestamps (matches the client's number-based model exactly).
const ms = (name: string) => bigint(name, { mode: "number" });
export const users = pgTable("users", {
id: uuid("id").primaryKey(),
email: text("email").notNull().unique(),
username: text("username").unique(),
passwordHash: text("password_hash").notNull(),
displayName: text("display_name").notNull(),
avatarUrl: text("avatar_url"),
createdAt: ms("created_at").notNull(),
updatedAt: ms("updated_at").notNull(),
});
export const refreshTokens = pgTable("refresh_tokens", {
id: uuid("id").primaryKey(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
tokenHash: text("token_hash").notNull(),
expiresAt: ms("expires_at").notNull(),
revokedAt: ms("revoked_at"),
createdAt: ms("created_at").notNull(),
});
export const locations = pgTable(
"locations",
{
id: uuid("id").primaryKey(),
ownerId: uuid("owner_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
address: text("address"),
createdAt: ms("created_at").notNull(),
updatedAt: ms("updated_at").notNull(),
deletedAt: ms("deleted_at"),
},
(t) => ({
ownerUpdatedIdx: index("locations_owner_updated_idx").on(
t.ownerId,
t.updatedAt,
),
}),
);
export const players = pgTable(
"players",
{
id: uuid("id").primaryKey(),
ownerId: uuid("owner_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
avatar: text("avatar"),
createdAt: ms("created_at").notNull(),
updatedAt: ms("updated_at").notNull(),
deletedAt: ms("deleted_at"),
},
(t) => ({
ownerUpdatedIdx: index("players_owner_updated_idx").on(
t.ownerId,
t.updatedAt,
),
}),
);
export const sessions = pgTable(
"sessions",
{
id: uuid("id").primaryKey(),
ownerId: uuid("owner_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
gameId: text("game_id").notNull(),
dateStart: ms("date_start").notNull(),
dateEnd: ms("date_end"),
players: jsonb("players").notNull(),
rounds: jsonb("rounds").notNull(),
status: text("status").notNull(),
options: jsonb("options").notNull(),
winnerIds: jsonb("winner_ids"),
locationId: uuid("location_id"),
createdAt: ms("created_at").notNull(),
updatedAt: ms("updated_at").notNull(),
deletedAt: ms("deleted_at"),
},
(t) => ({
ownerUpdatedIdx: index("sessions_owner_updated_idx").on(
t.ownerId,
t.updatedAt,
),
}),
);
+16
View File
@@ -0,0 +1,16 @@
import { createApp } from "./app.js";
import { initDb } from "./db/client.js";
import { env } from "./config/env.js";
async function main() {
await initDb();
const app = createApp();
app.listen(env.port, () => {
console.log(`[skori-server] listening on http://localhost:${env.port}`);
});
}
main().catch((err) => {
console.error("Fatal startup error:", err);
process.exit(1);
});
+29
View File
@@ -0,0 +1,29 @@
import type { Request, Response, NextFunction } from "express";
import { verifyAccessToken } from "../utils/jwt.js";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
userId?: string;
}
}
}
export function requireAuth(
req: Request,
res: Response,
next: NextFunction,
): void {
const header = req.headers.authorization;
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
const userId = token ? verifyAccessToken(token) : null;
if (!userId) {
res.status(401).json({ error: "unauthorized" });
return;
}
req.userId = userId;
next();
}
+39
View File
@@ -0,0 +1,39 @@
import type { Request, Response, NextFunction } from "express";
import { ZodError } from "zod";
export class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function errorHandler(
err: unknown,
_req: Request,
res: Response,
_next: NextFunction,
): void {
if (err instanceof ZodError) {
res.status(400).json({ error: "validation_error", details: err.issues });
return;
}
if (err instanceof HttpError) {
res.status(err.status).json({ error: err.message });
return;
}
console.error("[error]", err);
res.status(500).json({ error: "internal_error" });
}
// Wraps async route handlers so thrown errors reach the error handler.
export function asyncHandler<
T extends (req: Request, res: Response, next: NextFunction) => Promise<unknown>,
>(fn: T) {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next);
};
}
+70
View File
@@ -0,0 +1,70 @@
import type { Request, Response } from "express";
import { z } from "zod";
import * as authService from "./service.js";
import { HttpError } from "../../middleware/errorHandler.js";
import {
env,
REFRESH_COOKIE_NAME,
REFRESH_TOKEN_TTL_MS,
} from "../../config/env.js";
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(200),
displayName: z.string().min(1).max(80),
username: z
.string()
.min(3)
.max(30)
.regex(/^[a-zA-Z0-9_.-]+$/)
.optional(),
desiredId: z.string().uuid().optional(),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
function setRefreshCookie(res: Response, token: string) {
res.cookie(REFRESH_COOKIE_NAME, token, {
httpOnly: true,
secure: env.isProduction,
sameSite: "strict",
maxAge: REFRESH_TOKEN_TTL_MS,
path: "/api/v1/auth",
});
}
function clearRefreshCookie(res: Response) {
res.clearCookie(REFRESH_COOKIE_NAME, { path: "/api/v1/auth" });
}
export async function register(req: Request, res: Response) {
const input = registerSchema.parse(req.body);
const result = await authService.register(input);
setRefreshCookie(res, result.refreshToken);
res.status(201).json({ user: result.user, accessToken: result.accessToken });
}
export async function login(req: Request, res: Response) {
const input = loginSchema.parse(req.body);
const result = await authService.login(input);
setRefreshCookie(res, result.refreshToken);
res.json({ user: result.user, accessToken: result.accessToken });
}
export async function refresh(req: Request, res: Response) {
const token = req.cookies?.[REFRESH_COOKIE_NAME];
if (!token) throw new HttpError(401, "missing_refresh_token");
const result = await authService.refresh(token);
setRefreshCookie(res, result.refreshToken);
res.json({ user: result.user, accessToken: result.accessToken });
}
export async function logout(req: Request, res: Response) {
const token = req.cookies?.[REFRESH_COOKIE_NAME];
if (token) await authService.logout(token);
clearRefreshCookie(res);
res.json({ ok: true });
}
+10
View File
@@ -0,0 +1,10 @@
import { Router } from "express";
import { asyncHandler } from "../../middleware/errorHandler.js";
import * as controller from "./controller.js";
export const authRouter = Router();
authRouter.post("/register", asyncHandler(controller.register));
authRouter.post("/login", asyncHandler(controller.login));
authRouter.post("/refresh", asyncHandler(controller.refresh));
authRouter.post("/logout", asyncHandler(controller.logout));
+185
View File
@@ -0,0 +1,185 @@
import { and, eq, gt, isNull } from "drizzle-orm";
import { getDb } from "../../db/client.js";
import { users, refreshTokens } from "../../db/schema.js";
import { hashPassword, verifyPassword } from "../../utils/password.js";
import { signAccessToken } from "../../utils/jwt.js";
import { generateRefreshToken, hashToken, newId } from "../../utils/tokens.js";
import { REFRESH_TOKEN_TTL_MS } from "../../config/env.js";
import { HttpError } from "../../middleware/errorHandler.js";
export interface PublicUser {
id: string;
email: string;
username: string | null;
displayName: string;
avatarUrl: string | null;
}
export interface AuthResult {
user: PublicUser;
accessToken: string;
refreshToken: string;
}
function toPublicUser(u: typeof users.$inferSelect): PublicUser {
return {
id: u.id,
email: u.email,
username: u.username,
displayName: u.displayName,
avatarUrl: u.avatarUrl,
};
}
async function issueTokens(
userId: string,
): Promise<{ accessToken: string; refreshToken: string }> {
const db = getDb();
const now = Date.now();
const { token, hash } = generateRefreshToken();
await db.insert(refreshTokens).values({
id: newId(),
userId,
tokenHash: hash,
expiresAt: now + REFRESH_TOKEN_TTL_MS,
revokedAt: null,
createdAt: now,
});
return { accessToken: signAccessToken(userId), refreshToken: token };
}
export async function register(input: {
email: string;
password: string;
displayName: string;
username?: string;
desiredId?: string;
}): Promise<AuthResult> {
const db = getDb();
const email = input.email.toLowerCase().trim();
const existing = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existing.length > 0) {
throw new HttpError(409, "email_taken");
}
if (input.username) {
const takenUsername = await db
.select({ id: users.id })
.from(users)
.where(eq(users.username, input.username))
.limit(1);
if (takenUsername.length > 0) {
throw new HttpError(409, "username_taken");
}
}
// Reuse the client's local profile id as the account id (zero-mapping sync).
let userId = input.desiredId || newId();
if (input.desiredId) {
const clash = await db
.select({ id: users.id })
.from(users)
.where(eq(users.id, input.desiredId))
.limit(1);
if (clash.length > 0) userId = newId();
}
const now = Date.now();
const passwordHash = await hashPassword(input.password);
const [user] = await db
.insert(users)
.values({
id: userId,
email,
username: input.username || null,
passwordHash,
displayName: input.displayName.trim() || email,
avatarUrl: null,
createdAt: now,
updatedAt: now,
})
.returning();
const tokens = await issueTokens(user.id);
return { user: toPublicUser(user), ...tokens };
}
export async function login(input: {
email: string;
password: string;
}): Promise<AuthResult> {
const db = getDb();
const email = input.email.toLowerCase().trim();
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user || !(await verifyPassword(input.password, user.passwordHash))) {
throw new HttpError(401, "invalid_credentials");
}
const tokens = await issueTokens(user.id);
return { user: toPublicUser(user), ...tokens };
}
// Validates a refresh token and rotates it (single-use). Returns a fresh
// access token, a new refresh token, and the owning user.
export async function refresh(rawToken: string): Promise<AuthResult> {
const db = getDb();
const hash = hashToken(rawToken);
const now = Date.now();
const [row] = await db
.select()
.from(refreshTokens)
.where(
and(
eq(refreshTokens.tokenHash, hash),
isNull(refreshTokens.revokedAt),
gt(refreshTokens.expiresAt, now),
),
)
.limit(1);
if (!row) {
throw new HttpError(401, "invalid_refresh_token");
}
// Rotate: revoke the used token before issuing a new one.
await db
.update(refreshTokens)
.set({ revokedAt: now })
.where(eq(refreshTokens.id, row.id));
const [user] = await db
.select()
.from(users)
.where(eq(users.id, row.userId))
.limit(1);
if (!user) {
throw new HttpError(401, "invalid_refresh_token");
}
const tokens = await issueTokens(user.id);
return { user: toPublicUser(user), ...tokens };
}
export async function logout(rawToken: string): Promise<void> {
const db = getDb();
const hash = hashToken(rawToken);
await db
.update(refreshTokens)
.set({ revokedAt: Date.now() })
.where(eq(refreshTokens.tokenHash, hash));
}
+21
View File
@@ -0,0 +1,21 @@
import type { Request, Response } from "express";
import { z } from "zod";
import * as syncService from "./service.js";
const pushSchema = z.object({
locations: z.array(z.any()).optional(),
players: z.array(z.any()).optional(),
sessions: z.array(z.any()).optional(),
});
export async function pull(req: Request, res: Response) {
const since = Number(req.query.since) || 0;
const result = await syncService.pull(req.userId!, since);
res.json(result);
}
export async function push(req: Request, res: Response) {
const payload = pushSchema.parse(req.body);
const result = await syncService.push(req.userId!, payload);
res.json(result);
}
+9
View File
@@ -0,0 +1,9 @@
import { Router } from "express";
import { requireAuth } from "../../middleware/auth.js";
import { asyncHandler } from "../../middleware/errorHandler.js";
import * as controller from "./controller.js";
export const syncRouter = Router();
syncRouter.get("/", requireAuth, asyncHandler(controller.pull));
syncRouter.post("/", requireAuth, asyncHandler(controller.push));
+143
View File
@@ -0,0 +1,143 @@
import { and, eq, gt } from "drizzle-orm";
import { getDb } from "../../db/client.js";
import { locations, players, sessions } from "../../db/schema.js";
// ---- Pull: everything changed since a cursor (tombstones included) ----
export async function pull(ownerId: string, since: number) {
const db = getDb();
const serverTime = Date.now();
const [loc, pl, se] = await Promise.all([
db
.select()
.from(locations)
.where(and(eq(locations.ownerId, ownerId), gt(locations.updatedAt, since))),
db
.select()
.from(players)
.where(and(eq(players.ownerId, ownerId), gt(players.updatedAt, since))),
db
.select()
.from(sessions)
.where(and(eq(sessions.ownerId, ownerId), gt(sessions.updatedAt, since))),
]);
return { serverTime, locations: loc, players: pl, sessions: se };
}
// ---- Push: upsert client records; server clock is authoritative ----
export interface PushPayload {
locations?: any[];
players?: any[];
sessions?: any[];
}
interface Applied {
id: string;
updatedAt: number;
}
export async function push(ownerId: string, payload: PushPayload) {
const db = getDb();
const now = Date.now();
const applied = {
locations: [] as Applied[],
players: [] as Applied[],
sessions: [] as Applied[],
};
await db.transaction(async (tx) => {
for (const r of payload.locations ?? []) {
if (!r?.id) continue;
await tx
.insert(locations)
.values({
id: r.id,
ownerId,
name: r.name ?? "",
address: r.address ?? null,
createdAt: r.createdAt ?? now,
updatedAt: now,
deletedAt: r.deletedAt ?? null,
})
.onConflictDoUpdate({
target: locations.id,
set: {
name: r.name ?? "",
address: r.address ?? null,
updatedAt: now,
deletedAt: r.deletedAt ?? null,
},
});
applied.locations.push({ id: r.id, updatedAt: now });
}
for (const r of payload.players ?? []) {
if (!r?.id) continue;
await tx
.insert(players)
.values({
id: r.id,
ownerId,
name: r.name ?? "",
avatar: r.avatar ?? null,
createdAt: r.createdAt ?? now,
updatedAt: now,
deletedAt: r.deletedAt ?? null,
})
.onConflictDoUpdate({
target: players.id,
set: {
name: r.name ?? "",
avatar: r.avatar ?? null,
updatedAt: now,
deletedAt: r.deletedAt ?? null,
},
});
applied.players.push({ id: r.id, updatedAt: now });
}
for (const r of payload.sessions ?? []) {
if (!r?.id) continue;
await tx
.insert(sessions)
.values({
id: r.id,
ownerId,
gameId: r.gameId ?? "",
dateStart: r.dateStart ?? now,
dateEnd: r.dateEnd ?? null,
players: r.players ?? [],
rounds: r.rounds ?? [],
status: r.status ?? "finished",
options: r.options ?? {},
winnerIds: r.winnerIds ?? null,
locationId: r.locationId ?? null,
createdAt: r.dateStart ?? now,
updatedAt: now,
deletedAt: r.deletedAt ?? null,
})
.onConflictDoUpdate({
target: sessions.id,
set: {
gameId: r.gameId ?? "",
dateStart: r.dateStart ?? now,
dateEnd: r.dateEnd ?? null,
players: r.players ?? [],
rounds: r.rounds ?? [],
status: r.status ?? "finished",
options: r.options ?? {},
winnerIds: r.winnerIds ?? null,
locationId: r.locationId ?? null,
updatedAt: now,
deletedAt: r.deletedAt ?? null,
},
});
applied.sessions.push({ id: r.id, updatedAt: now });
}
});
return { serverTime: now, applied };
}
+63
View File
@@ -0,0 +1,63 @@
import type { Request, Response } from "express";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { getDb } from "../../db/client.js";
import { users } from "../../db/schema.js";
import { HttpError } from "../../middleware/errorHandler.js";
const patchSchema = z.object({
displayName: z.string().min(1).max(80).optional(),
avatarUrl: z.string().max(500_000).nullable().optional(),
username: z
.string()
.min(3)
.max(30)
.regex(/^[a-zA-Z0-9_.-]+$/)
.nullable()
.optional(),
});
function publicUser(u: typeof users.$inferSelect) {
return {
id: u.id,
email: u.email,
username: u.username,
displayName: u.displayName,
avatarUrl: u.avatarUrl,
};
}
export async function getMe(req: Request, res: Response) {
const db = getDb();
const [user] = await db
.select()
.from(users)
.where(eq(users.id, req.userId!))
.limit(1);
if (!user) throw new HttpError(404, "user_not_found");
res.json({ user: publicUser(user) });
}
export async function patchMe(req: Request, res: Response) {
const db = getDb();
const input = patchSchema.parse(req.body);
if (input.username) {
const [clash] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.username, input.username))
.limit(1);
if (clash && clash.id !== req.userId) {
throw new HttpError(409, "username_taken");
}
}
const [user] = await db
.update(users)
.set({ ...input, updatedAt: Date.now() })
.where(eq(users.id, req.userId!))
.returning();
if (!user) throw new HttpError(404, "user_not_found");
res.json({ user: publicUser(user) });
}
+9
View File
@@ -0,0 +1,9 @@
import { Router } from "express";
import { requireAuth } from "../../middleware/auth.js";
import { asyncHandler } from "../../middleware/errorHandler.js";
import * as controller from "./controller.js";
export const usersRouter = Router();
usersRouter.get("/me", requireAuth, asyncHandler(controller.getMe));
usersRouter.patch("/me", requireAuth, asyncHandler(controller.patchMe));
+174
View File
@@ -0,0 +1,174 @@
import { randomUUID } from "node:crypto";
import type { Server } from "node:http";
import { createApp } from "./app.js";
import { initDb } from "./db/client.js";
const BASE = "http://127.0.0.1:4599/api/v1";
let passed = 0;
let failed = 0;
function check(label: string, cond: boolean) {
if (cond) {
passed++;
console.log(` PASS ${label}`);
} else {
failed++;
console.error(` FAIL ${label}`);
}
}
async function req(
method: string,
path: string,
body?: unknown,
token?: string,
cookie?: string,
) {
const headers: Record<string, string> = { "content-type": "application/json" };
if (token) headers.authorization = `Bearer ${token}`;
if (cookie) headers.cookie = cookie;
const res = await fetch(BASE + path, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const setCookie = res.headers.get("set-cookie") || undefined;
let json: any = null;
try {
json = await res.json();
} catch {
/* no body */
}
return { status: res.status, json, setCookie };
}
async function main() {
await initDb();
const app = createApp();
const server: Server = await new Promise((resolve) => {
const s = app.listen(4599, () => resolve(s));
});
try {
// --- A. Register ---
const email = `u${Date.now()}@test.dev`;
const profileId = randomUUID();
const reg = await req("POST", "/auth/register", {
email,
password: "supersecret",
displayName: "Alice",
desiredId: profileId,
});
check("register returns 201", reg.status === 201);
check("register reuses profile id as account id", reg.json?.user?.id === profileId);
check("register returns access token", typeof reg.json?.accessToken === "string");
check("register sets refresh cookie", !!reg.setCookie);
const tokenA = reg.json.accessToken;
// --- B. Push data from "device A" ---
const locId = randomUUID();
const playerId = randomUUID();
const sessionId = randomUUID();
const t0 = Date.now();
const push1 = await req(
"POST",
"/sync",
{
locations: [
{ id: locId, name: "Maison", createdAt: t0, updatedAt: t0 },
],
players: [
{ id: playerId, name: "Bob", createdAt: t0, updatedAt: t0 },
],
sessions: [
{
id: sessionId,
gameId: "skyjo",
dateStart: t0,
dateEnd: t0,
players: [{ id: playerId, name: "Bob" }],
rounds: [],
status: "finished",
options: {},
winnerIds: [playerId],
locationId: locId,
updatedAt: t0,
},
],
},
tokenA,
);
check("push returns 200", push1.status === 200);
check("push acks location", push1.json?.applied?.locations?.[0]?.id === locId);
check("push ack has server updatedAt", typeof push1.json?.applied?.sessions?.[0]?.updatedAt === "number");
// --- C. Second device pulls everything from scratch ---
const login = await req("POST", "/auth/login", {
email,
password: "supersecret",
});
check("login returns 200", login.status === 200);
const tokenB = login.json.accessToken;
const pullB = await req("GET", "/sync?since=0", undefined, tokenB);
check("pull returns 200", pullB.status === 200);
check("device B pulls the location", pullB.json?.locations?.some((l: any) => l.id === locId));
check("device B pulls the player", pullB.json?.players?.some((p: any) => p.id === playerId));
check("device B pulls the session", pullB.json?.sessions?.some((s: any) => s.id === sessionId));
check("pulled session keeps jsonb players", Array.isArray(pullB.json?.sessions?.[0]?.players));
check("pull returns a server cursor", typeof pullB.json?.serverTime === "number");
// --- D. Conflict: both devices edit the same location, last push wins ---
await req("POST", "/sync", {
locations: [{ id: locId, name: "Maison A", updatedAt: Date.now() }],
}, tokenA);
await new Promise((r) => setTimeout(r, 5));
await req("POST", "/sync", {
locations: [{ id: locId, name: "Maison B", updatedAt: Date.now() }],
}, tokenB);
const pullFinal = await req("GET", "/sync?since=0", undefined, tokenA);
const finalLoc = pullFinal.json.locations.find((l: any) => l.id === locId);
check("conflict resolves last-write-wins (Maison B)", finalLoc?.name === "Maison B");
// --- E. Tombstone: soft delete propagates ---
await req("POST", "/sync", {
players: [{ id: playerId, name: "Bob", deletedAt: Date.now(), updatedAt: Date.now() }],
}, tokenA);
const pullDel = await req("GET", "/sync?since=0", undefined, tokenB);
const delPlayer = pullDel.json.players.find((p: any) => p.id === playerId);
check("tombstone is returned on pull", !!delPlayer?.deletedAt);
// --- F. Incremental pull excludes old records ---
const cursor = pullFinal.json.serverTime;
await new Promise((r) => setTimeout(r, 5));
const freshLoc = randomUUID();
await req("POST", "/sync", {
locations: [{ id: freshLoc, name: "Nouveau", updatedAt: Date.now() }],
}, tokenA);
const pullSince = await req("GET", `/sync?since=${cursor}`, undefined, tokenB);
check("incremental pull includes new record", pullSince.json.locations.some((l: any) => l.id === freshLoc));
// --- G. Refresh token rotation ---
const cookie = reg.setCookie!.split(";")[0];
const refreshed = await req("POST", "/auth/refresh", undefined, undefined, cookie);
check("refresh returns new access token", typeof refreshed.json?.accessToken === "string");
// Old refresh token is now revoked (rotation)
const reuse = await req("POST", "/auth/refresh", undefined, undefined, cookie);
check("reusing rotated refresh token fails", reuse.status === 401);
// --- H. Auth required on sync ---
const noAuth = await req("GET", "/sync?since=0");
check("sync without token is 401", noAuth.status === 401);
} finally {
server.close();
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch((err) => {
console.error("Smoke test crashed:", err);
process.exit(1);
});
+21
View File
@@ -0,0 +1,21 @@
import jwt from "jsonwebtoken";
import { env, ACCESS_TOKEN_TTL } from "../config/env.js";
interface AccessPayload {
sub: string; // user id
}
export function signAccessToken(userId: string): string {
return jwt.sign({ sub: userId } satisfies AccessPayload, env.jwtSecret, {
expiresIn: ACCESS_TOKEN_TTL,
});
}
export function verifyAccessToken(token: string): string | null {
try {
const decoded = jwt.verify(token, env.jwtSecret) as AccessPayload;
return decoded.sub || null;
} catch {
return null;
}
}
+15
View File
@@ -0,0 +1,15 @@
import bcrypt from "bcryptjs";
// bcryptjs is pure-JS: no native build step, works out of the box in Alpine.
const ROUNDS = 10;
export function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, ROUNDS);
}
export function verifyPassword(
password: string,
hash: string,
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
+15
View File
@@ -0,0 +1,15 @@
import { randomBytes, createHash, randomUUID } from "node:crypto";
// Opaque refresh tokens: a random secret is stored only as a hash server-side.
export function generateRefreshToken(): { token: string; hash: string } {
const token = randomBytes(48).toString("base64url");
return { token, hash: hashToken(token) };
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function newId(): string {
return randomUUID();
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"declaration": false,
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+17 -1
View File
@@ -8,16 +8,29 @@ import PlayGame from "./pages/PlayGame";
import GameOver from "./pages/GameOver";
import History from "./pages/History";
import Players from "./pages/Players";
import Locations from "./pages/Locations";
import Profiles from "./pages/Profiles";
import Auth from "./pages/Auth";
import Settings from "./pages/Settings";
import Statistics from "./pages/Stats";
import { useAppStore } from "./stores/appStore";
import { useProfileStore } from "./stores/profileStore";
import { useAuthStore } from "./stores/authStore";
import { startSyncTriggers } from "./sync/syncEngine";
function App() {
const { loadSettings } = useAppStore();
const { loadProfiles } = useProfileStore();
const restoreAuth = useAuthStore((s) => s.restore);
useEffect(() => {
// Order matters: profiles must be loaded before auth restore triggers a sync.
loadProfiles().then(() => {
restoreAuth();
startSyncTriggers();
});
loadSettings();
}, [loadSettings]);
}, [loadProfiles, loadSettings, restoreAuth]);
return (
<BrowserRouter>
@@ -26,6 +39,9 @@ function App() {
<Route path="/" element={<Home />} />
<Route path="/history" element={<History />} />
<Route path="/players" element={<Players />} />
<Route path="/locations" element={<Locations />} />
<Route path="/profiles" element={<Profiles />} />
<Route path="/login" element={<Auth />} />
<Route path="/stats" element={<Statistics />} />
<Route path="/settings" element={<Settings />} />
</Route>
+48 -3
View File
@@ -1,13 +1,26 @@
import { useState, useRef, useEffect } from "react";
import { Menu, Home, History, Settings, Users, BarChart2 } from "lucide-react";
import {
Menu,
Home,
History,
Settings,
Users,
BarChart2,
MapPin,
UserCircle,
ChevronRight,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "./ui/button";
import { Avatar } from "./ui/avatar";
import { useProfileStore } from "../stores/profileStore";
export function NavigationMenu() {
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
const menuRef = useRef<HTMLDivElement>(null);
const activeProfile = useProfileStore((s) => s.activeProfile);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -41,12 +54,32 @@ export function NavigationMenu() {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="absolute top-full left-0 mt-2 w-56 bg-card border shadow-xl rounded-2xl overflow-hidden"
className="absolute top-full left-0 mt-2 w-60 bg-card border shadow-xl rounded-2xl overflow-hidden"
>
<div className="flex flex-col">
<button
onClick={() => nav("/profiles")}
className="flex items-center w-full p-4 hover:bg-secondary text-left transition-colors bg-primary/5"
>
<Avatar
src={activeProfile?.avatar}
name={activeProfile?.name || "?"}
size="md"
className="mr-3"
/>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Profil actif
</div>
<div className="font-black truncate">
{activeProfile?.name || "—"}
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
</button>
<button
onClick={() => nav("/")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors"
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
>
<Home className="w-5 h-5 mr-3 text-primary" /> Accueil
</button>
@@ -62,12 +95,24 @@ export function NavigationMenu() {
>
<Users className="w-5 h-5 mr-3 text-primary" /> Joueurs
</button>
<button
onClick={() => nav("/locations")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
>
<MapPin className="w-5 h-5 mr-3 text-primary" /> Emplacements
</button>
<button
onClick={() => nav("/stats")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
>
<BarChart2 className="w-5 h-5 mr-3 text-primary" /> Statistiques
</button>
<button
onClick={() => nav("/profiles")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
>
<UserCircle className="w-5 h-5 mr-3 text-primary" /> Profils
</button>
<button
onClick={() => nav("/settings")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
+154 -2
View File
@@ -1,10 +1,30 @@
import Dexie, { Table } from "dexie";
import { GameSession, AppSettings, SavedPlayer } from "../types";
import {
GameSession,
AppSettings,
SavedPlayer,
Location,
Profile,
SyncState,
} from "../types";
import { generateId } from "../utils/id";
// When the sync engine applies records pulled/acked from the server, it flips
// this flag so the auto-dirty hooks don't re-mark those writes as dirty.
export const remoteApply = { active: false };
// The sync engine registers a (debounced) callback here so any local change to
// a synced table promptly schedules a push. Kept as a hook to avoid a circular
// import between db.ts and syncEngine.ts.
export const localChange = { notify: null as null | (() => void) };
export class BoardScoreDatabase extends Dexie {
sessions!: Table<GameSession, string>;
settings!: Table<AppSettings, number>;
players!: Table<SavedPlayer, string>;
locations!: Table<Location, string>;
profiles!: Table<Profile, string>;
syncState!: Table<SyncState, string>;
constructor() {
super("BoardScoreDatabase");
@@ -20,16 +40,148 @@ export class BoardScoreDatabase extends Dexie {
settings: "id",
players: "id, name, createdAt",
});
// Version 3: Added locations table, updatedAt tracking for future sync
this.version(3)
.stores({
sessions: "id, gameId, dateStart, status, locationId, updatedAt",
settings: "id",
players: "id, name, createdAt, updatedAt",
locations: "id, name, createdAt, updatedAt",
})
.upgrade(async (tx) => {
await tx
.table("sessions")
.toCollection()
.modify((session) => {
session.updatedAt = session.dateEnd ?? session.dateStart;
});
await tx
.table("players")
.toCollection()
.modify((player) => {
player.updatedAt = player.createdAt;
});
});
// Version 4: Added local profiles; scope existing data to a default profile
this.version(4)
.stores({
sessions:
"id, gameId, dateStart, status, locationId, updatedAt, profileId",
settings: "id",
players: "id, name, createdAt, updatedAt, profileId",
locations: "id, name, createdAt, updatedAt, profileId",
profiles: "id, name, createdAt, updatedAt",
})
.upgrade(async (tx) => {
const now = Date.now();
const defaultProfileId = generateId();
await tx.table("profiles").add({
id: defaultProfileId,
name: "Moi",
createdAt: now,
updatedAt: now,
});
await tx
.table("sessions")
.toCollection()
.modify((session) => {
session.profileId = defaultProfileId;
});
await tx
.table("players")
.toCollection()
.modify((player) => {
player.profileId = defaultProfileId;
});
await tx
.table("locations")
.toCollection()
.modify((location) => {
location.profileId = defaultProfileId;
});
const settings = await tx.table("settings").get(1);
if (settings) {
await tx
.table("settings")
.update(1, { activeProfileId: defaultProfileId });
} else {
await tx.table("settings").add({
id: 1,
theme: "system",
language: "fr",
activeProfileId: defaultProfileId,
});
}
});
// Version 5: Sync bookkeeping (dirty flag + sync cursor per profile)
this.version(5)
.stores({
sessions:
"id, gameId, dateStart, status, locationId, updatedAt, profileId, dirty",
settings: "id",
players: "id, name, createdAt, updatedAt, profileId, dirty",
locations: "id, name, createdAt, updatedAt, profileId, dirty",
profiles: "id, name, createdAt, updatedAt",
syncState: "profileId",
})
.upgrade(async (tx) => {
// Mark all pre-existing records dirty so they push on the first sync.
for (const table of ["sessions", "players", "locations"]) {
await tx
.table(table)
.toCollection()
.modify((row) => {
row.dirty = 1;
});
}
});
}
}
export const db = new BoardScoreDatabase();
// Initialize default settings if empty
// ---- Auto-stamp updatedAt + dirty on every local write to synced tables ----
// Skipped while the sync engine is applying server data (remoteApply.active).
for (const table of [db.sessions, db.players, db.locations]) {
table.hook("creating", (_primKey, obj: any) => {
if (remoteApply.active) return;
if (obj.updatedAt === undefined) obj.updatedAt = Date.now();
if (obj.dirty === undefined) obj.dirty = 1;
localChange.notify?.();
});
table.hook("updating", (modifications: any) => {
if (remoteApply.active) return;
// Don't clobber an explicit dirty/updatedAt already in this update.
const extra: Record<string, unknown> = {};
if (modifications.updatedAt === undefined) extra.updatedAt = Date.now();
if (modifications.dirty === undefined) extra.dirty = 1;
localChange.notify?.();
return extra;
});
}
// Initialize default profile + settings on a fresh install
db.on("populate", async () => {
const now = Date.now();
const defaultProfileId = generateId();
await db.profiles.add({
id: defaultProfileId,
name: "Moi",
createdAt: now,
updatedAt: now,
});
await db.settings.add({
id: 1,
theme: "system",
language: "fr",
activeProfileId: defaultProfileId,
});
});
+101
View File
@@ -0,0 +1,101 @@
// Thin fetch wrapper around the Skori API. The access token lives in memory
// only (never localStorage) to limit XSS blast radius; the refresh token is an
// httpOnly cookie handled entirely by the browser.
const API_BASE = "/api/v1";
let accessToken: string | null = null;
let onUnauthorized: (() => void) | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
export function setOnUnauthorized(cb: (() => void) | null) {
onUnauthorized = cb;
}
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
function rawFetch(
path: string,
opts: RequestInit,
withAuth: boolean,
): Promise<Response> {
const headers: Record<string, string> = {
"content-type": "application/json",
...((opts.headers as Record<string, string>) || {}),
};
if (withAuth && accessToken) {
headers.authorization = `Bearer ${accessToken}`;
}
return fetch(API_BASE + path, {
...opts,
headers,
credentials: "include",
});
}
async function tryRefresh(): Promise<boolean> {
try {
const res = await rawFetch("/auth/refresh", { method: "POST" }, false);
if (!res.ok) return false;
const data = await res.json();
accessToken = data.accessToken;
return true;
} catch {
return false;
}
}
interface CallOptions {
auth?: boolean;
retry?: boolean;
}
export async function apiFetch(
path: string,
opts: RequestInit = {},
{ auth = true, retry = true }: CallOptions = {},
): Promise<Response> {
let res = await rawFetch(path, opts, auth);
if (res.status === 401 && auth && retry) {
const refreshed = await tryRefresh();
if (refreshed) {
res = await rawFetch(path, opts, auth);
} else {
onUnauthorized?.();
}
}
return res;
}
export async function apiJson<T>(
path: string,
opts: RequestInit = {},
cfg: CallOptions = {},
): Promise<T> {
const res = await apiFetch(path, opts, cfg);
if (!res.ok) {
let message = "request_failed";
try {
const body = await res.json();
message = body.error || message;
} catch {
/* ignore */
}
throw new ApiError(res.status, message);
}
return res.json() as Promise<T>;
}
+184
View File
@@ -0,0 +1,184 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { LogIn, UserPlus, Loader2, ChevronLeft } from "lucide-react";
import { useAuthStore } from "../stores/authStore";
import { useProfileStore } from "../stores/profileStore";
import { ApiError } from "../lib/apiClient";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Card, CardContent } from "../components/ui/card";
const schema = z.object({
email: z.string().email("Email invalide"),
password: z.string().min(8, "8 caractères minimum"),
username: z
.string()
.min(3, "3 caractères minimum")
.regex(/^[a-zA-Z0-9_.-]+$/, "Lettres, chiffres, . _ - uniquement")
.optional()
.or(z.literal("")),
});
type FormValues = z.infer<typeof schema>;
const ERROR_MESSAGES: Record<string, string> = {
email_taken: "Cet email est déjà utilisé.",
username_taken: "Ce pseudo est déjà pris.",
invalid_credentials: "Email ou mot de passe incorrect.",
};
export default function Auth() {
const navigate = useNavigate();
const [mode, setMode] = useState<"login" | "register">("login");
const [serverError, setServerError] = useState<string | null>(null);
const { login, register: registerAccount } = useAuthStore();
const activeProfile = useProfileStore((s) => s.activeProfile);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) });
const onSubmit = async (values: FormValues) => {
setServerError(null);
try {
if (mode === "login") {
await login(values.email, values.password);
} else {
await registerAccount({
email: values.email,
password: values.password,
username: values.username || undefined,
displayName: activeProfile?.name,
});
}
navigate("/settings");
} catch (err) {
const code = err instanceof ApiError ? err.message : "request_failed";
setServerError(
ERROR_MESSAGES[code] ||
"Une erreur est survenue. Vérifiez votre connexion.",
);
}
};
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="rounded-full hover:bg-black/10 dark:hover:bg-white/10"
>
<ChevronLeft className="w-6 h-6" />
</Button>
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
{mode === "login" ? "Connexion" : "Créer un compte"}
</h1>
</header>
<p className="text-sm text-muted-foreground px-2">
{mode === "login"
? "Connectez-vous pour synchroniser vos parties entre vos appareils."
: `Créez un compte pour sauvegarder et synchroniser le profil « ${activeProfile?.name ?? ""} ».`}
</p>
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
<CardContent className="p-5">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-bold px-1">Email</label>
<Input
type="email"
autoComplete="email"
placeholder="vous@exemple.fr"
className="h-12"
{...register("email")}
/>
{errors.email && (
<p className="text-xs text-destructive px-1">
{errors.email.message}
</p>
)}
</div>
{mode === "register" && (
<div className="space-y-1">
<label className="text-sm font-bold px-1">
Pseudo (optionnel)
</label>
<Input
autoComplete="username"
placeholder="pour vos futurs amis"
className="h-12"
{...register("username")}
/>
{errors.username && (
<p className="text-xs text-destructive px-1">
{errors.username.message}
</p>
)}
</div>
)}
<div className="space-y-1">
<label className="text-sm font-bold px-1">Mot de passe</label>
<Input
type="password"
autoComplete={
mode === "login" ? "current-password" : "new-password"
}
placeholder="••••••••"
className="h-12"
{...register("password")}
/>
{errors.password && (
<p className="text-xs text-destructive px-1">
{errors.password.message}
</p>
)}
</div>
{serverError && (
<p className="text-sm text-destructive font-medium bg-destructive/10 rounded-xl p-3">
{serverError}
</p>
)}
<Button
type="submit"
disabled={isSubmitting}
className="w-full h-14 rounded-[1.5rem] font-black text-lg shadow-lg"
>
{isSubmitting ? (
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
) : mode === "login" ? (
<LogIn className="w-5 h-5 mr-2" />
) : (
<UserPlus className="w-5 h-5 mr-2" />
)}
{mode === "login" ? "Se connecter" : "Créer le compte"}
</Button>
</form>
</CardContent>
</Card>
<button
onClick={() => {
setServerError(null);
setMode(mode === "login" ? "register" : "login");
}}
className="w-full text-center text-sm font-bold text-primary py-2"
>
{mode === "login"
? "Pas encore de compte ? Créez-en un"
: "Déjà un compte ? Connectez-vous"}
</button>
</div>
);
}
+21 -4
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Trophy, Home, Share2, RotateCcw, Clock } from "lucide-react";
import { Trophy, Home, Share2, RotateCcw, Clock, MapPin } from "lucide-react";
import * as Icons from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db";
import { GameSession } from "../types";
import { getGameConfig } from "../games";
@@ -25,6 +26,12 @@ export default function GameOver() {
const gameConfig = getGameConfig(session?.gameId || "");
useGameTheme(gameConfig);
const location = useLiveQuery(
() =>
session?.locationId ? db.locations.get(session.locationId) : undefined,
[session?.locationId],
);
useEffect(() => {
if (sessionId) {
db.sessions.get(sessionId).then((data) => {
@@ -115,6 +122,8 @@ export default function GameOver() {
gameConfig.id,
newPlayers,
session.options,
session.profileId,
session.locationId,
);
navigate(`/play/${newSessionId}`);
};
@@ -174,9 +183,17 @@ export default function GameOver() {
</p>
</div>
<div className="flex items-center text-sm font-medium text-muted-foreground bg-background px-4 py-2 rounded-full mt-6 shadow-sm border">
<Clock className="w-4 h-4 mr-2" />
{durationInMinutes} minutes {session.rounds.length} manches
<div className="flex items-center flex-wrap justify-center gap-2 mt-6">
<div className="flex items-center text-sm font-medium text-muted-foreground bg-background px-4 py-2 rounded-full shadow-sm border">
<Clock className="w-4 h-4 mr-2" />
{durationInMinutes} minutes {session.rounds.length} manches
</div>
{session.locationId && (
<div className="flex items-center text-sm font-medium text-muted-foreground bg-background px-4 py-2 rounded-full shadow-sm border">
<MapPin className="w-4 h-4 mr-2" />
{location?.name ?? "—"}
</div>
)}
</div>
</motion.div>
</div>
+26 -4
View File
@@ -4,6 +4,8 @@ import { db } from "../database/db";
import { games } from "../games";
import { Card, CardContent } from "../components/ui/card";
import { NavigationMenu } from "../components/NavigationMenu";
import { useProfileStore } from "../stores/profileStore";
import { MapPin } from "lucide-react";
import { motion } from "framer-motion";
@@ -18,9 +20,20 @@ function formatDate(ms: number) {
export default function History() {
const navigate = useNavigate();
const sessions = useLiveQuery(() =>
db.sessions.orderBy("dateStart").reverse().toArray(),
const activeProfileId = useProfileStore((s) => s.activeProfileId);
const sessions = useLiveQuery(
async () => {
if (!activeProfileId) return [];
const arr = await db.sessions
.where("profileId")
.equals(activeProfileId)
.and((s) => !s.deletedAt)
.sortBy("dateStart");
return arr.reverse();
},
[activeProfileId],
);
const locations = useLiveQuery(() => db.locations.toArray()) || [];
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
@@ -40,6 +53,9 @@ export default function History() {
{sessions.map((session, index) => {
const game = games.find((g) => g.id === session.gameId);
if (!game) return null;
const location = session.locationId
? locations.find((l) => l.id === session.locationId)
: undefined;
return (
<motion.div
@@ -85,8 +101,14 @@ export default function History() {
: "Terminée"}
</span>
</div>
<p className="text-sm text-foreground/70 font-medium mb-2">
{formatDate(session.dateStart)}
<p className="text-sm text-foreground/70 font-medium mb-2 flex items-center flex-wrap gap-x-2">
<span>{formatDate(session.dateStart)}</span>
{session.locationId && (
<span className="inline-flex items-center text-xs font-bold text-muted-foreground bg-black/5 dark:bg-white/10 px-2 py-0.5 rounded-full">
<MapPin className="w-3 h-3 mr-1" />
{location?.name ?? "—"}
</span>
)}
</p>
<div className="flex flex-wrap gap-1.5 mt-1">
{session.players.map((p) => {
+13 -3
View File
@@ -5,6 +5,7 @@ import { games } from "../games";
import { db } from "../database/db";
import { motion } from "framer-motion";
import { NavigationMenu } from "../components/NavigationMenu";
import { useProfileStore } from "../stores/profileStore";
// Kurzgesagt-style Planet Logo
const PlanetLogo = () => (
@@ -31,10 +32,19 @@ const PlanetLogo = () => (
export default function Home() {
const navigate = useNavigate();
const activeProfileId = useProfileStore((s) => s.activeProfileId);
// Load unfinished games
const activeSessions = useLiveQuery(() =>
db.sessions.where("status").equals("playing").toArray(),
// Load unfinished games for the active profile
const activeSessions = useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.sessions
.where("profileId")
.equals(activeProfileId)
.and((s) => s.status === "playing" && !s.deletedAt)
.toArray();
},
[activeProfileId],
);
return (
+238
View File
@@ -0,0 +1,238 @@
import { useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db";
import { MapPin, Trash2, Edit2, Check, X, Plus, Home } from "lucide-react";
import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { NavigationMenu } from "../components/NavigationMenu";
import { generateId } from "../utils/id";
import { useProfileStore } from "../stores/profileStore";
import { motion } from "framer-motion";
export default function Locations() {
const activeProfileId = useProfileStore((s) => s.activeProfileId);
const locations = useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.locations
.where("profileId")
.equals(activeProfileId)
.and((l) => !l.deletedAt)
.sortBy("name");
},
[activeProfileId],
);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editAddress, setEditAddress] = useState("");
const [isAdding, setIsAdding] = useState(false);
const [newName, setNewName] = useState("");
const [newAddress, setNewAddress] = useState("");
const handleAdd = async () => {
const name = newName.trim();
if (!name || !activeProfileId) return;
const now = Date.now();
await db.locations.add({
id: generateId(),
name,
address: newAddress.trim() || undefined,
createdAt: now,
updatedAt: now,
profileId: activeProfileId,
});
setNewName("");
setNewAddress("");
setIsAdding(false);
};
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Êtes-vous sûr de vouloir supprimer "${name}" ?`)) {
// Soft-delete (tombstone) so the deletion can propagate to the server.
await db.locations.update(id, { deletedAt: Date.now() });
}
};
const startEdit = (id: string, name: string, address?: string) => {
setEditingId(id);
setEditName(name);
setEditAddress(address ?? "");
};
const saveEdit = async (id: string) => {
const name = editName.trim();
if (name) {
await db.locations.update(id, {
name,
address: editAddress.trim() || undefined,
updatedAt: Date.now(),
});
}
setEditingId(null);
};
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
<NavigationMenu />
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
Emplacements
</h1>
</header>
{isAdding ? (
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
<CardContent className="p-4 space-y-3">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Nom (ex: Maison, Le Valet d'Or...)"
className="h-12"
autoFocus
/>
<Input
value={newAddress}
onChange={(e) => setNewAddress(e.target.value)}
placeholder="Adresse (optionnel)"
className="h-12"
/>
<div className="flex space-x-2">
<Button
onClick={handleAdd}
className="flex-1 rounded-full font-bold"
disabled={!newName.trim()}
>
<Check className="w-5 h-5 mr-2" /> Ajouter
</Button>
<Button
variant="ghost"
onClick={() => {
setIsAdding(false);
setNewName("");
setNewAddress("");
}}
className="rounded-full"
>
<X className="w-5 h-5" />
</Button>
</div>
</CardContent>
</Card>
) : (
<Button
onClick={() => setIsAdding(true)}
className="w-full h-14 rounded-[1.5rem] font-black text-lg shadow-lg"
>
<Plus className="w-6 h-6 mr-2" /> Nouvel emplacement
</Button>
)}
<div className="space-y-4">
{!locations || locations.length === 0 ? (
<div className="text-center text-muted-foreground py-12 flex flex-col items-center bg-background/60 backdrop-blur-sm rounded-[2rem]">
<MapPin className="w-16 h-16 mb-4 opacity-20" />
<p className="font-bold text-lg">Aucun emplacement enregistré.</p>
<p className="text-sm mt-1 max-w-[250px]">
Ajoutez votre maison ou vos enseignes de jeux favorites.
</p>
</div>
) : (
locations.map((location, index) => (
<motion.div
key={location.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{
delay: index * 0.05,
type: "spring",
stiffness: 100,
}}
>
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md overflow-visible relative group">
<div className="absolute -left-2 -top-2 w-8 h-8 rounded-full bg-primary/20 blur-md pointer-events-none group-hover:bg-primary/40 transition-colors" />
<CardContent className="p-4 flex items-center justify-between">
{editingId === location.id ? (
<div className="flex flex-col space-y-2 flex-1 mr-2 relative z-10">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-12"
autoFocus
/>
<Input
value={editAddress}
onChange={(e) => setEditAddress(e.target.value)}
placeholder="Adresse (optionnel)"
className="h-12"
/>
<div className="flex space-x-2">
<Button
variant="ghost"
size="icon"
onClick={() => saveEdit(location.id)}
className="text-green-600 bg-green-500/10 hover:bg-green-500/20"
>
<Check className="w-6 h-6" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingId(null)}
className="text-muted-foreground bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10"
>
<X className="w-6 h-6" />
</Button>
</div>
</div>
) : (
<>
<div className="flex items-center mr-4 shrink-0">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center shadow-md border-4 border-background">
<Home className="w-6 h-6 text-primary" />
</div>
</div>
<div className="flex-1 min-w-0">
<span className="font-black text-xl truncate block tracking-tight">
{location.name}
</span>
{location.address && (
<span className="text-sm text-muted-foreground truncate block">
{location.address}
</span>
)}
</div>
<div className="flex items-center shrink-0 space-x-1 relative z-10">
<Button
variant="ghost"
size="icon"
onClick={() =>
startEdit(location.id, location.name, location.address)
}
className="text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5"
>
<Edit2 className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(location.id, location.name)}
className="text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
</>
)}
</CardContent>
</Card>
</motion.div>
))
)}
</div>
</div>
);
}
+76 -3
View File
@@ -8,6 +8,7 @@ import {
Users,
GripVertical,
BookOpen,
MapPin,
} from "lucide-react";
import * as Icons from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks";
@@ -15,6 +16,7 @@ import { Reorder, useDragControls } from "framer-motion";
import { db } from "../database/db";
import { getGameConfig } from "../games";
import { useGameStore } from "../stores/gameStore";
import { useProfileStore } from "../stores/profileStore";
import { useGameTheme } from "../hooks/useGameTheme";
import { Player, SavedPlayer } from "../types";
import { Button } from "../components/ui/button";
@@ -82,15 +84,39 @@ export default function NewGame() {
useGameTheme(gameConfig);
const startNewGame = useGameStore((state) => state.startNewGame);
const activeProfileId = useProfileStore((state) => state.activeProfileId);
const [players, setPlayers] = useState<Player[]>([
{ id: generateId(), name: "" },
{ id: generateId(), name: "" },
]);
const [options, setOptions] = useState<Record<string, any>>({});
const [locationId, setLocationId] = useState<string | undefined>(undefined);
const savedPlayers =
useLiveQuery(() => db.players.orderBy("name").toArray()) || [];
useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.players
.where("profileId")
.equals(activeProfileId)
.and((p) => !p.deletedAt)
.sortBy("name");
},
[activeProfileId],
) || [];
const locations =
useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.locations
.where("profileId")
.equals(activeProfileId)
.and((l) => !l.deletedAt)
.sortBy("name");
},
[activeProfileId],
) || [];
const availableSavedPlayers = savedPlayers.filter(
(sp) =>
!players.some(
@@ -143,6 +169,8 @@ export default function NewGame() {
};
const handleStart = async () => {
if (!activeProfileId) return;
// Basic validation
const validPlayers = players.filter((p) => p.name.trim() !== "");
if (validPlayers.length < gameConfig.minPlayers) {
@@ -158,6 +186,7 @@ export default function NewGame() {
const exists = await db.players
.where("name")
.equalsIgnoreCase(p.name)
.and((pl) => pl.profileId === activeProfileId && !pl.deletedAt)
.first();
if (!exists) {
@@ -176,16 +205,25 @@ export default function NewGame() {
console.error("Failed to generate auto-avatar");
}
}
const now = Date.now();
await db.players.add({
id: p.id,
name: p.name,
createdAt: Date.now(),
createdAt: now,
updatedAt: now,
avatar: finalAvatar,
profileId: activeProfileId,
});
}
}
const sessionId = await startNewGame(gameConfig.id, playersToSave, options);
const sessionId = await startNewGame(
gameConfig.id,
playersToSave,
options,
activeProfileId,
locationId,
);
navigate(`/play/${sessionId}`);
};
@@ -323,6 +361,41 @@ export default function NewGame() {
)}
</section>
{locations.length > 0 && (
<section className="space-y-4">
<h2 className="text-2xl font-black tracking-tighter px-2 drop-shadow-sm">
Lieu
</h2>
<div className="flex flex-wrap gap-2 px-1">
<div
className={`flex items-center px-3 py-2 rounded-full cursor-pointer active:scale-95 transition-all text-sm font-bold border-2 shadow-sm ${
!locationId
? "bg-primary/10 text-primary border-primary/30"
: "bg-background/80 backdrop-blur-md border-transparent hover:border-primary/20"
}`}
onClick={() => setLocationId(undefined)}
>
<MapPin className="w-4 h-4 mr-1.5 opacity-50" />
Aucun
</div>
{locations.map((loc) => (
<div
key={loc.id}
className={`flex items-center px-3 py-2 rounded-full cursor-pointer active:scale-95 transition-all text-sm font-bold border-2 shadow-sm ${
locationId === loc.id
? "bg-primary/10 text-primary border-primary/30"
: "bg-background/80 backdrop-blur-md border-transparent hover:border-primary/20"
}`}
onClick={() => setLocationId(loc.id)}
>
<MapPin className="w-4 h-4 mr-1.5 opacity-50" />
{loc.name}
</div>
))}
</div>
</section>
)}
{gameConfig.options && gameConfig.options.length > 0 && (
<section className="space-y-4">
<h2 className="text-2xl font-black tracking-tighter px-2 drop-shadow-sm">
+31 -7
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db";
import { useProfileStore } from "../stores/profileStore";
import {
Users,
Trash2,
@@ -20,14 +21,26 @@ import { resizeImage } from "../utils/image";
import { motion } from "framer-motion";
export default function Players() {
const players = useLiveQuery(() => db.players.orderBy("name").toArray());
const activeProfileId = useProfileStore((s) => s.activeProfileId);
const players = useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.players
.where("profileId")
.equals(activeProfileId)
.and((p) => !p.deletedAt)
.sortBy("name");
},
[activeProfileId],
);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [isGenerating, setIsGenerating] = useState<string | null>(null);
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Êtes-vous sûr de vouloir supprimer ${name} ?`)) {
await db.players.delete(id);
// Soft-delete (tombstone) so the deletion can propagate to the server.
await db.players.update(id, { deletedAt: Date.now() });
}
};
@@ -41,10 +54,16 @@ export default function Players() {
const newName = editName.trim();
const playerToEdit = players?.find((p) => p.id === id);
await db.players.update(id, { name: newName });
await db.players.update(id, { name: newName, updatedAt: Date.now() });
// Update the player's name in all existing game sessions (history)
const sessions = await db.sessions.toArray();
// Update the player's name in this profile's game sessions (history).
// Scoped by profile so a same-named player in another profile is untouched.
const sessions = activeProfileId
? await db.sessions
.where("profileId")
.equals(activeProfileId)
.toArray()
: [];
const updatedSessions = sessions
.map((session) => {
let hasChanges = false;
@@ -75,10 +94,15 @@ export default function Players() {
playerId: string,
avatarData: string,
) => {
await db.players.update(playerId, { avatar: avatarData });
await db.players.update(playerId, {
avatar: avatarData,
updatedAt: Date.now(),
});
// Update historical sessions too if we want the avatar to reflect immediately everywhere
const sessions = await db.sessions.toArray();
const sessions = activeProfileId
? await db.sessions.where("profileId").equals(activeProfileId).toArray()
: [];
const updatedSessions = sessions
.map((session) => {
let hasChanges = false;
+314
View File
@@ -0,0 +1,314 @@
import { useState } from "react";
import { useProfileStore } from "../stores/profileStore";
import {
UserCircle,
Trash2,
Edit2,
Check,
X,
Camera,
Dices,
Loader2,
Plus,
CheckCircle2,
} from "lucide-react";
import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { NavigationMenu } from "../components/NavigationMenu";
import { Avatar } from "../components/ui/avatar";
import { resizeImage } from "../utils/image";
import { motion } from "framer-motion";
export default function Profiles() {
const {
profiles,
activeProfileId,
switchProfile,
createProfile,
updateProfile,
deleteProfile,
} = useProfileStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [isAdding, setIsAdding] = useState(false);
const [newName, setNewName] = useState("");
const [isGenerating, setIsGenerating] = useState<string | null>(null);
const handleAdd = async () => {
const name = newName.trim();
if (!name) return;
await createProfile(name);
setNewName("");
setIsAdding(false);
};
const handleDelete = async (id: string, name: string) => {
if (profiles.length <= 1) {
alert("Vous devez conserver au moins un profil.");
return;
}
if (
window.confirm(
`Supprimer le profil "${name}" ?\n\nToutes ses parties, joueurs et emplacements seront définitivement supprimés.`,
)
) {
await deleteProfile(id);
}
};
const startEdit = (id: string, name: string) => {
setEditingId(id);
setEditName(name);
};
const saveEdit = async (id: string) => {
if (editName.trim()) {
await updateProfile(id, { name: editName.trim() });
}
setEditingId(null);
};
const handleAvatarChange = async (
profileId: string,
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const base64Image = await resizeImage(file);
await updateProfile(profileId, { avatar: base64Image });
} catch (e) {
alert("Erreur lors de l'enregistrement de l'image.");
}
};
const handleGenerateRandomAvatar = async (profileId: string) => {
setIsGenerating(profileId);
try {
const seed = Math.random().toString(36).substring(7);
const url = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${seed}`;
const response = await fetch(url);
const svgText = await response.text();
const encodedSvg = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`;
await updateProfile(profileId, { avatar: encodedSvg });
} catch (e) {
alert(
"Impossible de générer l'avatar. Vérifiez votre connexion internet.",
);
} finally {
setIsGenerating(null);
}
};
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
<NavigationMenu />
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
Profils
</h1>
</header>
<p className="text-sm text-muted-foreground px-2">
Chaque profil possède ses propres parties, joueurs et emplacements.
Touchez un profil pour l'activer.
</p>
{isAdding ? (
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
<CardContent className="p-4 space-y-3">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Nom du profil"
className="h-12"
autoFocus
/>
<div className="flex space-x-2">
<Button
onClick={handleAdd}
className="flex-1 rounded-full font-bold"
disabled={!newName.trim()}
>
<Check className="w-5 h-5 mr-2" /> Créer
</Button>
<Button
variant="ghost"
onClick={() => {
setIsAdding(false);
setNewName("");
}}
className="rounded-full"
>
<X className="w-5 h-5" />
</Button>
</div>
</CardContent>
</Card>
) : (
<Button
onClick={() => setIsAdding(true)}
className="w-full h-14 rounded-[1.5rem] font-black text-lg shadow-lg"
>
<Plus className="w-6 h-6 mr-2" /> Nouveau profil
</Button>
)}
<div className="space-y-4">
{profiles.length === 0 ? (
<div className="text-center text-muted-foreground py-12 flex flex-col items-center bg-background/60 backdrop-blur-sm rounded-[2rem]">
<UserCircle className="w-16 h-16 mb-4 opacity-20" />
<p className="font-bold text-lg">Aucun profil.</p>
</div>
) : (
profiles.map((profile, index) => {
const isActive = profile.id === activeProfileId;
return (
<motion.div
key={profile.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{
delay: index * 0.05,
type: "spring",
stiffness: 100,
}}
>
<Card
className={`border-0 shadow-lg backdrop-blur-md overflow-visible relative group transition-colors ${
isActive
? "bg-primary/10 ring-2 ring-primary/40"
: "bg-background/90 cursor-pointer"
}`}
onClick={() => {
if (!isActive && editingId !== profile.id) {
switchProfile(profile.id);
}
}}
>
<CardContent className="p-4 flex items-center justify-between">
{editingId === profile.id ? (
<div className="flex items-center space-x-2 flex-1 mr-2 relative z-10">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-12"
autoFocus
/>
<Button
variant="ghost"
size="icon"
onClick={() => saveEdit(profile.id)}
className="text-green-600 bg-green-500/10 hover:bg-green-500/20"
>
<Check className="w-6 h-6" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingId(null)}
className="text-muted-foreground bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10"
>
<X className="w-6 h-6" />
</Button>
</div>
) : (
<>
<div className="flex flex-col items-center mr-4 shrink-0 gap-1.5">
<div
className="relative group/avatar cursor-pointer"
onClick={(e) => {
e.stopPropagation();
document
.getElementById(`profile-avatar-${profile.id}`)
?.click();
}}
>
<Avatar
src={profile.avatar}
name={profile.name}
size="lg"
className="shadow-md border-4 border-background"
/>
<div className="absolute inset-0 bg-black/40 rounded-full flex items-center justify-center opacity-0 group-hover/avatar:opacity-100 transition-opacity">
<Camera className="w-5 h-5 text-white" />
</div>
<input
type="file"
id={`profile-avatar-${profile.id}`}
className="hidden"
accept="image/*"
onChange={(e) =>
handleAvatarChange(profile.id, e)
}
/>
</div>
<Button
variant="ghost"
size="sm"
disabled={isGenerating === profile.id}
className="h-6 px-3 rounded-full text-[10px] font-black uppercase tracking-wider bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
onClick={(e) => {
e.stopPropagation();
handleGenerateRandomAvatar(profile.id);
}}
>
{isGenerating === profile.id ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<Dices className="w-3 h-3 mr-1" />
)}
Aléatoire
</Button>
</div>
<div className="flex-1 min-w-0">
<span className="font-black text-xl truncate block tracking-tight">
{profile.name}
</span>
{isActive && (
<span className="inline-flex items-center text-xs font-bold text-primary mt-0.5">
<CheckCircle2 className="w-3.5 h-3.5 mr-1" />
Profil actif
</span>
)}
</div>
<div className="flex items-center shrink-0 space-x-1 relative z-10">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
startEdit(profile.id, profile.name);
}}
className="text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5"
>
<Edit2 className="w-5 h-5" />
</Button>
{profiles.length > 1 && (
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleDelete(profile.id, profile.name);
}}
className="text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-5 h-5" />
</Button>
)}
</div>
</>
)}
</CardContent>
</Card>
</motion.div>
);
})
)}
</div>
</div>
);
}
+147 -6
View File
@@ -1,3 +1,5 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Moon,
Sun,
@@ -6,9 +8,18 @@ import {
Download,
Upload,
FileSpreadsheet,
LogIn,
LogOut,
RefreshCw,
Check,
CloudOff,
} from "lucide-react";
import { useAppStore } from "../stores/appStore";
import { useProfileStore } from "../stores/profileStore";
import { useAuthStore } from "../stores/authStore";
import { runSync, onSyncStatus, SyncStatus } from "../sync/syncEngine";
import { db } from "../database/db";
import { generateId } from "../utils/id";
import { getGameConfig } from "../games";
import { calculatePlayerTotalScore } from "../utils/scoring";
import { Card, CardContent } from "../components/ui/card";
@@ -17,13 +28,26 @@ import { NavigationMenu } from "../components/NavigationMenu";
export default function Settings() {
const { theme, setTheme } = useAppStore();
const navigate = useNavigate();
const { user, status, logout } = useAuthStore();
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
useEffect(() => onSyncStatus(setSyncStatus), []);
const handleExportJson = async () => {
try {
const sessions = await db.sessions.toArray();
const settings = await db.settings.toArray();
const players = await db.players.toArray();
const data = JSON.stringify({ sessions, settings, players });
const locations = await db.locations.toArray();
const profiles = await db.profiles.toArray();
const data = JSON.stringify({
sessions,
settings,
players,
locations,
profiles,
});
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -54,6 +78,31 @@ export default function Settings() {
throw new Error("Fichier de sauvegarde invalide ou corrompu.");
}
const locations = data.locations || [];
let profiles = data.profiles || [];
// Rétrocompatibilité : un ancien backup n'a pas de profils.
// On crée un profil de secours et on y rattache les données héritées.
if (profiles.length === 0) {
const now = Date.now();
const fallbackId = generateId();
profiles = [
{ id: fallbackId, name: "Moi", createdAt: now, updatedAt: now },
];
data.sessions.forEach((s: any) => {
if (!s.profileId) s.profileId = fallbackId;
});
data.players.forEach((p: any) => {
if (!p.profileId) p.profileId = fallbackId;
});
locations.forEach((l: any) => {
if (!l.profileId) l.profileId = fallbackId;
});
data.settings.forEach((st: any) => {
if (!st.activeProfileId) st.activeProfileId = fallbackId;
});
}
if (
window.confirm(
"Attention : L'importation va écraser vos données actuelles. Voulez-vous continuer ?",
@@ -61,13 +110,21 @@ export default function Settings() {
) {
await db.transaction(
"rw",
db.sessions,
db.settings,
db.players,
[
db.sessions,
db.settings,
db.players,
db.locations,
db.profiles,
db.syncState,
],
async () => {
await db.sessions.clear();
await db.settings.clear();
await db.players.clear();
await db.locations.clear();
await db.profiles.clear();
await db.syncState.clear();
if (data.sessions.length > 0)
await db.sessions.bulkAdd(data.sessions);
@@ -75,6 +132,10 @@ export default function Settings() {
await db.settings.bulkAdd(data.settings);
if (data.players.length > 0)
await db.players.bulkAdd(data.players);
if (locations.length > 0)
await db.locations.bulkAdd(locations);
if (profiles.length > 0)
await db.profiles.bulkAdd(profiles);
},
);
@@ -152,10 +213,17 @@ export default function Settings() {
const handleReset = async () => {
if (
window.confirm(
"Êtes-vous sûr de vouloir supprimer TOUTES vos parties ? Cette action est irréversible.",
"Êtes-vous sûr de vouloir supprimer TOUTES les parties de ce profil ? Cette action est irréversible.",
)
) {
await db.sessions.clear();
const activeProfileId = useProfileStore.getState().activeProfileId;
if (!activeProfileId) return;
// Soft-delete the active profile's sessions so the reset propagates on sync.
const now = Date.now();
await db.sessions
.where("profileId")
.equals(activeProfileId)
.modify({ deletedAt: now });
alert("Données réinitialisées.");
}
};
@@ -169,6 +237,79 @@ export default function Settings() {
</h1>
</header>
<section className="space-y-4">
<h2 className="text-xl font-bold px-2 tracking-tight">Compte</h2>
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
<CardContent className="p-4">
{status === "authenticated" && user ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="font-black text-lg truncate">
{user.displayName}
</p>
<p className="text-sm text-muted-foreground truncate">
{user.email}
</p>
</div>
<span className="flex items-center text-xs font-bold text-emerald-600 bg-emerald-500/10 px-3 py-1.5 rounded-full shrink-0">
{syncStatus === "syncing" ? (
<>
<RefreshCw className="w-3.5 h-3.5 mr-1 animate-spin" />
Sync
</>
) : syncStatus === "error" ? (
<span className="flex items-center text-destructive">
<CloudOff className="w-3.5 h-3.5 mr-1" />
Erreur
</span>
) : (
<>
<Check className="w-3.5 h-3.5 mr-1" />
Synchronisé
</>
)}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<Button
variant="outline"
className="rounded-2xl font-bold"
onClick={() => runSync()}
disabled={syncStatus === "syncing"}
>
<RefreshCw className="w-4 h-4 mr-2" />
Synchroniser
</Button>
<Button
variant="outline"
className="rounded-2xl font-bold text-destructive hover:bg-destructive/10"
onClick={() => logout()}
>
<LogOut className="w-4 h-4 mr-2" />
Déconnexion
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Connectez-vous pour sauvegarder et synchroniser vos parties
entre vos appareils.
</p>
<Button
className="w-full h-12 rounded-2xl font-black"
onClick={() => navigate("/login")}
>
<LogIn className="w-5 h-5 mr-2" />
Se connecter / Créer un compte
</Button>
</div>
)}
</CardContent>
</Card>
</section>
<section className="space-y-4">
<h2 className="text-xl font-bold px-2 tracking-tight">Apparence</h2>
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
+26 -2
View File
@@ -1,6 +1,7 @@
import { useState, useMemo } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../../database/db";
import { useProfileStore } from "../../stores/profileStore";
import { games } from "../../games";
import { Card, CardContent } from "../../components/ui/card";
import { Badge } from "../../components/ui/badge";
@@ -10,8 +11,31 @@ import { Avatar } from "../../components/ui/avatar";
import { motion } from "framer-motion";
export default function Statistics() {
const allSessions = useLiveQuery(() => db.sessions.toArray()) || [];
const players = useLiveQuery(() => db.players.toArray()) || [];
const activeProfileId = useProfileStore((s) => s.activeProfileId);
const allSessions =
useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.sessions
.where("profileId")
.equals(activeProfileId)
.and((s) => !s.deletedAt)
.toArray();
},
[activeProfileId],
) || [];
const players =
useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.players
.where("profileId")
.equals(activeProfileId)
.and((p) => !p.deletedAt)
.toArray();
},
[activeProfileId],
) || [];
const [timeFilter, setTimeFilter] = useState<"all" | "7d" | "30d" | "year">(
"all",
+8 -1
View File
@@ -27,7 +27,14 @@ export const useAppStore = create<AppState>((set) => ({
if (settings) {
await db.settings.put({ ...settings, theme });
} else {
await db.settings.add({ id: 1, theme, language: 'fr' });
// Fallback (should not happen: populate always seeds settings + a profile)
const firstProfile = await db.profiles.orderBy('createdAt').first();
await db.settings.add({
id: 1,
theme,
language: 'fr',
activeProfileId: firstProfile?.id ?? '',
});
}
}
}));
+117
View File
@@ -0,0 +1,117 @@
import { create } from "zustand";
import {
apiJson,
setAccessToken,
setOnUnauthorized,
} from "../lib/apiClient";
import { useProfileStore } from "./profileStore";
import { db } from "../database/db";
import { runSync } from "../sync/syncEngine";
export interface AuthUser {
id: string;
email: string;
username: string | null;
displayName: string;
avatarUrl: string | null;
}
type AuthStatus = "unknown" | "authenticated" | "anonymous";
interface AuthResponse {
user: AuthUser;
accessToken: string;
}
interface AuthState {
user: AuthUser | null;
status: AuthStatus;
register: (input: {
email: string;
password: string;
displayName?: string;
username?: string;
}) => Promise<void>;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
restore: () => Promise<void>;
}
async function linkActiveProfileToUser(userId: string) {
const profileId = useProfileStore.getState().activeProfileId;
if (profileId) {
await db.profiles.update(profileId, { remoteUserId: userId });
await useProfileStore.getState().loadProfiles();
}
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
status: "unknown",
register: async ({ email, password, displayName, username }) => {
const profile = useProfileStore.getState().activeProfile;
const res = await apiJson<AuthResponse>(
"/auth/register",
{
method: "POST",
body: JSON.stringify({
email,
password,
displayName: displayName || profile?.name || email,
username: username || undefined,
desiredId: profile?.id,
}),
},
{ auth: false },
);
setAccessToken(res.accessToken);
set({ user: res.user, status: "authenticated" });
await linkActiveProfileToUser(res.user.id);
void runSync();
},
login: async (email, password) => {
const res = await apiJson<AuthResponse>(
"/auth/login",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ auth: false },
);
setAccessToken(res.accessToken);
set({ user: res.user, status: "authenticated" });
await linkActiveProfileToUser(res.user.id);
void runSync();
},
logout: async () => {
try {
await apiJson("/auth/logout", { method: "POST" }, { auth: false });
} catch {
/* ignore network errors on logout */
}
setAccessToken(null);
set({ user: null, status: "anonymous" });
},
// On boot: try to silently resume a session via the refresh cookie.
restore: async () => {
try {
const res = await apiJson<AuthResponse>(
"/auth/refresh",
{ method: "POST" },
{ auth: false },
);
setAccessToken(res.accessToken);
set({ user: res.user, status: "authenticated" });
void runSync();
} catch {
set({ user: null, status: "anonymous" });
}
},
}));
// If a refresh ultimately fails mid-request, drop back to anonymous.
setOnUnauthorized(() => {
setAccessToken(null);
useAuthStore.setState({ user: null, status: "anonymous" });
});
+9 -3
View File
@@ -14,6 +14,8 @@ interface GameState {
gameId: string,
players: Player[],
options: Record<string, any>,
profileId: string,
locationId?: string,
) => Promise<string>;
addRound: (scores: RoundScore[]) => Promise<void>;
updateRound: (roundId: string, scores: RoundScore[]) => Promise<void>;
@@ -37,19 +39,23 @@ export const useGameStore = create<GameState>((set, get) => ({
saveSession: async () => {
const { activeSession } = get();
if (activeSession) {
await db.sessions.put(activeSession);
await db.sessions.put({ ...activeSession, updatedAt: Date.now() });
}
},
startNewGame: async (gameId, players, options) => {
startNewGame: async (gameId, players, options, profileId, locationId) => {
const now = Date.now();
const newSession: GameSession = {
id: generateId(),
gameId,
dateStart: Date.now(),
dateStart: now,
players,
rounds: [],
status: "playing",
options,
locationId,
profileId,
updatedAt: now,
};
await db.sessions.add(newSession);
+133
View File
@@ -0,0 +1,133 @@
import { create } from "zustand";
import { Profile } from "../types";
import { db } from "../database/db";
import { generateId } from "../utils/id";
interface ProfileState {
activeProfileId: string | null;
activeProfile: Profile | null;
profiles: Profile[];
loadProfiles: () => Promise<void>;
switchProfile: (id: string) => Promise<void>;
createProfile: (name: string, avatar?: string) => Promise<Profile>;
updateProfile: (
id: string,
changes: Partial<Pick<Profile, "name" | "avatar">>,
) => Promise<void>;
deleteProfile: (id: string) => Promise<void>;
}
async function ensureDefaultProfile(): Promise<Profile> {
const now = Date.now();
const profile: Profile = {
id: generateId(),
name: "Moi",
createdAt: now,
updatedAt: now,
};
await db.profiles.add(profile);
return profile;
}
async function setActiveProfileId(id: string) {
const settings = await db.settings.get(1);
if (settings) {
await db.settings.put({ ...settings, activeProfileId: id });
} else {
await db.settings.add({
id: 1,
theme: "system",
language: "fr",
activeProfileId: id,
});
}
}
export const useProfileStore = create<ProfileState>((set, get) => ({
activeProfileId: null,
activeProfile: null,
profiles: [],
loadProfiles: async () => {
let profiles = await db.profiles.orderBy("createdAt").toArray();
// Defensive: guarantee at least one profile exists
if (profiles.length === 0) {
const created = await ensureDefaultProfile();
profiles = [created];
}
const settings = await db.settings.get(1);
let activeId = settings?.activeProfileId;
// Fallback if the stored active profile no longer exists
if (!activeId || !profiles.some((p) => p.id === activeId)) {
activeId = profiles[0].id;
await setActiveProfileId(activeId);
}
const activeProfile = profiles.find((p) => p.id === activeId) || null;
set({ profiles, activeProfileId: activeId, activeProfile });
},
switchProfile: async (id) => {
const profile = await db.profiles.get(id);
if (!profile) return;
await setActiveProfileId(id);
set({ activeProfileId: id, activeProfile: profile });
},
createProfile: async (name, avatar) => {
const now = Date.now();
const profile: Profile = {
id: generateId(),
name: name.trim(),
avatar,
createdAt: now,
updatedAt: now,
};
await db.profiles.add(profile);
await get().loadProfiles();
return profile;
},
updateProfile: async (id, changes) => {
await db.profiles.update(id, { ...changes, updatedAt: Date.now() });
await get().loadProfiles();
},
deleteProfile: async (id) => {
const { profiles, activeProfileId } = get();
// Never delete the last remaining profile
if (profiles.length <= 1) return;
// Remove this profile and its local data from the device. This is a local
// removal (not a sync deletion), so we hard-delete without tombstones.
await db.transaction(
"rw",
db.profiles,
db.sessions,
db.players,
db.locations,
db.syncState,
async () => {
await db.sessions.where("profileId").equals(id).delete();
await db.players.where("profileId").equals(id).delete();
await db.locations.where("profileId").equals(id).delete();
await db.syncState.delete(id);
await db.profiles.delete(id);
},
);
// If we removed the active profile, switch to another one
if (activeProfileId === id) {
const remaining = await db.profiles.orderBy("createdAt").first();
if (remaining) {
await setActiveProfileId(remaining.id);
}
}
await get().loadProfiles();
},
}));
+217
View File
@@ -0,0 +1,217 @@
import { db, remoteApply, localChange } from "../database/db";
import { apiJson, getAccessToken } from "../lib/apiClient";
import { useProfileStore } from "../stores/profileStore";
import { GameSession, Location, SavedPlayer } from "../types";
interface PullResponse {
serverTime: number;
locations: any[];
players: any[];
sessions: any[];
}
interface PushResponse {
serverTime: number;
applied: {
locations: { id: string; updatedAt: number }[];
players: { id: string; updatedAt: number }[];
sessions: { id: string; updatedAt: number }[];
};
}
let syncing = false;
const listeners = new Set<(state: SyncStatus) => void>();
export type SyncStatus = "idle" | "syncing" | "error";
let currentStatus: SyncStatus = "idle";
export function onSyncStatus(cb: (state: SyncStatus) => void): () => void {
listeners.add(cb);
cb(currentStatus);
return () => listeners.delete(cb);
}
function setStatus(s: SyncStatus) {
currentStatus = s;
listeners.forEach((cb) => cb(s));
}
// ---- Mapping: server rows -> local records (scoped to the active profile) ----
function toLocation(r: any, profileId: string): Location {
return {
id: r.id,
name: r.name,
address: r.address ?? undefined,
createdAt: Number(r.createdAt),
updatedAt: Number(r.updatedAt),
deletedAt: r.deletedAt ? Number(r.deletedAt) : undefined,
profileId,
dirty: 0,
};
}
function toPlayer(r: any, profileId: string): SavedPlayer {
return {
id: r.id,
name: r.name,
avatar: r.avatar ?? undefined,
createdAt: Number(r.createdAt),
updatedAt: Number(r.updatedAt),
deletedAt: r.deletedAt ? Number(r.deletedAt) : undefined,
profileId,
dirty: 0,
};
}
function toSession(r: any, profileId: string): GameSession {
return {
id: r.id,
gameId: r.gameId,
dateStart: Number(r.dateStart),
dateEnd: r.dateEnd ? Number(r.dateEnd) : undefined,
players: r.players ?? [],
rounds: r.rounds ?? [],
status: r.status,
options: r.options ?? {},
winnerIds: r.winnerIds ?? undefined,
locationId: r.locationId ?? undefined,
updatedAt: Number(r.updatedAt),
deletedAt: r.deletedAt ? Number(r.deletedAt) : undefined,
profileId,
dirty: 0,
};
}
// ---- Push local (dirty) records, then clear their dirty flag on ack ----
async function pushDirty(profileId: string): Promise<void> {
const [locations, players, sessions] = await Promise.all([
db.locations
.where("profileId")
.equals(profileId)
.and((r) => r.dirty === 1)
.toArray(),
db.players
.where("profileId")
.equals(profileId)
.and((r) => r.dirty === 1)
.toArray(),
db.sessions
.where("profileId")
.equals(profileId)
.and((r) => r.dirty === 1)
.toArray(),
]);
if (
locations.length === 0 &&
players.length === 0 &&
sessions.length === 0
) {
return;
}
const res = await apiJson<PushResponse>("/sync", {
method: "POST",
body: JSON.stringify({ locations, players, sessions }),
});
remoteApply.active = true;
try {
await db.transaction(
"rw",
db.locations,
db.players,
db.sessions,
async () => {
for (const a of res.applied.locations)
await db.locations.update(a.id, { updatedAt: a.updatedAt, dirty: 0 });
for (const a of res.applied.players)
await db.players.update(a.id, { updatedAt: a.updatedAt, dirty: 0 });
for (const a of res.applied.sessions)
await db.sessions.update(a.id, { updatedAt: a.updatedAt, dirty: 0 });
},
);
} finally {
remoteApply.active = false;
}
}
// ---- Pull remote changes since the stored cursor and apply them locally ----
async function pullSince(profileId: string): Promise<void> {
const state = await db.syncState.get(profileId);
const since = state?.lastSyncedAt ?? 0;
const res = await apiJson<PullResponse>(`/sync?since=${since}`, {
method: "GET",
});
remoteApply.active = true;
try {
await db.transaction(
"rw",
db.locations,
db.players,
db.sessions,
db.syncState,
async () => {
for (const r of res.locations)
await db.locations.put(toLocation(r, profileId));
for (const r of res.players)
await db.players.put(toPlayer(r, profileId));
for (const r of res.sessions)
await db.sessions.put(toSession(r, profileId));
await db.syncState.put({ profileId, lastSyncedAt: res.serverTime });
},
);
} finally {
remoteApply.active = false;
}
}
// ---- Public entry point ----
export async function runSync(): Promise<void> {
if (syncing) return;
if (!getAccessToken()) return; // not logged in
const profileId = useProfileStore.getState().activeProfileId;
if (!profileId) return;
syncing = true;
setStatus("syncing");
try {
await pushDirty(profileId);
await pullSince(profileId);
setStatus("idle");
} catch (err) {
console.error("[sync] failed", err);
setStatus("error");
} finally {
syncing = false;
}
}
// ---- Triggers: reconnection, tab focus, and a periodic heartbeat ----
let started = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Push shortly after any local change (coalesces bursts of edits).
function scheduleSync() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => void runSync(), 1500);
}
export function startSyncTriggers() {
if (started) return;
started = true;
localChange.notify = scheduleSync;
window.addEventListener("online", () => void runSync());
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") void runSync();
});
setInterval(() => void runSync(), 60_000);
}
+36
View File
@@ -73,17 +73,53 @@ export interface GameSession {
status: "playing" | "finished";
options: Record<string, any>;
winnerIds?: string[];
locationId?: string;
profileId: string;
updatedAt: number;
deletedAt?: number; // tombstone pour la synchronisation
dirty?: number; // 1 = modifié localement, à pousser (0/absent = synchronisé)
}
export interface AppSettings {
id: number;
theme: "light" | "dark" | "system";
language: string;
activeProfileId: string;
}
export interface SavedPlayer {
id: string;
name: string;
createdAt: number;
updatedAt: number;
avatar?: string;
profileId: string;
linkedProfileId?: string; // réservé pour la Phase 4 (lien vers un ami)
deletedAt?: number; // tombstone pour la synchronisation
dirty?: number; // 1 = modifié localement, à pousser
}
export interface Location {
id: string;
name: string;
address?: string;
createdAt: number;
updatedAt: number;
deletedAt?: number; // tombstone pour la synchronisation
profileId: string;
dirty?: number; // 1 = modifié localement, à pousser
}
export interface SyncState {
profileId: string;
lastSyncedAt: number;
}
export interface Profile {
id: string;
name: string;
avatar?: string;
createdAt: number;
updatedAt: number;
remoteUserId?: string; // réservé pour la Phase 3 (compte serveur)
}
+12
View File
@@ -61,6 +61,8 @@ export default defineConfig({
},
],
navigateFallback: "/index.html",
// Never let the SPA fallback swallow API calls.
navigateFallbackDenylist: [/^\/api/],
cleanupOutdatedCaches: true,
},
}),
@@ -70,4 +72,14 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
server: {
// Dev: forward /api to the backend so the browser sees a single origin
// (required for the httpOnly refresh cookie to work end-to-end).
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
},
},
},
});