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

This commit is contained in:
Zed
2026-07-11 02:01:08 +02:00
parent 9ae37f98ed
commit ce1db937f2
52 changed files with 5210 additions and 48 deletions
+17 -1
View File
@@ -8,16 +8,29 @@ import PlayGame from "./pages/PlayGame";
import GameOver from "./pages/GameOver";
import History from "./pages/History";
import Players from "./pages/Players";
import Locations from "./pages/Locations";
import Profiles from "./pages/Profiles";
import Auth from "./pages/Auth";
import Settings from "./pages/Settings";
import Statistics from "./pages/Stats";
import { useAppStore } from "./stores/appStore";
import { useProfileStore } from "./stores/profileStore";
import { useAuthStore } from "./stores/authStore";
import { startSyncTriggers } from "./sync/syncEngine";
function App() {
const { loadSettings } = useAppStore();
const { loadProfiles } = useProfileStore();
const restoreAuth = useAuthStore((s) => s.restore);
useEffect(() => {
// Order matters: profiles must be loaded before auth restore triggers a sync.
loadProfiles().then(() => {
restoreAuth();
startSyncTriggers();
});
loadSettings();
}, [loadSettings]);
}, [loadProfiles, loadSettings, restoreAuth]);
return (
<BrowserRouter>
@@ -26,6 +39,9 @@ function App() {
<Route path="/" element={<Home />} />
<Route path="/history" element={<History />} />
<Route path="/players" element={<Players />} />
<Route path="/locations" element={<Locations />} />
<Route path="/profiles" element={<Profiles />} />
<Route path="/login" element={<Auth />} />
<Route path="/stats" element={<Statistics />} />
<Route path="/settings" element={<Settings />} />
</Route>
+48 -3
View File
@@ -1,13 +1,26 @@
import { useState, useRef, useEffect } from "react";
import { Menu, Home, History, Settings, Users, BarChart2 } from "lucide-react";
import {
Menu,
Home,
History,
Settings,
Users,
BarChart2,
MapPin,
UserCircle,
ChevronRight,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "./ui/button";
import { Avatar } from "./ui/avatar";
import { useProfileStore } from "../stores/profileStore";
export function NavigationMenu() {
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
const menuRef = useRef<HTMLDivElement>(null);
const activeProfile = useProfileStore((s) => s.activeProfile);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -41,12 +54,32 @@ export function NavigationMenu() {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="absolute top-full left-0 mt-2 w-56 bg-card border shadow-xl rounded-2xl overflow-hidden"
className="absolute top-full left-0 mt-2 w-60 bg-card border shadow-xl rounded-2xl overflow-hidden"
>
<div className="flex flex-col">
<button
onClick={() => nav("/profiles")}
className="flex items-center w-full p-4 hover:bg-secondary text-left transition-colors bg-primary/5"
>
<Avatar
src={activeProfile?.avatar}
name={activeProfile?.name || "?"}
size="md"
className="mr-3"
/>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Profil actif
</div>
<div className="font-black truncate">
{activeProfile?.name || "—"}
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
</button>
<button
onClick={() => nav("/")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors"
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
>
<Home className="w-5 h-5 mr-3 text-primary" /> Accueil
</button>
@@ -62,12 +95,24 @@ export function NavigationMenu() {
>
<Users className="w-5 h-5 mr-3 text-primary" /> Joueurs
</button>
<button
onClick={() => nav("/locations")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
>
<MapPin className="w-5 h-5 mr-3 text-primary" /> Emplacements
</button>
<button
onClick={() => nav("/stats")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
>
<BarChart2 className="w-5 h-5 mr-3 text-primary" /> Statistiques
</button>
<button
onClick={() => nav("/profiles")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
>
<UserCircle className="w-5 h-5 mr-3 text-primary" /> Profils
</button>
<button
onClick={() => nav("/settings")}
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
+154 -2
View File
@@ -1,10 +1,30 @@
import Dexie, { Table } from "dexie";
import { GameSession, AppSettings, SavedPlayer } from "../types";
import {
GameSession,
AppSettings,
SavedPlayer,
Location,
Profile,
SyncState,
} from "../types";
import { generateId } from "../utils/id";
// When the sync engine applies records pulled/acked from the server, it flips
// this flag so the auto-dirty hooks don't re-mark those writes as dirty.
export const remoteApply = { active: false };
// The sync engine registers a (debounced) callback here so any local change to
// a synced table promptly schedules a push. Kept as a hook to avoid a circular
// import between db.ts and syncEngine.ts.
export const localChange = { notify: null as null | (() => void) };
export class BoardScoreDatabase extends Dexie {
sessions!: Table<GameSession, string>;
settings!: Table<AppSettings, number>;
players!: Table<SavedPlayer, string>;
locations!: Table<Location, string>;
profiles!: Table<Profile, string>;
syncState!: Table<SyncState, string>;
constructor() {
super("BoardScoreDatabase");
@@ -20,16 +40,148 @@ export class BoardScoreDatabase extends Dexie {
settings: "id",
players: "id, name, createdAt",
});
// Version 3: Added locations table, updatedAt tracking for future sync
this.version(3)
.stores({
sessions: "id, gameId, dateStart, status, locationId, updatedAt",
settings: "id",
players: "id, name, createdAt, updatedAt",
locations: "id, name, createdAt, updatedAt",
})
.upgrade(async (tx) => {
await tx
.table("sessions")
.toCollection()
.modify((session) => {
session.updatedAt = session.dateEnd ?? session.dateStart;
});
await tx
.table("players")
.toCollection()
.modify((player) => {
player.updatedAt = player.createdAt;
});
});
// Version 4: Added local profiles; scope existing data to a default profile
this.version(4)
.stores({
sessions:
"id, gameId, dateStart, status, locationId, updatedAt, profileId",
settings: "id",
players: "id, name, createdAt, updatedAt, profileId",
locations: "id, name, createdAt, updatedAt, profileId",
profiles: "id, name, createdAt, updatedAt",
})
.upgrade(async (tx) => {
const now = Date.now();
const defaultProfileId = generateId();
await tx.table("profiles").add({
id: defaultProfileId,
name: "Moi",
createdAt: now,
updatedAt: now,
});
await tx
.table("sessions")
.toCollection()
.modify((session) => {
session.profileId = defaultProfileId;
});
await tx
.table("players")
.toCollection()
.modify((player) => {
player.profileId = defaultProfileId;
});
await tx
.table("locations")
.toCollection()
.modify((location) => {
location.profileId = defaultProfileId;
});
const settings = await tx.table("settings").get(1);
if (settings) {
await tx
.table("settings")
.update(1, { activeProfileId: defaultProfileId });
} else {
await tx.table("settings").add({
id: 1,
theme: "system",
language: "fr",
activeProfileId: defaultProfileId,
});
}
});
// Version 5: Sync bookkeeping (dirty flag + sync cursor per profile)
this.version(5)
.stores({
sessions:
"id, gameId, dateStart, status, locationId, updatedAt, profileId, dirty",
settings: "id",
players: "id, name, createdAt, updatedAt, profileId, dirty",
locations: "id, name, createdAt, updatedAt, profileId, dirty",
profiles: "id, name, createdAt, updatedAt",
syncState: "profileId",
})
.upgrade(async (tx) => {
// Mark all pre-existing records dirty so they push on the first sync.
for (const table of ["sessions", "players", "locations"]) {
await tx
.table(table)
.toCollection()
.modify((row) => {
row.dirty = 1;
});
}
});
}
}
export const db = new BoardScoreDatabase();
// Initialize default settings if empty
// ---- Auto-stamp updatedAt + dirty on every local write to synced tables ----
// Skipped while the sync engine is applying server data (remoteApply.active).
for (const table of [db.sessions, db.players, db.locations]) {
table.hook("creating", (_primKey, obj: any) => {
if (remoteApply.active) return;
if (obj.updatedAt === undefined) obj.updatedAt = Date.now();
if (obj.dirty === undefined) obj.dirty = 1;
localChange.notify?.();
});
table.hook("updating", (modifications: any) => {
if (remoteApply.active) return;
// Don't clobber an explicit dirty/updatedAt already in this update.
const extra: Record<string, unknown> = {};
if (modifications.updatedAt === undefined) extra.updatedAt = Date.now();
if (modifications.dirty === undefined) extra.dirty = 1;
localChange.notify?.();
return extra;
});
}
// Initialize default profile + settings on a fresh install
db.on("populate", async () => {
const now = Date.now();
const defaultProfileId = generateId();
await db.profiles.add({
id: defaultProfileId,
name: "Moi",
createdAt: now,
updatedAt: now,
});
await db.settings.add({
id: 1,
theme: "system",
language: "fr",
activeProfileId: defaultProfileId,
});
});
+101
View File
@@ -0,0 +1,101 @@
// Thin fetch wrapper around the Skori API. The access token lives in memory
// only (never localStorage) to limit XSS blast radius; the refresh token is an
// httpOnly cookie handled entirely by the browser.
const API_BASE = "/api/v1";
let accessToken: string | null = null;
let onUnauthorized: (() => void) | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
export function setOnUnauthorized(cb: (() => void) | null) {
onUnauthorized = cb;
}
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
function rawFetch(
path: string,
opts: RequestInit,
withAuth: boolean,
): Promise<Response> {
const headers: Record<string, string> = {
"content-type": "application/json",
...((opts.headers as Record<string, string>) || {}),
};
if (withAuth && accessToken) {
headers.authorization = `Bearer ${accessToken}`;
}
return fetch(API_BASE + path, {
...opts,
headers,
credentials: "include",
});
}
async function tryRefresh(): Promise<boolean> {
try {
const res = await rawFetch("/auth/refresh", { method: "POST" }, false);
if (!res.ok) return false;
const data = await res.json();
accessToken = data.accessToken;
return true;
} catch {
return false;
}
}
interface CallOptions {
auth?: boolean;
retry?: boolean;
}
export async function apiFetch(
path: string,
opts: RequestInit = {},
{ auth = true, retry = true }: CallOptions = {},
): Promise<Response> {
let res = await rawFetch(path, opts, auth);
if (res.status === 401 && auth && retry) {
const refreshed = await tryRefresh();
if (refreshed) {
res = await rawFetch(path, opts, auth);
} else {
onUnauthorized?.();
}
}
return res;
}
export async function apiJson<T>(
path: string,
opts: RequestInit = {},
cfg: CallOptions = {},
): Promise<T> {
const res = await apiFetch(path, opts, cfg);
if (!res.ok) {
let message = "request_failed";
try {
const body = await res.json();
message = body.error || message;
} catch {
/* ignore */
}
throw new ApiError(res.status, message);
}
return res.json() as Promise<T>;
}
+184
View File
@@ -0,0 +1,184 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { LogIn, UserPlus, Loader2, ChevronLeft } from "lucide-react";
import { useAuthStore } from "../stores/authStore";
import { useProfileStore } from "../stores/profileStore";
import { ApiError } from "../lib/apiClient";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Card, CardContent } from "../components/ui/card";
const schema = z.object({
email: z.string().email("Email invalide"),
password: z.string().min(8, "8 caractères minimum"),
username: z
.string()
.min(3, "3 caractères minimum")
.regex(/^[a-zA-Z0-9_.-]+$/, "Lettres, chiffres, . _ - uniquement")
.optional()
.or(z.literal("")),
});
type FormValues = z.infer<typeof schema>;
const ERROR_MESSAGES: Record<string, string> = {
email_taken: "Cet email est déjà utilisé.",
username_taken: "Ce pseudo est déjà pris.",
invalid_credentials: "Email ou mot de passe incorrect.",
};
export default function Auth() {
const navigate = useNavigate();
const [mode, setMode] = useState<"login" | "register">("login");
const [serverError, setServerError] = useState<string | null>(null);
const { login, register: registerAccount } = useAuthStore();
const activeProfile = useProfileStore((s) => s.activeProfile);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) });
const onSubmit = async (values: FormValues) => {
setServerError(null);
try {
if (mode === "login") {
await login(values.email, values.password);
} else {
await registerAccount({
email: values.email,
password: values.password,
username: values.username || undefined,
displayName: activeProfile?.name,
});
}
navigate("/settings");
} catch (err) {
const code = err instanceof ApiError ? err.message : "request_failed";
setServerError(
ERROR_MESSAGES[code] ||
"Une erreur est survenue. Vérifiez votre connexion.",
);
}
};
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="rounded-full hover:bg-black/10 dark:hover:bg-white/10"
>
<ChevronLeft className="w-6 h-6" />
</Button>
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
{mode === "login" ? "Connexion" : "Créer un compte"}
</h1>
</header>
<p className="text-sm text-muted-foreground px-2">
{mode === "login"
? "Connectez-vous pour synchroniser vos parties entre vos appareils."
: `Créez un compte pour sauvegarder et synchroniser le profil « ${activeProfile?.name ?? ""} ».`}
</p>
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
<CardContent className="p-5">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-bold px-1">Email</label>
<Input
type="email"
autoComplete="email"
placeholder="vous@exemple.fr"
className="h-12"
{...register("email")}
/>
{errors.email && (
<p className="text-xs text-destructive px-1">
{errors.email.message}
</p>
)}
</div>
{mode === "register" && (
<div className="space-y-1">
<label className="text-sm font-bold px-1">
Pseudo (optionnel)
</label>
<Input
autoComplete="username"
placeholder="pour vos futurs amis"
className="h-12"
{...register("username")}
/>
{errors.username && (
<p className="text-xs text-destructive px-1">
{errors.username.message}
</p>
)}
</div>
)}
<div className="space-y-1">
<label className="text-sm font-bold px-1">Mot de passe</label>
<Input
type="password"
autoComplete={
mode === "login" ? "current-password" : "new-password"
}
placeholder="••••••••"
className="h-12"
{...register("password")}
/>
{errors.password && (
<p className="text-xs text-destructive px-1">
{errors.password.message}
</p>
)}
</div>
{serverError && (
<p className="text-sm text-destructive font-medium bg-destructive/10 rounded-xl p-3">
{serverError}
</p>
)}
<Button
type="submit"
disabled={isSubmitting}
className="w-full h-14 rounded-[1.5rem] font-black text-lg shadow-lg"
>
{isSubmitting ? (
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
) : mode === "login" ? (
<LogIn className="w-5 h-5 mr-2" />
) : (
<UserPlus className="w-5 h-5 mr-2" />
)}
{mode === "login" ? "Se connecter" : "Créer le compte"}
</Button>
</form>
</CardContent>
</Card>
<button
onClick={() => {
setServerError(null);
setMode(mode === "login" ? "register" : "login");
}}
className="w-full text-center text-sm font-bold text-primary py-2"
>
{mode === "login"
? "Pas encore de compte ? Créez-en un"
: "Déjà un compte ? Connectez-vous"}
</button>
</div>
);
}
+21 -4
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Trophy, Home, Share2, RotateCcw, Clock } from "lucide-react";
import { Trophy, Home, Share2, RotateCcw, Clock, MapPin } from "lucide-react";
import * as Icons from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db";
import { GameSession } from "../types";
import { getGameConfig } from "../games";
@@ -25,6 +26,12 @@ export default function GameOver() {
const gameConfig = getGameConfig(session?.gameId || "");
useGameTheme(gameConfig);
const location = useLiveQuery(
() =>
session?.locationId ? db.locations.get(session.locationId) : undefined,
[session?.locationId],
);
useEffect(() => {
if (sessionId) {
db.sessions.get(sessionId).then((data) => {
@@ -115,6 +122,8 @@ export default function GameOver() {
gameConfig.id,
newPlayers,
session.options,
session.profileId,
session.locationId,
);
navigate(`/play/${newSessionId}`);
};
@@ -174,9 +183,17 @@ export default function GameOver() {
</p>
</div>
<div className="flex items-center text-sm font-medium text-muted-foreground bg-background px-4 py-2 rounded-full mt-6 shadow-sm border">
<Clock className="w-4 h-4 mr-2" />
{durationInMinutes} minutes {session.rounds.length} manches
<div className="flex items-center flex-wrap justify-center gap-2 mt-6">
<div className="flex items-center text-sm font-medium text-muted-foreground bg-background px-4 py-2 rounded-full shadow-sm border">
<Clock className="w-4 h-4 mr-2" />
{durationInMinutes} minutes {session.rounds.length} manches
</div>
{session.locationId && (
<div className="flex items-center text-sm font-medium text-muted-foreground bg-background px-4 py-2 rounded-full shadow-sm border">
<MapPin className="w-4 h-4 mr-2" />
{location?.name ?? "—"}
</div>
)}
</div>
</motion.div>
</div>
+26 -4
View File
@@ -4,6 +4,8 @@ import { db } from "../database/db";
import { games } from "../games";
import { Card, CardContent } from "../components/ui/card";
import { NavigationMenu } from "../components/NavigationMenu";
import { useProfileStore } from "../stores/profileStore";
import { MapPin } from "lucide-react";
import { motion } from "framer-motion";
@@ -18,9 +20,20 @@ function formatDate(ms: number) {
export default function History() {
const navigate = useNavigate();
const sessions = useLiveQuery(() =>
db.sessions.orderBy("dateStart").reverse().toArray(),
const activeProfileId = useProfileStore((s) => s.activeProfileId);
const sessions = useLiveQuery(
async () => {
if (!activeProfileId) return [];
const arr = await db.sessions
.where("profileId")
.equals(activeProfileId)
.and((s) => !s.deletedAt)
.sortBy("dateStart");
return arr.reverse();
},
[activeProfileId],
);
const locations = useLiveQuery(() => db.locations.toArray()) || [];
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
@@ -40,6 +53,9 @@ export default function History() {
{sessions.map((session, index) => {
const game = games.find((g) => g.id === session.gameId);
if (!game) return null;
const location = session.locationId
? locations.find((l) => l.id === session.locationId)
: undefined;
return (
<motion.div
@@ -85,8 +101,14 @@ export default function History() {
: "Terminée"}
</span>
</div>
<p className="text-sm text-foreground/70 font-medium mb-2">
{formatDate(session.dateStart)}
<p className="text-sm text-foreground/70 font-medium mb-2 flex items-center flex-wrap gap-x-2">
<span>{formatDate(session.dateStart)}</span>
{session.locationId && (
<span className="inline-flex items-center text-xs font-bold text-muted-foreground bg-black/5 dark:bg-white/10 px-2 py-0.5 rounded-full">
<MapPin className="w-3 h-3 mr-1" />
{location?.name ?? "—"}
</span>
)}
</p>
<div className="flex flex-wrap gap-1.5 mt-1">
{session.players.map((p) => {
+13 -3
View File
@@ -5,6 +5,7 @@ import { games } from "../games";
import { db } from "../database/db";
import { motion } from "framer-motion";
import { NavigationMenu } from "../components/NavigationMenu";
import { useProfileStore } from "../stores/profileStore";
// Kurzgesagt-style Planet Logo
const PlanetLogo = () => (
@@ -31,10 +32,19 @@ const PlanetLogo = () => (
export default function Home() {
const navigate = useNavigate();
const activeProfileId = useProfileStore((s) => s.activeProfileId);
// Load unfinished games
const activeSessions = useLiveQuery(() =>
db.sessions.where("status").equals("playing").toArray(),
// Load unfinished games for the active profile
const activeSessions = useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.sessions
.where("profileId")
.equals(activeProfileId)
.and((s) => s.status === "playing" && !s.deletedAt)
.toArray();
},
[activeProfileId],
);
return (
+238
View File
@@ -0,0 +1,238 @@
import { useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db";
import { MapPin, Trash2, Edit2, Check, X, Plus, Home } from "lucide-react";
import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { NavigationMenu } from "../components/NavigationMenu";
import { generateId } from "../utils/id";
import { useProfileStore } from "../stores/profileStore";
import { motion } from "framer-motion";
export default function Locations() {
const activeProfileId = useProfileStore((s) => s.activeProfileId);
const locations = useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.locations
.where("profileId")
.equals(activeProfileId)
.and((l) => !l.deletedAt)
.sortBy("name");
},
[activeProfileId],
);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editAddress, setEditAddress] = useState("");
const [isAdding, setIsAdding] = useState(false);
const [newName, setNewName] = useState("");
const [newAddress, setNewAddress] = useState("");
const handleAdd = async () => {
const name = newName.trim();
if (!name || !activeProfileId) return;
const now = Date.now();
await db.locations.add({
id: generateId(),
name,
address: newAddress.trim() || undefined,
createdAt: now,
updatedAt: now,
profileId: activeProfileId,
});
setNewName("");
setNewAddress("");
setIsAdding(false);
};
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Êtes-vous sûr de vouloir supprimer "${name}" ?`)) {
// Soft-delete (tombstone) so the deletion can propagate to the server.
await db.locations.update(id, { deletedAt: Date.now() });
}
};
const startEdit = (id: string, name: string, address?: string) => {
setEditingId(id);
setEditName(name);
setEditAddress(address ?? "");
};
const saveEdit = async (id: string) => {
const name = editName.trim();
if (name) {
await db.locations.update(id, {
name,
address: editAddress.trim() || undefined,
updatedAt: Date.now(),
});
}
setEditingId(null);
};
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
<NavigationMenu />
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
Emplacements
</h1>
</header>
{isAdding ? (
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
<CardContent className="p-4 space-y-3">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Nom (ex: Maison, Le Valet d'Or...)"
className="h-12"
autoFocus
/>
<Input
value={newAddress}
onChange={(e) => setNewAddress(e.target.value)}
placeholder="Adresse (optionnel)"
className="h-12"
/>
<div className="flex space-x-2">
<Button
onClick={handleAdd}
className="flex-1 rounded-full font-bold"
disabled={!newName.trim()}
>
<Check className="w-5 h-5 mr-2" /> Ajouter
</Button>
<Button
variant="ghost"
onClick={() => {
setIsAdding(false);
setNewName("");
setNewAddress("");
}}
className="rounded-full"
>
<X className="w-5 h-5" />
</Button>
</div>
</CardContent>
</Card>
) : (
<Button
onClick={() => setIsAdding(true)}
className="w-full h-14 rounded-[1.5rem] font-black text-lg shadow-lg"
>
<Plus className="w-6 h-6 mr-2" /> Nouvel emplacement
</Button>
)}
<div className="space-y-4">
{!locations || locations.length === 0 ? (
<div className="text-center text-muted-foreground py-12 flex flex-col items-center bg-background/60 backdrop-blur-sm rounded-[2rem]">
<MapPin className="w-16 h-16 mb-4 opacity-20" />
<p className="font-bold text-lg">Aucun emplacement enregistré.</p>
<p className="text-sm mt-1 max-w-[250px]">
Ajoutez votre maison ou vos enseignes de jeux favorites.
</p>
</div>
) : (
locations.map((location, index) => (
<motion.div
key={location.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{
delay: index * 0.05,
type: "spring",
stiffness: 100,
}}
>
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md overflow-visible relative group">
<div className="absolute -left-2 -top-2 w-8 h-8 rounded-full bg-primary/20 blur-md pointer-events-none group-hover:bg-primary/40 transition-colors" />
<CardContent className="p-4 flex items-center justify-between">
{editingId === location.id ? (
<div className="flex flex-col space-y-2 flex-1 mr-2 relative z-10">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-12"
autoFocus
/>
<Input
value={editAddress}
onChange={(e) => setEditAddress(e.target.value)}
placeholder="Adresse (optionnel)"
className="h-12"
/>
<div className="flex space-x-2">
<Button
variant="ghost"
size="icon"
onClick={() => saveEdit(location.id)}
className="text-green-600 bg-green-500/10 hover:bg-green-500/20"
>
<Check className="w-6 h-6" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingId(null)}
className="text-muted-foreground bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10"
>
<X className="w-6 h-6" />
</Button>
</div>
</div>
) : (
<>
<div className="flex items-center mr-4 shrink-0">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center shadow-md border-4 border-background">
<Home className="w-6 h-6 text-primary" />
</div>
</div>
<div className="flex-1 min-w-0">
<span className="font-black text-xl truncate block tracking-tight">
{location.name}
</span>
{location.address && (
<span className="text-sm text-muted-foreground truncate block">
{location.address}
</span>
)}
</div>
<div className="flex items-center shrink-0 space-x-1 relative z-10">
<Button
variant="ghost"
size="icon"
onClick={() =>
startEdit(location.id, location.name, location.address)
}
className="text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5"
>
<Edit2 className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(location.id, location.name)}
className="text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
</>
)}
</CardContent>
</Card>
</motion.div>
))
)}
</div>
</div>
);
}
+76 -3
View File
@@ -8,6 +8,7 @@ import {
Users,
GripVertical,
BookOpen,
MapPin,
} from "lucide-react";
import * as Icons from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks";
@@ -15,6 +16,7 @@ import { Reorder, useDragControls } from "framer-motion";
import { db } from "../database/db";
import { getGameConfig } from "../games";
import { useGameStore } from "../stores/gameStore";
import { useProfileStore } from "../stores/profileStore";
import { useGameTheme } from "../hooks/useGameTheme";
import { Player, SavedPlayer } from "../types";
import { Button } from "../components/ui/button";
@@ -82,15 +84,39 @@ export default function NewGame() {
useGameTheme(gameConfig);
const startNewGame = useGameStore((state) => state.startNewGame);
const activeProfileId = useProfileStore((state) => state.activeProfileId);
const [players, setPlayers] = useState<Player[]>([
{ id: generateId(), name: "" },
{ id: generateId(), name: "" },
]);
const [options, setOptions] = useState<Record<string, any>>({});
const [locationId, setLocationId] = useState<string | undefined>(undefined);
const savedPlayers =
useLiveQuery(() => db.players.orderBy("name").toArray()) || [];
useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.players
.where("profileId")
.equals(activeProfileId)
.and((p) => !p.deletedAt)
.sortBy("name");
},
[activeProfileId],
) || [];
const locations =
useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.locations
.where("profileId")
.equals(activeProfileId)
.and((l) => !l.deletedAt)
.sortBy("name");
},
[activeProfileId],
) || [];
const availableSavedPlayers = savedPlayers.filter(
(sp) =>
!players.some(
@@ -143,6 +169,8 @@ export default function NewGame() {
};
const handleStart = async () => {
if (!activeProfileId) return;
// Basic validation
const validPlayers = players.filter((p) => p.name.trim() !== "");
if (validPlayers.length < gameConfig.minPlayers) {
@@ -158,6 +186,7 @@ export default function NewGame() {
const exists = await db.players
.where("name")
.equalsIgnoreCase(p.name)
.and((pl) => pl.profileId === activeProfileId && !pl.deletedAt)
.first();
if (!exists) {
@@ -176,16 +205,25 @@ export default function NewGame() {
console.error("Failed to generate auto-avatar");
}
}
const now = Date.now();
await db.players.add({
id: p.id,
name: p.name,
createdAt: Date.now(),
createdAt: now,
updatedAt: now,
avatar: finalAvatar,
profileId: activeProfileId,
});
}
}
const sessionId = await startNewGame(gameConfig.id, playersToSave, options);
const sessionId = await startNewGame(
gameConfig.id,
playersToSave,
options,
activeProfileId,
locationId,
);
navigate(`/play/${sessionId}`);
};
@@ -323,6 +361,41 @@ export default function NewGame() {
)}
</section>
{locations.length > 0 && (
<section className="space-y-4">
<h2 className="text-2xl font-black tracking-tighter px-2 drop-shadow-sm">
Lieu
</h2>
<div className="flex flex-wrap gap-2 px-1">
<div
className={`flex items-center px-3 py-2 rounded-full cursor-pointer active:scale-95 transition-all text-sm font-bold border-2 shadow-sm ${
!locationId
? "bg-primary/10 text-primary border-primary/30"
: "bg-background/80 backdrop-blur-md border-transparent hover:border-primary/20"
}`}
onClick={() => setLocationId(undefined)}
>
<MapPin className="w-4 h-4 mr-1.5 opacity-50" />
Aucun
</div>
{locations.map((loc) => (
<div
key={loc.id}
className={`flex items-center px-3 py-2 rounded-full cursor-pointer active:scale-95 transition-all text-sm font-bold border-2 shadow-sm ${
locationId === loc.id
? "bg-primary/10 text-primary border-primary/30"
: "bg-background/80 backdrop-blur-md border-transparent hover:border-primary/20"
}`}
onClick={() => setLocationId(loc.id)}
>
<MapPin className="w-4 h-4 mr-1.5 opacity-50" />
{loc.name}
</div>
))}
</div>
</section>
)}
{gameConfig.options && gameConfig.options.length > 0 && (
<section className="space-y-4">
<h2 className="text-2xl font-black tracking-tighter px-2 drop-shadow-sm">
+31 -7
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db";
import { useProfileStore } from "../stores/profileStore";
import {
Users,
Trash2,
@@ -20,14 +21,26 @@ import { resizeImage } from "../utils/image";
import { motion } from "framer-motion";
export default function Players() {
const players = useLiveQuery(() => db.players.orderBy("name").toArray());
const activeProfileId = useProfileStore((s) => s.activeProfileId);
const players = useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.players
.where("profileId")
.equals(activeProfileId)
.and((p) => !p.deletedAt)
.sortBy("name");
},
[activeProfileId],
);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [isGenerating, setIsGenerating] = useState<string | null>(null);
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`Êtes-vous sûr de vouloir supprimer ${name} ?`)) {
await db.players.delete(id);
// Soft-delete (tombstone) so the deletion can propagate to the server.
await db.players.update(id, { deletedAt: Date.now() });
}
};
@@ -41,10 +54,16 @@ export default function Players() {
const newName = editName.trim();
const playerToEdit = players?.find((p) => p.id === id);
await db.players.update(id, { name: newName });
await db.players.update(id, { name: newName, updatedAt: Date.now() });
// Update the player's name in all existing game sessions (history)
const sessions = await db.sessions.toArray();
// Update the player's name in this profile's game sessions (history).
// Scoped by profile so a same-named player in another profile is untouched.
const sessions = activeProfileId
? await db.sessions
.where("profileId")
.equals(activeProfileId)
.toArray()
: [];
const updatedSessions = sessions
.map((session) => {
let hasChanges = false;
@@ -75,10 +94,15 @@ export default function Players() {
playerId: string,
avatarData: string,
) => {
await db.players.update(playerId, { avatar: avatarData });
await db.players.update(playerId, {
avatar: avatarData,
updatedAt: Date.now(),
});
// Update historical sessions too if we want the avatar to reflect immediately everywhere
const sessions = await db.sessions.toArray();
const sessions = activeProfileId
? await db.sessions.where("profileId").equals(activeProfileId).toArray()
: [];
const updatedSessions = sessions
.map((session) => {
let hasChanges = false;
+314
View File
@@ -0,0 +1,314 @@
import { useState } from "react";
import { useProfileStore } from "../stores/profileStore";
import {
UserCircle,
Trash2,
Edit2,
Check,
X,
Camera,
Dices,
Loader2,
Plus,
CheckCircle2,
} from "lucide-react";
import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { NavigationMenu } from "../components/NavigationMenu";
import { Avatar } from "../components/ui/avatar";
import { resizeImage } from "../utils/image";
import { motion } from "framer-motion";
export default function Profiles() {
const {
profiles,
activeProfileId,
switchProfile,
createProfile,
updateProfile,
deleteProfile,
} = useProfileStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [isAdding, setIsAdding] = useState(false);
const [newName, setNewName] = useState("");
const [isGenerating, setIsGenerating] = useState<string | null>(null);
const handleAdd = async () => {
const name = newName.trim();
if (!name) return;
await createProfile(name);
setNewName("");
setIsAdding(false);
};
const handleDelete = async (id: string, name: string) => {
if (profiles.length <= 1) {
alert("Vous devez conserver au moins un profil.");
return;
}
if (
window.confirm(
`Supprimer le profil "${name}" ?\n\nToutes ses parties, joueurs et emplacements seront définitivement supprimés.`,
)
) {
await deleteProfile(id);
}
};
const startEdit = (id: string, name: string) => {
setEditingId(id);
setEditName(name);
};
const saveEdit = async (id: string) => {
if (editName.trim()) {
await updateProfile(id, { name: editName.trim() });
}
setEditingId(null);
};
const handleAvatarChange = async (
profileId: string,
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const base64Image = await resizeImage(file);
await updateProfile(profileId, { avatar: base64Image });
} catch (e) {
alert("Erreur lors de l'enregistrement de l'image.");
}
};
const handleGenerateRandomAvatar = async (profileId: string) => {
setIsGenerating(profileId);
try {
const seed = Math.random().toString(36).substring(7);
const url = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${seed}`;
const response = await fetch(url);
const svgText = await response.text();
const encodedSvg = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`;
await updateProfile(profileId, { avatar: encodedSvg });
} catch (e) {
alert(
"Impossible de générer l'avatar. Vérifiez votre connexion internet.",
);
} finally {
setIsGenerating(null);
}
};
return (
<div className="p-4 space-y-6 pb-20 relative z-50">
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
<NavigationMenu />
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
Profils
</h1>
</header>
<p className="text-sm text-muted-foreground px-2">
Chaque profil possède ses propres parties, joueurs et emplacements.
Touchez un profil pour l'activer.
</p>
{isAdding ? (
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
<CardContent className="p-4 space-y-3">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Nom du profil"
className="h-12"
autoFocus
/>
<div className="flex space-x-2">
<Button
onClick={handleAdd}
className="flex-1 rounded-full font-bold"
disabled={!newName.trim()}
>
<Check className="w-5 h-5 mr-2" /> Créer
</Button>
<Button
variant="ghost"
onClick={() => {
setIsAdding(false);
setNewName("");
}}
className="rounded-full"
>
<X className="w-5 h-5" />
</Button>
</div>
</CardContent>
</Card>
) : (
<Button
onClick={() => setIsAdding(true)}
className="w-full h-14 rounded-[1.5rem] font-black text-lg shadow-lg"
>
<Plus className="w-6 h-6 mr-2" /> Nouveau profil
</Button>
)}
<div className="space-y-4">
{profiles.length === 0 ? (
<div className="text-center text-muted-foreground py-12 flex flex-col items-center bg-background/60 backdrop-blur-sm rounded-[2rem]">
<UserCircle className="w-16 h-16 mb-4 opacity-20" />
<p className="font-bold text-lg">Aucun profil.</p>
</div>
) : (
profiles.map((profile, index) => {
const isActive = profile.id === activeProfileId;
return (
<motion.div
key={profile.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{
delay: index * 0.05,
type: "spring",
stiffness: 100,
}}
>
<Card
className={`border-0 shadow-lg backdrop-blur-md overflow-visible relative group transition-colors ${
isActive
? "bg-primary/10 ring-2 ring-primary/40"
: "bg-background/90 cursor-pointer"
}`}
onClick={() => {
if (!isActive && editingId !== profile.id) {
switchProfile(profile.id);
}
}}
>
<CardContent className="p-4 flex items-center justify-between">
{editingId === profile.id ? (
<div className="flex items-center space-x-2 flex-1 mr-2 relative z-10">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-12"
autoFocus
/>
<Button
variant="ghost"
size="icon"
onClick={() => saveEdit(profile.id)}
className="text-green-600 bg-green-500/10 hover:bg-green-500/20"
>
<Check className="w-6 h-6" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditingId(null)}
className="text-muted-foreground bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10"
>
<X className="w-6 h-6" />
</Button>
</div>
) : (
<>
<div className="flex flex-col items-center mr-4 shrink-0 gap-1.5">
<div
className="relative group/avatar cursor-pointer"
onClick={(e) => {
e.stopPropagation();
document
.getElementById(`profile-avatar-${profile.id}`)
?.click();
}}
>
<Avatar
src={profile.avatar}
name={profile.name}
size="lg"
className="shadow-md border-4 border-background"
/>
<div className="absolute inset-0 bg-black/40 rounded-full flex items-center justify-center opacity-0 group-hover/avatar:opacity-100 transition-opacity">
<Camera className="w-5 h-5 text-white" />
</div>
<input
type="file"
id={`profile-avatar-${profile.id}`}
className="hidden"
accept="image/*"
onChange={(e) =>
handleAvatarChange(profile.id, e)
}
/>
</div>
<Button
variant="ghost"
size="sm"
disabled={isGenerating === profile.id}
className="h-6 px-3 rounded-full text-[10px] font-black uppercase tracking-wider bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
onClick={(e) => {
e.stopPropagation();
handleGenerateRandomAvatar(profile.id);
}}
>
{isGenerating === profile.id ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<Dices className="w-3 h-3 mr-1" />
)}
Aléatoire
</Button>
</div>
<div className="flex-1 min-w-0">
<span className="font-black text-xl truncate block tracking-tight">
{profile.name}
</span>
{isActive && (
<span className="inline-flex items-center text-xs font-bold text-primary mt-0.5">
<CheckCircle2 className="w-3.5 h-3.5 mr-1" />
Profil actif
</span>
)}
</div>
<div className="flex items-center shrink-0 space-x-1 relative z-10">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
startEdit(profile.id, profile.name);
}}
className="text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5"
>
<Edit2 className="w-5 h-5" />
</Button>
{profiles.length > 1 && (
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleDelete(profile.id, profile.name);
}}
className="text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-5 h-5" />
</Button>
)}
</div>
</>
)}
</CardContent>
</Card>
</motion.div>
);
})
)}
</div>
</div>
);
}
+147 -6
View File
@@ -1,3 +1,5 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Moon,
Sun,
@@ -6,9 +8,18 @@ import {
Download,
Upload,
FileSpreadsheet,
LogIn,
LogOut,
RefreshCw,
Check,
CloudOff,
} from "lucide-react";
import { useAppStore } from "../stores/appStore";
import { useProfileStore } from "../stores/profileStore";
import { useAuthStore } from "../stores/authStore";
import { runSync, onSyncStatus, SyncStatus } from "../sync/syncEngine";
import { db } from "../database/db";
import { generateId } from "../utils/id";
import { getGameConfig } from "../games";
import { calculatePlayerTotalScore } from "../utils/scoring";
import { Card, CardContent } from "../components/ui/card";
@@ -17,13 +28,26 @@ import { NavigationMenu } from "../components/NavigationMenu";
export default function Settings() {
const { theme, setTheme } = useAppStore();
const navigate = useNavigate();
const { user, status, logout } = useAuthStore();
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
useEffect(() => onSyncStatus(setSyncStatus), []);
const handleExportJson = async () => {
try {
const sessions = await db.sessions.toArray();
const settings = await db.settings.toArray();
const players = await db.players.toArray();
const data = JSON.stringify({ sessions, settings, players });
const locations = await db.locations.toArray();
const profiles = await db.profiles.toArray();
const data = JSON.stringify({
sessions,
settings,
players,
locations,
profiles,
});
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -54,6 +78,31 @@ export default function Settings() {
throw new Error("Fichier de sauvegarde invalide ou corrompu.");
}
const locations = data.locations || [];
let profiles = data.profiles || [];
// Rétrocompatibilité : un ancien backup n'a pas de profils.
// On crée un profil de secours et on y rattache les données héritées.
if (profiles.length === 0) {
const now = Date.now();
const fallbackId = generateId();
profiles = [
{ id: fallbackId, name: "Moi", createdAt: now, updatedAt: now },
];
data.sessions.forEach((s: any) => {
if (!s.profileId) s.profileId = fallbackId;
});
data.players.forEach((p: any) => {
if (!p.profileId) p.profileId = fallbackId;
});
locations.forEach((l: any) => {
if (!l.profileId) l.profileId = fallbackId;
});
data.settings.forEach((st: any) => {
if (!st.activeProfileId) st.activeProfileId = fallbackId;
});
}
if (
window.confirm(
"Attention : L'importation va écraser vos données actuelles. Voulez-vous continuer ?",
@@ -61,13 +110,21 @@ export default function Settings() {
) {
await db.transaction(
"rw",
db.sessions,
db.settings,
db.players,
[
db.sessions,
db.settings,
db.players,
db.locations,
db.profiles,
db.syncState,
],
async () => {
await db.sessions.clear();
await db.settings.clear();
await db.players.clear();
await db.locations.clear();
await db.profiles.clear();
await db.syncState.clear();
if (data.sessions.length > 0)
await db.sessions.bulkAdd(data.sessions);
@@ -75,6 +132,10 @@ export default function Settings() {
await db.settings.bulkAdd(data.settings);
if (data.players.length > 0)
await db.players.bulkAdd(data.players);
if (locations.length > 0)
await db.locations.bulkAdd(locations);
if (profiles.length > 0)
await db.profiles.bulkAdd(profiles);
},
);
@@ -152,10 +213,17 @@ export default function Settings() {
const handleReset = async () => {
if (
window.confirm(
"Êtes-vous sûr de vouloir supprimer TOUTES vos parties ? Cette action est irréversible.",
"Êtes-vous sûr de vouloir supprimer TOUTES les parties de ce profil ? Cette action est irréversible.",
)
) {
await db.sessions.clear();
const activeProfileId = useProfileStore.getState().activeProfileId;
if (!activeProfileId) return;
// Soft-delete the active profile's sessions so the reset propagates on sync.
const now = Date.now();
await db.sessions
.where("profileId")
.equals(activeProfileId)
.modify({ deletedAt: now });
alert("Données réinitialisées.");
}
};
@@ -169,6 +237,79 @@ export default function Settings() {
</h1>
</header>
<section className="space-y-4">
<h2 className="text-xl font-bold px-2 tracking-tight">Compte</h2>
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
<CardContent className="p-4">
{status === "authenticated" && user ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="font-black text-lg truncate">
{user.displayName}
</p>
<p className="text-sm text-muted-foreground truncate">
{user.email}
</p>
</div>
<span className="flex items-center text-xs font-bold text-emerald-600 bg-emerald-500/10 px-3 py-1.5 rounded-full shrink-0">
{syncStatus === "syncing" ? (
<>
<RefreshCw className="w-3.5 h-3.5 mr-1 animate-spin" />
Sync
</>
) : syncStatus === "error" ? (
<span className="flex items-center text-destructive">
<CloudOff className="w-3.5 h-3.5 mr-1" />
Erreur
</span>
) : (
<>
<Check className="w-3.5 h-3.5 mr-1" />
Synchronisé
</>
)}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<Button
variant="outline"
className="rounded-2xl font-bold"
onClick={() => runSync()}
disabled={syncStatus === "syncing"}
>
<RefreshCw className="w-4 h-4 mr-2" />
Synchroniser
</Button>
<Button
variant="outline"
className="rounded-2xl font-bold text-destructive hover:bg-destructive/10"
onClick={() => logout()}
>
<LogOut className="w-4 h-4 mr-2" />
Déconnexion
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Connectez-vous pour sauvegarder et synchroniser vos parties
entre vos appareils.
</p>
<Button
className="w-full h-12 rounded-2xl font-black"
onClick={() => navigate("/login")}
>
<LogIn className="w-5 h-5 mr-2" />
Se connecter / Créer un compte
</Button>
</div>
)}
</CardContent>
</Card>
</section>
<section className="space-y-4">
<h2 className="text-xl font-bold px-2 tracking-tight">Apparence</h2>
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
+26 -2
View File
@@ -1,6 +1,7 @@
import { useState, useMemo } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../../database/db";
import { useProfileStore } from "../../stores/profileStore";
import { games } from "../../games";
import { Card, CardContent } from "../../components/ui/card";
import { Badge } from "../../components/ui/badge";
@@ -10,8 +11,31 @@ import { Avatar } from "../../components/ui/avatar";
import { motion } from "framer-motion";
export default function Statistics() {
const allSessions = useLiveQuery(() => db.sessions.toArray()) || [];
const players = useLiveQuery(() => db.players.toArray()) || [];
const activeProfileId = useProfileStore((s) => s.activeProfileId);
const allSessions =
useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.sessions
.where("profileId")
.equals(activeProfileId)
.and((s) => !s.deletedAt)
.toArray();
},
[activeProfileId],
) || [];
const players =
useLiveQuery(
async () => {
if (!activeProfileId) return [];
return db.players
.where("profileId")
.equals(activeProfileId)
.and((p) => !p.deletedAt)
.toArray();
},
[activeProfileId],
) || [];
const [timeFilter, setTimeFilter] = useState<"all" | "7d" | "30d" | "year">(
"all",
+8 -1
View File
@@ -27,7 +27,14 @@ export const useAppStore = create<AppState>((set) => ({
if (settings) {
await db.settings.put({ ...settings, theme });
} else {
await db.settings.add({ id: 1, theme, language: 'fr' });
// Fallback (should not happen: populate always seeds settings + a profile)
const firstProfile = await db.profiles.orderBy('createdAt').first();
await db.settings.add({
id: 1,
theme,
language: 'fr',
activeProfileId: firstProfile?.id ?? '',
});
}
}
}));
+117
View File
@@ -0,0 +1,117 @@
import { create } from "zustand";
import {
apiJson,
setAccessToken,
setOnUnauthorized,
} from "../lib/apiClient";
import { useProfileStore } from "./profileStore";
import { db } from "../database/db";
import { runSync } from "../sync/syncEngine";
export interface AuthUser {
id: string;
email: string;
username: string | null;
displayName: string;
avatarUrl: string | null;
}
type AuthStatus = "unknown" | "authenticated" | "anonymous";
interface AuthResponse {
user: AuthUser;
accessToken: string;
}
interface AuthState {
user: AuthUser | null;
status: AuthStatus;
register: (input: {
email: string;
password: string;
displayName?: string;
username?: string;
}) => Promise<void>;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
restore: () => Promise<void>;
}
async function linkActiveProfileToUser(userId: string) {
const profileId = useProfileStore.getState().activeProfileId;
if (profileId) {
await db.profiles.update(profileId, { remoteUserId: userId });
await useProfileStore.getState().loadProfiles();
}
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
status: "unknown",
register: async ({ email, password, displayName, username }) => {
const profile = useProfileStore.getState().activeProfile;
const res = await apiJson<AuthResponse>(
"/auth/register",
{
method: "POST",
body: JSON.stringify({
email,
password,
displayName: displayName || profile?.name || email,
username: username || undefined,
desiredId: profile?.id,
}),
},
{ auth: false },
);
setAccessToken(res.accessToken);
set({ user: res.user, status: "authenticated" });
await linkActiveProfileToUser(res.user.id);
void runSync();
},
login: async (email, password) => {
const res = await apiJson<AuthResponse>(
"/auth/login",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ auth: false },
);
setAccessToken(res.accessToken);
set({ user: res.user, status: "authenticated" });
await linkActiveProfileToUser(res.user.id);
void runSync();
},
logout: async () => {
try {
await apiJson("/auth/logout", { method: "POST" }, { auth: false });
} catch {
/* ignore network errors on logout */
}
setAccessToken(null);
set({ user: null, status: "anonymous" });
},
// On boot: try to silently resume a session via the refresh cookie.
restore: async () => {
try {
const res = await apiJson<AuthResponse>(
"/auth/refresh",
{ method: "POST" },
{ auth: false },
);
setAccessToken(res.accessToken);
set({ user: res.user, status: "authenticated" });
void runSync();
} catch {
set({ user: null, status: "anonymous" });
}
},
}));
// If a refresh ultimately fails mid-request, drop back to anonymous.
setOnUnauthorized(() => {
setAccessToken(null);
useAuthStore.setState({ user: null, status: "anonymous" });
});
+9 -3
View File
@@ -14,6 +14,8 @@ interface GameState {
gameId: string,
players: Player[],
options: Record<string, any>,
profileId: string,
locationId?: string,
) => Promise<string>;
addRound: (scores: RoundScore[]) => Promise<void>;
updateRound: (roundId: string, scores: RoundScore[]) => Promise<void>;
@@ -37,19 +39,23 @@ export const useGameStore = create<GameState>((set, get) => ({
saveSession: async () => {
const { activeSession } = get();
if (activeSession) {
await db.sessions.put(activeSession);
await db.sessions.put({ ...activeSession, updatedAt: Date.now() });
}
},
startNewGame: async (gameId, players, options) => {
startNewGame: async (gameId, players, options, profileId, locationId) => {
const now = Date.now();
const newSession: GameSession = {
id: generateId(),
gameId,
dateStart: Date.now(),
dateStart: now,
players,
rounds: [],
status: "playing",
options,
locationId,
profileId,
updatedAt: now,
};
await db.sessions.add(newSession);
+133
View File
@@ -0,0 +1,133 @@
import { create } from "zustand";
import { Profile } from "../types";
import { db } from "../database/db";
import { generateId } from "../utils/id";
interface ProfileState {
activeProfileId: string | null;
activeProfile: Profile | null;
profiles: Profile[];
loadProfiles: () => Promise<void>;
switchProfile: (id: string) => Promise<void>;
createProfile: (name: string, avatar?: string) => Promise<Profile>;
updateProfile: (
id: string,
changes: Partial<Pick<Profile, "name" | "avatar">>,
) => Promise<void>;
deleteProfile: (id: string) => Promise<void>;
}
async function ensureDefaultProfile(): Promise<Profile> {
const now = Date.now();
const profile: Profile = {
id: generateId(),
name: "Moi",
createdAt: now,
updatedAt: now,
};
await db.profiles.add(profile);
return profile;
}
async function setActiveProfileId(id: string) {
const settings = await db.settings.get(1);
if (settings) {
await db.settings.put({ ...settings, activeProfileId: id });
} else {
await db.settings.add({
id: 1,
theme: "system",
language: "fr",
activeProfileId: id,
});
}
}
export const useProfileStore = create<ProfileState>((set, get) => ({
activeProfileId: null,
activeProfile: null,
profiles: [],
loadProfiles: async () => {
let profiles = await db.profiles.orderBy("createdAt").toArray();
// Defensive: guarantee at least one profile exists
if (profiles.length === 0) {
const created = await ensureDefaultProfile();
profiles = [created];
}
const settings = await db.settings.get(1);
let activeId = settings?.activeProfileId;
// Fallback if the stored active profile no longer exists
if (!activeId || !profiles.some((p) => p.id === activeId)) {
activeId = profiles[0].id;
await setActiveProfileId(activeId);
}
const activeProfile = profiles.find((p) => p.id === activeId) || null;
set({ profiles, activeProfileId: activeId, activeProfile });
},
switchProfile: async (id) => {
const profile = await db.profiles.get(id);
if (!profile) return;
await setActiveProfileId(id);
set({ activeProfileId: id, activeProfile: profile });
},
createProfile: async (name, avatar) => {
const now = Date.now();
const profile: Profile = {
id: generateId(),
name: name.trim(),
avatar,
createdAt: now,
updatedAt: now,
};
await db.profiles.add(profile);
await get().loadProfiles();
return profile;
},
updateProfile: async (id, changes) => {
await db.profiles.update(id, { ...changes, updatedAt: Date.now() });
await get().loadProfiles();
},
deleteProfile: async (id) => {
const { profiles, activeProfileId } = get();
// Never delete the last remaining profile
if (profiles.length <= 1) return;
// Remove this profile and its local data from the device. This is a local
// removal (not a sync deletion), so we hard-delete without tombstones.
await db.transaction(
"rw",
db.profiles,
db.sessions,
db.players,
db.locations,
db.syncState,
async () => {
await db.sessions.where("profileId").equals(id).delete();
await db.players.where("profileId").equals(id).delete();
await db.locations.where("profileId").equals(id).delete();
await db.syncState.delete(id);
await db.profiles.delete(id);
},
);
// If we removed the active profile, switch to another one
if (activeProfileId === id) {
const remaining = await db.profiles.orderBy("createdAt").first();
if (remaining) {
await setActiveProfileId(remaining.id);
}
}
await get().loadProfiles();
},
}));
+217
View File
@@ -0,0 +1,217 @@
import { db, remoteApply, localChange } from "../database/db";
import { apiJson, getAccessToken } from "../lib/apiClient";
import { useProfileStore } from "../stores/profileStore";
import { GameSession, Location, SavedPlayer } from "../types";
interface PullResponse {
serverTime: number;
locations: any[];
players: any[];
sessions: any[];
}
interface PushResponse {
serverTime: number;
applied: {
locations: { id: string; updatedAt: number }[];
players: { id: string; updatedAt: number }[];
sessions: { id: string; updatedAt: number }[];
};
}
let syncing = false;
const listeners = new Set<(state: SyncStatus) => void>();
export type SyncStatus = "idle" | "syncing" | "error";
let currentStatus: SyncStatus = "idle";
export function onSyncStatus(cb: (state: SyncStatus) => void): () => void {
listeners.add(cb);
cb(currentStatus);
return () => listeners.delete(cb);
}
function setStatus(s: SyncStatus) {
currentStatus = s;
listeners.forEach((cb) => cb(s));
}
// ---- Mapping: server rows -> local records (scoped to the active profile) ----
function toLocation(r: any, profileId: string): Location {
return {
id: r.id,
name: r.name,
address: r.address ?? undefined,
createdAt: Number(r.createdAt),
updatedAt: Number(r.updatedAt),
deletedAt: r.deletedAt ? Number(r.deletedAt) : undefined,
profileId,
dirty: 0,
};
}
function toPlayer(r: any, profileId: string): SavedPlayer {
return {
id: r.id,
name: r.name,
avatar: r.avatar ?? undefined,
createdAt: Number(r.createdAt),
updatedAt: Number(r.updatedAt),
deletedAt: r.deletedAt ? Number(r.deletedAt) : undefined,
profileId,
dirty: 0,
};
}
function toSession(r: any, profileId: string): GameSession {
return {
id: r.id,
gameId: r.gameId,
dateStart: Number(r.dateStart),
dateEnd: r.dateEnd ? Number(r.dateEnd) : undefined,
players: r.players ?? [],
rounds: r.rounds ?? [],
status: r.status,
options: r.options ?? {},
winnerIds: r.winnerIds ?? undefined,
locationId: r.locationId ?? undefined,
updatedAt: Number(r.updatedAt),
deletedAt: r.deletedAt ? Number(r.deletedAt) : undefined,
profileId,
dirty: 0,
};
}
// ---- Push local (dirty) records, then clear their dirty flag on ack ----
async function pushDirty(profileId: string): Promise<void> {
const [locations, players, sessions] = await Promise.all([
db.locations
.where("profileId")
.equals(profileId)
.and((r) => r.dirty === 1)
.toArray(),
db.players
.where("profileId")
.equals(profileId)
.and((r) => r.dirty === 1)
.toArray(),
db.sessions
.where("profileId")
.equals(profileId)
.and((r) => r.dirty === 1)
.toArray(),
]);
if (
locations.length === 0 &&
players.length === 0 &&
sessions.length === 0
) {
return;
}
const res = await apiJson<PushResponse>("/sync", {
method: "POST",
body: JSON.stringify({ locations, players, sessions }),
});
remoteApply.active = true;
try {
await db.transaction(
"rw",
db.locations,
db.players,
db.sessions,
async () => {
for (const a of res.applied.locations)
await db.locations.update(a.id, { updatedAt: a.updatedAt, dirty: 0 });
for (const a of res.applied.players)
await db.players.update(a.id, { updatedAt: a.updatedAt, dirty: 0 });
for (const a of res.applied.sessions)
await db.sessions.update(a.id, { updatedAt: a.updatedAt, dirty: 0 });
},
);
} finally {
remoteApply.active = false;
}
}
// ---- Pull remote changes since the stored cursor and apply them locally ----
async function pullSince(profileId: string): Promise<void> {
const state = await db.syncState.get(profileId);
const since = state?.lastSyncedAt ?? 0;
const res = await apiJson<PullResponse>(`/sync?since=${since}`, {
method: "GET",
});
remoteApply.active = true;
try {
await db.transaction(
"rw",
db.locations,
db.players,
db.sessions,
db.syncState,
async () => {
for (const r of res.locations)
await db.locations.put(toLocation(r, profileId));
for (const r of res.players)
await db.players.put(toPlayer(r, profileId));
for (const r of res.sessions)
await db.sessions.put(toSession(r, profileId));
await db.syncState.put({ profileId, lastSyncedAt: res.serverTime });
},
);
} finally {
remoteApply.active = false;
}
}
// ---- Public entry point ----
export async function runSync(): Promise<void> {
if (syncing) return;
if (!getAccessToken()) return; // not logged in
const profileId = useProfileStore.getState().activeProfileId;
if (!profileId) return;
syncing = true;
setStatus("syncing");
try {
await pushDirty(profileId);
await pullSince(profileId);
setStatus("idle");
} catch (err) {
console.error("[sync] failed", err);
setStatus("error");
} finally {
syncing = false;
}
}
// ---- Triggers: reconnection, tab focus, and a periodic heartbeat ----
let started = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Push shortly after any local change (coalesces bursts of edits).
function scheduleSync() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => void runSync(), 1500);
}
export function startSyncTriggers() {
if (started) return;
started = true;
localChange.notify = scheduleSync;
window.addEventListener("online", () => void runSync());
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") void runSync();
});
setInterval(() => void runSync(), 60_000);
}
+36
View File
@@ -73,17 +73,53 @@ export interface GameSession {
status: "playing" | "finished";
options: Record<string, any>;
winnerIds?: string[];
locationId?: string;
profileId: string;
updatedAt: number;
deletedAt?: number; // tombstone pour la synchronisation
dirty?: number; // 1 = modifié localement, à pousser (0/absent = synchronisé)
}
export interface AppSettings {
id: number;
theme: "light" | "dark" | "system";
language: string;
activeProfileId: string;
}
export interface SavedPlayer {
id: string;
name: string;
createdAt: number;
updatedAt: number;
avatar?: string;
profileId: string;
linkedProfileId?: string; // réservé pour la Phase 4 (lien vers un ami)
deletedAt?: number; // tombstone pour la synchronisation
dirty?: number; // 1 = modifié localement, à pousser
}
export interface Location {
id: string;
name: string;
address?: string;
createdAt: number;
updatedAt: number;
deletedAt?: number; // tombstone pour la synchronisation
profileId: string;
dirty?: number; // 1 = modifié localement, à pousser
}
export interface SyncState {
profileId: string;
lastSyncedAt: number;
}
export interface Profile {
id: string;
name: string;
avatar?: string;
createdAt: number;
updatedAt: number;
remoteUserId?: string; // réservé pour la Phase 3 (compte serveur)
}