This commit is contained in:
@@ -21,14 +21,24 @@ jobs:
|
|||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
run: echo "${{ secrets.DOCKER_TOKEN }}" | docker login ${{ env.REGISTRY }} -u "${{ gitea.actor }}" --password-stdin
|
run: echo "${{ secrets.DOCKER_TOKEN }}" | docker login ${{ env.REGISTRY }} -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
|
||||||
# Build standard using docker CLI
|
# Frontend image (nginx + SPA)
|
||||||
- name: Build Docker image
|
- name: Build frontend Docker image
|
||||||
run: |
|
run: |
|
||||||
LOWERCASE_IMAGE=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
|
LOWERCASE_IMAGE=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
|
||||||
docker build -t ${{ env.REGISTRY }}/$LOWERCASE_IMAGE:latest .
|
docker build -t ${{ env.REGISTRY }}/$LOWERCASE_IMAGE:latest .
|
||||||
|
|
||||||
# Push directly using docker push
|
- name: Push frontend Docker image
|
||||||
- name: Push Docker image
|
|
||||||
run: |
|
run: |
|
||||||
LOWERCASE_IMAGE=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
|
LOWERCASE_IMAGE=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
|
||||||
docker push ${{ env.REGISTRY }}/$LOWERCASE_IMAGE:latest
|
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
@@ -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
@@ -9,6 +9,39 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
# Optionnel: si vous avez des logos dans un dossier externe sur votre machine hôte
|
depends_on:
|
||||||
# volumes:
|
- backend
|
||||||
# - ./games-assets:/usr/share/nginx/html/games-assets
|
|
||||||
|
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
@@ -4,6 +4,16 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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)
|
# Important pour les Single Page Applications (React Router)
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "board-score",
|
"name": "skori",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "board-score",
|
"name": "skori",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.2",
|
"@hookform/resolvers": "^3.3.2",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.pgdata
|
||||||
|
.env
|
||||||
|
npm-debug.log
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
Generated
+2006
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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) });
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
@@ -8,16 +8,29 @@ import PlayGame from "./pages/PlayGame";
|
|||||||
import GameOver from "./pages/GameOver";
|
import GameOver from "./pages/GameOver";
|
||||||
import History from "./pages/History";
|
import History from "./pages/History";
|
||||||
import Players from "./pages/Players";
|
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 Settings from "./pages/Settings";
|
||||||
import Statistics from "./pages/Stats";
|
import Statistics from "./pages/Stats";
|
||||||
import { useAppStore } from "./stores/appStore";
|
import { useAppStore } from "./stores/appStore";
|
||||||
|
import { useProfileStore } from "./stores/profileStore";
|
||||||
|
import { useAuthStore } from "./stores/authStore";
|
||||||
|
import { startSyncTriggers } from "./sync/syncEngine";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { loadSettings } = useAppStore();
|
const { loadSettings } = useAppStore();
|
||||||
|
const { loadProfiles } = useProfileStore();
|
||||||
|
const restoreAuth = useAuthStore((s) => s.restore);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Order matters: profiles must be loaded before auth restore triggers a sync.
|
||||||
|
loadProfiles().then(() => {
|
||||||
|
restoreAuth();
|
||||||
|
startSyncTriggers();
|
||||||
|
});
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}, [loadSettings]);
|
}, [loadProfiles, loadSettings, restoreAuth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -26,6 +39,9 @@ function App() {
|
|||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/history" element={<History />} />
|
<Route path="/history" element={<History />} />
|
||||||
<Route path="/players" element={<Players />} />
|
<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="/stats" element={<Statistics />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
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 { useNavigate } from "react-router-dom";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
import { Avatar } from "./ui/avatar";
|
||||||
|
import { useProfileStore } from "../stores/profileStore";
|
||||||
|
|
||||||
export function NavigationMenu() {
|
export function NavigationMenu() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeProfile = useProfileStore((s) => s.activeProfile);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@@ -41,12 +54,32 @@ export function NavigationMenu() {
|
|||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
transition={{ duration: 0.15 }}
|
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">
|
<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
|
<button
|
||||||
onClick={() => nav("/")}
|
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
|
<Home className="w-5 h-5 mr-3 text-primary" /> Accueil
|
||||||
</button>
|
</button>
|
||||||
@@ -62,12 +95,24 @@ export function NavigationMenu() {
|
|||||||
>
|
>
|
||||||
<Users className="w-5 h-5 mr-3 text-primary" /> Joueurs
|
<Users className="w-5 h-5 mr-3 text-primary" /> Joueurs
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => nav("/stats")}
|
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"
|
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
|
<BarChart2 className="w-5 h-5 mr-3 text-primary" /> Statistiques
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => nav("/settings")}
|
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"
|
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
||||||
|
|||||||
+154
-2
@@ -1,10 +1,30 @@
|
|||||||
import Dexie, { Table } from "dexie";
|
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 {
|
export class BoardScoreDatabase extends Dexie {
|
||||||
sessions!: Table<GameSession, string>;
|
sessions!: Table<GameSession, string>;
|
||||||
settings!: Table<AppSettings, number>;
|
settings!: Table<AppSettings, number>;
|
||||||
players!: Table<SavedPlayer, string>;
|
players!: Table<SavedPlayer, string>;
|
||||||
|
locations!: Table<Location, string>;
|
||||||
|
profiles!: Table<Profile, string>;
|
||||||
|
syncState!: Table<SyncState, string>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("BoardScoreDatabase");
|
super("BoardScoreDatabase");
|
||||||
@@ -20,16 +40,148 @@ export class BoardScoreDatabase extends Dexie {
|
|||||||
settings: "id",
|
settings: "id",
|
||||||
players: "id, name, createdAt",
|
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();
|
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 () => {
|
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({
|
await db.settings.add({
|
||||||
id: 1,
|
id: 1,
|
||||||
theme: "system",
|
theme: "system",
|
||||||
language: "fr",
|
language: "fr",
|
||||||
|
activeProfileId: defaultProfileId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+19
-2
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
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 * as Icons from "lucide-react";
|
||||||
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import { db } from "../database/db";
|
import { db } from "../database/db";
|
||||||
import { GameSession } from "../types";
|
import { GameSession } from "../types";
|
||||||
import { getGameConfig } from "../games";
|
import { getGameConfig } from "../games";
|
||||||
@@ -25,6 +26,12 @@ export default function GameOver() {
|
|||||||
const gameConfig = getGameConfig(session?.gameId || "");
|
const gameConfig = getGameConfig(session?.gameId || "");
|
||||||
useGameTheme(gameConfig);
|
useGameTheme(gameConfig);
|
||||||
|
|
||||||
|
const location = useLiveQuery(
|
||||||
|
() =>
|
||||||
|
session?.locationId ? db.locations.get(session.locationId) : undefined,
|
||||||
|
[session?.locationId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
db.sessions.get(sessionId).then((data) => {
|
db.sessions.get(sessionId).then((data) => {
|
||||||
@@ -115,6 +122,8 @@ export default function GameOver() {
|
|||||||
gameConfig.id,
|
gameConfig.id,
|
||||||
newPlayers,
|
newPlayers,
|
||||||
session.options,
|
session.options,
|
||||||
|
session.profileId,
|
||||||
|
session.locationId,
|
||||||
);
|
);
|
||||||
navigate(`/play/${newSessionId}`);
|
navigate(`/play/${newSessionId}`);
|
||||||
};
|
};
|
||||||
@@ -174,10 +183,18 @@ export default function GameOver() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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" />
|
<Clock className="w-4 h-4 mr-2" />
|
||||||
{durationInMinutes} minutes • {session.rounds.length} manches
|
{durationInMinutes} minutes • {session.rounds.length} manches
|
||||||
</div>
|
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+26
-4
@@ -4,6 +4,8 @@ import { db } from "../database/db";
|
|||||||
import { games } from "../games";
|
import { games } from "../games";
|
||||||
import { Card, CardContent } from "../components/ui/card";
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
import { NavigationMenu } from "../components/NavigationMenu";
|
import { NavigationMenu } from "../components/NavigationMenu";
|
||||||
|
import { useProfileStore } from "../stores/profileStore";
|
||||||
|
import { MapPin } from "lucide-react";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
@@ -18,9 +20,20 @@ function formatDate(ms: number) {
|
|||||||
|
|
||||||
export default function History() {
|
export default function History() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const sessions = useLiveQuery(() =>
|
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
||||||
db.sessions.orderBy("dateStart").reverse().toArray(),
|
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 (
|
return (
|
||||||
<div className="p-4 space-y-6 pb-20 relative z-50">
|
<div className="p-4 space-y-6 pb-20 relative z-50">
|
||||||
@@ -40,6 +53,9 @@ export default function History() {
|
|||||||
{sessions.map((session, index) => {
|
{sessions.map((session, index) => {
|
||||||
const game = games.find((g) => g.id === session.gameId);
|
const game = games.find((g) => g.id === session.gameId);
|
||||||
if (!game) return null;
|
if (!game) return null;
|
||||||
|
const location = session.locationId
|
||||||
|
? locations.find((l) => l.id === session.locationId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -85,8 +101,14 @@ export default function History() {
|
|||||||
: "Terminée"}
|
: "Terminée"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-foreground/70 font-medium mb-2">
|
<p className="text-sm text-foreground/70 font-medium mb-2 flex items-center flex-wrap gap-x-2">
|
||||||
{formatDate(session.dateStart)}
|
<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>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
{session.players.map((p) => {
|
{session.players.map((p) => {
|
||||||
|
|||||||
+13
-3
@@ -5,6 +5,7 @@ import { games } from "../games";
|
|||||||
import { db } from "../database/db";
|
import { db } from "../database/db";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { NavigationMenu } from "../components/NavigationMenu";
|
import { NavigationMenu } from "../components/NavigationMenu";
|
||||||
|
import { useProfileStore } from "../stores/profileStore";
|
||||||
|
|
||||||
// Kurzgesagt-style Planet Logo
|
// Kurzgesagt-style Planet Logo
|
||||||
const PlanetLogo = () => (
|
const PlanetLogo = () => (
|
||||||
@@ -31,10 +32,19 @@ const PlanetLogo = () => (
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
||||||
|
|
||||||
// Load unfinished games
|
// Load unfinished games for the active profile
|
||||||
const activeSessions = useLiveQuery(() =>
|
const activeSessions = useLiveQuery(
|
||||||
db.sessions.where("status").equals("playing").toArray(),
|
async () => {
|
||||||
|
if (!activeProfileId) return [];
|
||||||
|
return db.sessions
|
||||||
|
.where("profileId")
|
||||||
|
.equals(activeProfileId)
|
||||||
|
.and((s) => s.status === "playing" && !s.deletedAt)
|
||||||
|
.toArray();
|
||||||
|
},
|
||||||
|
[activeProfileId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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
@@ -8,6 +8,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
MapPin,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as Icons from "lucide-react";
|
import * as Icons from "lucide-react";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
@@ -15,6 +16,7 @@ import { Reorder, useDragControls } from "framer-motion";
|
|||||||
import { db } from "../database/db";
|
import { db } from "../database/db";
|
||||||
import { getGameConfig } from "../games";
|
import { getGameConfig } from "../games";
|
||||||
import { useGameStore } from "../stores/gameStore";
|
import { useGameStore } from "../stores/gameStore";
|
||||||
|
import { useProfileStore } from "../stores/profileStore";
|
||||||
import { useGameTheme } from "../hooks/useGameTheme";
|
import { useGameTheme } from "../hooks/useGameTheme";
|
||||||
import { Player, SavedPlayer } from "../types";
|
import { Player, SavedPlayer } from "../types";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
@@ -82,15 +84,39 @@ export default function NewGame() {
|
|||||||
useGameTheme(gameConfig);
|
useGameTheme(gameConfig);
|
||||||
|
|
||||||
const startNewGame = useGameStore((state) => state.startNewGame);
|
const startNewGame = useGameStore((state) => state.startNewGame);
|
||||||
|
const activeProfileId = useProfileStore((state) => state.activeProfileId);
|
||||||
|
|
||||||
const [players, setPlayers] = useState<Player[]>([
|
const [players, setPlayers] = useState<Player[]>([
|
||||||
{ id: generateId(), name: "" },
|
{ id: generateId(), name: "" },
|
||||||
{ id: generateId(), name: "" },
|
{ id: generateId(), name: "" },
|
||||||
]);
|
]);
|
||||||
const [options, setOptions] = useState<Record<string, any>>({});
|
const [options, setOptions] = useState<Record<string, any>>({});
|
||||||
|
const [locationId, setLocationId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const savedPlayers =
|
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(
|
const availableSavedPlayers = savedPlayers.filter(
|
||||||
(sp) =>
|
(sp) =>
|
||||||
!players.some(
|
!players.some(
|
||||||
@@ -143,6 +169,8 @@ export default function NewGame() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
|
if (!activeProfileId) return;
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
const validPlayers = players.filter((p) => p.name.trim() !== "");
|
const validPlayers = players.filter((p) => p.name.trim() !== "");
|
||||||
if (validPlayers.length < gameConfig.minPlayers) {
|
if (validPlayers.length < gameConfig.minPlayers) {
|
||||||
@@ -158,6 +186,7 @@ export default function NewGame() {
|
|||||||
const exists = await db.players
|
const exists = await db.players
|
||||||
.where("name")
|
.where("name")
|
||||||
.equalsIgnoreCase(p.name)
|
.equalsIgnoreCase(p.name)
|
||||||
|
.and((pl) => pl.profileId === activeProfileId && !pl.deletedAt)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@@ -176,16 +205,25 @@ export default function NewGame() {
|
|||||||
console.error("Failed to generate auto-avatar");
|
console.error("Failed to generate auto-avatar");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const now = Date.now();
|
||||||
await db.players.add({
|
await db.players.add({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
createdAt: Date.now(),
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
avatar: finalAvatar,
|
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}`);
|
navigate(`/play/${sessionId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -323,6 +361,41 @@ export default function NewGame() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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 && (
|
{gameConfig.options && gameConfig.options.length > 0 && (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-2xl font-black tracking-tighter px-2 drop-shadow-sm">
|
<h2 className="text-2xl font-black tracking-tighter px-2 drop-shadow-sm">
|
||||||
|
|||||||
+31
-7
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import { db } from "../database/db";
|
import { db } from "../database/db";
|
||||||
|
import { useProfileStore } from "../stores/profileStore";
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -20,14 +21,26 @@ import { resizeImage } from "../utils/image";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
export default function Players() {
|
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 [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [isGenerating, setIsGenerating] = useState<string | null>(null);
|
const [isGenerating, setIsGenerating] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
const handleDelete = async (id: string, name: string) => {
|
||||||
if (window.confirm(`Êtes-vous sûr de vouloir supprimer ${name} ?`)) {
|
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 newName = editName.trim();
|
||||||
const playerToEdit = players?.find((p) => p.id === id);
|
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)
|
// Update the player's name in this profile's game sessions (history).
|
||||||
const sessions = await db.sessions.toArray();
|
// 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
|
const updatedSessions = sessions
|
||||||
.map((session) => {
|
.map((session) => {
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
@@ -75,10 +94,15 @@ export default function Players() {
|
|||||||
playerId: string,
|
playerId: string,
|
||||||
avatarData: 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
|
// 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
|
const updatedSessions = sessions
|
||||||
.map((session) => {
|
.map((session) => {
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+144
-3
@@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
@@ -6,9 +8,18 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Upload,
|
Upload,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
|
LogIn,
|
||||||
|
LogOut,
|
||||||
|
RefreshCw,
|
||||||
|
Check,
|
||||||
|
CloudOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAppStore } from "../stores/appStore";
|
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 { db } from "../database/db";
|
||||||
|
import { generateId } from "../utils/id";
|
||||||
import { getGameConfig } from "../games";
|
import { getGameConfig } from "../games";
|
||||||
import { calculatePlayerTotalScore } from "../utils/scoring";
|
import { calculatePlayerTotalScore } from "../utils/scoring";
|
||||||
import { Card, CardContent } from "../components/ui/card";
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
@@ -17,13 +28,26 @@ import { NavigationMenu } from "../components/NavigationMenu";
|
|||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { theme, setTheme } = useAppStore();
|
const { theme, setTheme } = useAppStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, status, logout } = useAuthStore();
|
||||||
|
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
|
||||||
|
|
||||||
|
useEffect(() => onSyncStatus(setSyncStatus), []);
|
||||||
|
|
||||||
const handleExportJson = async () => {
|
const handleExportJson = async () => {
|
||||||
try {
|
try {
|
||||||
const sessions = await db.sessions.toArray();
|
const sessions = await db.sessions.toArray();
|
||||||
const settings = await db.settings.toArray();
|
const settings = await db.settings.toArray();
|
||||||
const players = await db.players.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 blob = new Blob([data], { type: "application/json" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
@@ -54,6 +78,31 @@ export default function Settings() {
|
|||||||
throw new Error("Fichier de sauvegarde invalide ou corrompu.");
|
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 (
|
if (
|
||||||
window.confirm(
|
window.confirm(
|
||||||
"Attention : L'importation va écraser vos données actuelles. Voulez-vous continuer ?",
|
"Attention : L'importation va écraser vos données actuelles. Voulez-vous continuer ?",
|
||||||
@@ -61,13 +110,21 @@ export default function Settings() {
|
|||||||
) {
|
) {
|
||||||
await db.transaction(
|
await db.transaction(
|
||||||
"rw",
|
"rw",
|
||||||
|
[
|
||||||
db.sessions,
|
db.sessions,
|
||||||
db.settings,
|
db.settings,
|
||||||
db.players,
|
db.players,
|
||||||
|
db.locations,
|
||||||
|
db.profiles,
|
||||||
|
db.syncState,
|
||||||
|
],
|
||||||
async () => {
|
async () => {
|
||||||
await db.sessions.clear();
|
await db.sessions.clear();
|
||||||
await db.settings.clear();
|
await db.settings.clear();
|
||||||
await db.players.clear();
|
await db.players.clear();
|
||||||
|
await db.locations.clear();
|
||||||
|
await db.profiles.clear();
|
||||||
|
await db.syncState.clear();
|
||||||
|
|
||||||
if (data.sessions.length > 0)
|
if (data.sessions.length > 0)
|
||||||
await db.sessions.bulkAdd(data.sessions);
|
await db.sessions.bulkAdd(data.sessions);
|
||||||
@@ -75,6 +132,10 @@ export default function Settings() {
|
|||||||
await db.settings.bulkAdd(data.settings);
|
await db.settings.bulkAdd(data.settings);
|
||||||
if (data.players.length > 0)
|
if (data.players.length > 0)
|
||||||
await db.players.bulkAdd(data.players);
|
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 () => {
|
const handleReset = async () => {
|
||||||
if (
|
if (
|
||||||
window.confirm(
|
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.");
|
alert("Données réinitialisées.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -169,6 +237,79 @@ export default function Settings() {
|
|||||||
</h1>
|
</h1>
|
||||||
</header>
|
</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">
|
<section className="space-y-4">
|
||||||
<h2 className="text-xl font-bold px-2 tracking-tight">Apparence</h2>
|
<h2 className="text-xl font-bold px-2 tracking-tight">Apparence</h2>
|
||||||
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
|
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import { db } from "../../database/db";
|
import { db } from "../../database/db";
|
||||||
|
import { useProfileStore } from "../../stores/profileStore";
|
||||||
import { games } from "../../games";
|
import { games } from "../../games";
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
import { Card, CardContent } from "../../components/ui/card";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
@@ -10,8 +11,31 @@ import { Avatar } from "../../components/ui/avatar";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
export default function Statistics() {
|
export default function Statistics() {
|
||||||
const allSessions = useLiveQuery(() => db.sessions.toArray()) || [];
|
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
||||||
const players = useLiveQuery(() => db.players.toArray()) || [];
|
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">(
|
const [timeFilter, setTimeFilter] = useState<"all" | "7d" | "30d" | "year">(
|
||||||
"all",
|
"all",
|
||||||
|
|||||||
@@ -27,7 +27,14 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
if (settings) {
|
if (settings) {
|
||||||
await db.settings.put({ ...settings, theme });
|
await db.settings.put({ ...settings, theme });
|
||||||
} else {
|
} 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 ?? '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
@@ -14,6 +14,8 @@ interface GameState {
|
|||||||
gameId: string,
|
gameId: string,
|
||||||
players: Player[],
|
players: Player[],
|
||||||
options: Record<string, any>,
|
options: Record<string, any>,
|
||||||
|
profileId: string,
|
||||||
|
locationId?: string,
|
||||||
) => Promise<string>;
|
) => Promise<string>;
|
||||||
addRound: (scores: RoundScore[]) => Promise<void>;
|
addRound: (scores: RoundScore[]) => Promise<void>;
|
||||||
updateRound: (roundId: string, scores: RoundScore[]) => Promise<void>;
|
updateRound: (roundId: string, scores: RoundScore[]) => Promise<void>;
|
||||||
@@ -37,19 +39,23 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
saveSession: async () => {
|
saveSession: async () => {
|
||||||
const { activeSession } = get();
|
const { activeSession } = get();
|
||||||
if (activeSession) {
|
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 = {
|
const newSession: GameSession = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
gameId,
|
gameId,
|
||||||
dateStart: Date.now(),
|
dateStart: now,
|
||||||
players,
|
players,
|
||||||
rounds: [],
|
rounds: [],
|
||||||
status: "playing",
|
status: "playing",
|
||||||
options,
|
options,
|
||||||
|
locationId,
|
||||||
|
profileId,
|
||||||
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.sessions.add(newSession);
|
await db.sessions.add(newSession);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -73,17 +73,53 @@ export interface GameSession {
|
|||||||
status: "playing" | "finished";
|
status: "playing" | "finished";
|
||||||
options: Record<string, any>;
|
options: Record<string, any>;
|
||||||
winnerIds?: string[];
|
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 {
|
export interface AppSettings {
|
||||||
id: number;
|
id: number;
|
||||||
theme: "light" | "dark" | "system";
|
theme: "light" | "dark" | "system";
|
||||||
language: string;
|
language: string;
|
||||||
|
activeProfileId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedPlayer {
|
export interface SavedPlayer {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
avatar?: string;
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
navigateFallback: "/index.html",
|
navigateFallback: "/index.html",
|
||||||
|
// Never let the SPA fallback swallow API calls.
|
||||||
|
navigateFallbackDenylist: [/^\/api/],
|
||||||
cleanupOutdatedCaches: true,
|
cleanupOutdatedCaches: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -70,4 +72,14 @@ export default defineConfig({
|
|||||||
"@": path.resolve(__dirname, "./src"),
|
"@": 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user