This commit is contained in:
+17
-1
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+21
-4
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
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);
|
||||
|
||||
@@ -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";
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user