diff --git a/docs/creation-jeu.md b/docs/creation-jeu.md
new file mode 100644
index 0000000..30e9c37
--- /dev/null
+++ b/docs/creation-jeu.md
@@ -0,0 +1,372 @@
+# Créer un jeu personnalisé pour Skori
+
+Ce document décrit **tous les paramètres** d'un fichier de définition de jeu (`.json`)
+importable dans Skori via **Paramètres → Jeux → Gérer les jeux importés → Importer**.
+
+Un jeu importé ajoute une nouvelle tuile à l'écran d'accueil, avec ses propres
+règles de score, options, icône et couleurs — sans recompiler l'application.
+
+> Les règles de calcul sont **déclaratives** (décrites par des données, pas du
+> code). C'est volontaire : un fichier importé ne peut jamais exécuter de code
+> arbitraire sur votre appareil.
+
+---
+
+## 1. Structure minimale
+
+Le plus petit fichier valide :
+
+```json
+{
+ "schemaVersion": 1,
+ "id": "mon-jeu",
+ "name": "Mon Jeu",
+ "minPlayers": 2,
+ "maxPlayers": 6,
+ "scoreType": "positive",
+ "scoreFormula": { "strategy": "sum_rounds" },
+ "colors": { "primary": "#3b82f6" }
+}
+```
+
+Tout le reste est optionnel.
+
+---
+
+## 2. Champs racine
+
+| Champ | Type | Obligatoire | Description |
+|---|---|---|---|
+| `schemaVersion` | `1` | ✅ | Toujours `1` (version du format). |
+| `id` | texte | ✅ | Identifiant unique. Lettres, chiffres, `_` et `-` uniquement. **Ne peut pas** être un id natif (voir §9). Réimporter avec le même `id` **met à jour** le jeu. |
+| `name` | texte | ✅ | Nom affiché (max 60 caractères). |
+| `minPlayers` | entier | ✅ | Nombre minimum de joueurs (1 à 20). |
+| `maxPlayers` | entier | ✅ | Nombre maximum de joueurs (1 à 20). |
+| `scoreType` | énum | ✅ | Nature du score — voir §3. |
+| `scoreFormula` | objet | ✅ | Comment additionner les manches — voir §4. |
+| `colors` | objet | ✅ | Couleur(s) du thème — voir §7. |
+| `description` | texte | ❌ | Phrase affichée sur l'écran de la partie (max 400 caractères). |
+| `targetScore` | objet | ❌ | Score de fin de partie — voir §5. |
+| `targetScoreCondition` | `"reaches"` \| `"exceeds"` | ❌ | `reaches` = fin si score **≥** cible (défaut logique) ; `exceeds` = fin si score **>** cible. |
+| `fixedRounds` | entier | ❌ | La partie se termine après ce nombre de manches — voir §6. |
+| `scoringCategories` | tableau | ❌ | Catégories de score saisies une par une — voir §8. |
+| `options` | tableau | ❌ | Options configurables avant la partie — voir §6/§8. |
+| `iconImage` | texte | ❌ | Icône du jeu (image en base64 « data URI ») — voir §7. |
+| `bannerImage` | texte | ❌ | Bannière de l'écran de fin (base64 data URI) — voir §7. |
+
+---
+
+## 3. `scoreType` — nature du score
+
+Détermine **qui gagne** et la façon dont le score est interprété.
+
+| Valeur | Le gagnant est… | Exemple |
+|---|---|---|
+| `positive` | le **plus haut** score | Azul, Harmonies |
+| `negative` | le **plus bas** score | Skyjo, Cabo, Odin |
+| `target` | le plus haut (course vers un objectif) | jeux à objectif de points |
+| `fixed_rounds` | le plus haut, sur un nombre fixe de manches | — |
+| `sudden_death` | le plus haut (mort subite) | — |
+
+> En pratique, le classement de fin de partie utilise `negative` → plus petit
+> gagne, **tous les autres** → plus grand gagne. Choisissez `negative` pour un
+> jeu « le moins de points possible », sinon `positive`.
+
+---
+
+## 4. `scoreFormula` — calcul du total
+
+Comment le total d'un joueur est calculé à partir de ses scores de manche.
+
+### `sum_rounds` (par défaut)
+
+Simple addition de toutes les manches.
+
+```json
+"scoreFormula": { "strategy": "sum_rounds" }
+```
+
+### `sum_rounds_with_reset`
+
+Addition, avec **remise à un palier** quand le total atteint *exactement* un seuil
+(règle « Cabo » : tomber pile sur 100 redescend à 50).
+
+```json
+"scoreFormula": {
+ "strategy": "sum_rounds_with_reset",
+ "resetThreshold": 100,
+ "resetTo": 50,
+ "oncePerPlayer": true
+}
+```
+
+| Paramètre | Type | Description |
+|---|---|---|
+| `resetThreshold` | nombre | Si le total **égale exactement** cette valeur… |
+| `resetTo` | nombre | …il est ramené à cette valeur. |
+| `oncePerPlayer` | booléen | `true` = la remise ne peut avoir lieu qu'**une seule fois** par joueur. |
+
+---
+
+## 5. `targetScore` — fin de partie sur un score
+
+Déclenche la fin de la partie dès qu'un joueur atteint (ou dépasse, selon
+`targetScoreCondition`) une valeur cible. Combinable avec `fixedRounds`
+(la première condition atteinte l'emporte).
+
+### Cible fixe
+
+```json
+"targetScore": { "type": "fixed", "value": 100 }
+```
+
+### Cible dépendant d'une option
+
+Permet à l'utilisateur de choisir la cible avant la partie (comme Odin :
+« 15 pts / 20 pts / personnalisé »).
+
+```json
+"targetScore": {
+ "type": "fromOption",
+ "optionId": "target_score",
+ "customSentinel": "custom",
+ "customOptionId": "custom_target_score"
+}
+```
+
+| Paramètre | Description |
+|---|---|
+| `optionId` | id de l'option (`select` ou `number`) qui contient la cible. |
+| `customSentinel` | *(optionnel)* valeur de l'option signifiant « personnalisé ». |
+| `customOptionId` | *(optionnel)* id de l'option `number` lue si la valeur = `customSentinel`. |
+
+---
+
+## 6. `fixedRounds` — nombre de manches fixe
+
+```json
+"fixedRounds": 5
+```
+
+- La partie se termine automatiquement après ce nombre de manches.
+- `fixedRounds: 1` active un mode spécial **« Décompte final »** (une seule
+ saisie de scores, idéal pour un jeu où l'on ne compte qu'à la fin).
+
+---
+
+## 7. Apparence : `colors`, `iconImage`, `bannerImage`
+
+### Couleurs
+
+```json
+"colors": { "primary": "#6e1d5a", "secondary": "#c2410c" }
+```
+
+- `primary` (obligatoire) : couleur principale du thème du jeu (format hex).
+- `secondary` (optionnel) : couleur d'appoint.
+
+### Images (base64)
+
+`iconImage` et `bannerImage` sont des **data URI** : l'image encodée directement
+dans le fichier (aucun chemin externe). Formats : PNG, SVG, WebP, JPEG.
+
+```json
+"iconImage": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA…"
+```
+
+Pour convertir une image en data URI :
+- en ligne : chercher « image to base64 data uri » ;
+- ou dans un terminal :
+ - macOS/Linux : `echo "data:image/png;base64,$(base64 -w0 icone.png)"`
+ - Windows PowerShell : `"data:image/png;base64," + [Convert]::ToBase64String([IO.File]::ReadAllBytes("icone.png"))`
+
+> Gardez les images légères (idéalement < 200 Ko) : elles sont stockées telles
+> quelles dans l'appareil. Si `iconImage` est absent, une icône générique est
+> utilisée.
+
+---
+
+## 8. Options et catégories
+
+### `options` — réglages avant la partie
+
+Chaque option apparaît sur l'écran de préparation de la partie.
+
+```json
+"options": [
+ {
+ "id": "kamikaze",
+ "label": "Règle Kamikaze",
+ "type": "boolean",
+ "defaultValue": true
+ },
+ {
+ "id": "target_score",
+ "label": "Score de fin",
+ "type": "select",
+ "defaultValue": "15",
+ "options": [
+ { "label": "Courte (15)", "value": "15" },
+ { "label": "Classique (20)", "value": "20" },
+ { "label": "Personnalisé", "value": "custom" }
+ ]
+ },
+ {
+ "id": "custom_target_score",
+ "label": "Score personnalisé",
+ "type": "number",
+ "defaultValue": 25
+ }
+]
+```
+
+| Champ | Description |
+|---|---|
+| `id` | Identifiant de l'option (référencé par `targetScore.fromOption`). |
+| `label` | Libellé affiché. |
+| `type` | `boolean` (case à cocher), `number` (champ numérique), `select` (liste). |
+| `defaultValue` | Valeur par défaut. |
+| `options` | *(select uniquement)* liste de `{ label, value }`. |
+
+### `scoringCategories` — saisie par catégories
+
+Active une saisie **étape par étape** (une catégorie à la fois, comme Harmonies).
+Le score de la manche = **somme des catégories**.
+
+```json
+"scoringCategories": [
+ { "id": "arbres", "label": "Arbres" },
+ { "id": "montagnes", "label": "Montagnes" },
+ { "id": "eau", "label": "Champs d'eau" }
+]
+```
+
+> Généralement associé à `fixedRounds: 1` (un seul décompte final réparti en
+> catégories).
+
+---
+
+## 9. Contraintes & validation
+
+À l'import, le fichier est validé. En cas d'erreur, un message précis indique le
+champ fautif.
+
+- `schemaVersion` doit valoir `1`.
+- `id` : lettres/chiffres/`_`/`-`, et **pas** un id réservé :
+ `azul`, `cabo`, `skyjo`, `harmonie`, `odin_cards`.
+- `name` ≤ 60 caractères, `description` ≤ 400 caractères.
+- `minPlayers` / `maxPlayers` entre 1 et 20.
+- `scoreType` parmi les valeurs du §3.
+- `scoreFormula.strategy` parmi `sum_rounds`, `sum_rounds_with_reset`.
+- Les jeux importés sont **propres à chaque profil** (pas encore synchronisés
+ entre appareils).
+
+> **Non pris en charge en v1** : les formules 100 % personnalisées par expression
+> (`custom_expression`). Les deux stratégies ci-dessus couvrent tous les jeux
+> fournis en standard.
+
+---
+
+## 10. Exemples complets
+
+### A. Skyjo (le moins de points, fin à 100)
+
+```json
+{
+ "schemaVersion": 1,
+ "id": "mon-skyjo",
+ "name": "Skyjo maison",
+ "minPlayers": 2,
+ "maxPlayers": 8,
+ "scoreType": "negative",
+ "scoreFormula": { "strategy": "sum_rounds" },
+ "targetScore": { "type": "fixed", "value": 100 },
+ "targetScoreCondition": "reaches",
+ "description": "Le moins de points possible. Fin dès qu'un joueur atteint 100.",
+ "colors": { "primary": "#0f766e" }
+}
+```
+
+### B. Cabo (remise à 50 sur 100 pile)
+
+```json
+{
+ "schemaVersion": 1,
+ "id": "mon-cabo",
+ "name": "Cabo maison",
+ "minPlayers": 2,
+ "maxPlayers": 5,
+ "scoreType": "negative",
+ "scoreFormula": {
+ "strategy": "sum_rounds_with_reset",
+ "resetThreshold": 100,
+ "resetTo": 50,
+ "oncePerPlayer": true
+ },
+ "targetScore": { "type": "fixed", "value": 100 },
+ "targetScoreCondition": "reaches",
+ "colors": { "primary": "#6e1d5a" }
+}
+```
+
+### C. Odin (cible choisie par option)
+
+```json
+{
+ "schemaVersion": 1,
+ "id": "mon-odin",
+ "name": "Odin maison",
+ "minPlayers": 2,
+ "maxPlayers": 6,
+ "scoreType": "negative",
+ "scoreFormula": { "strategy": "sum_rounds" },
+ "targetScore": {
+ "type": "fromOption",
+ "optionId": "target_score",
+ "customSentinel": "custom",
+ "customOptionId": "custom_target_score"
+ },
+ "targetScoreCondition": "reaches",
+ "options": [
+ {
+ "id": "target_score",
+ "label": "Score de fin de partie",
+ "type": "select",
+ "defaultValue": "15",
+ "options": [
+ { "label": "Partie courte (15 pts)", "value": "15" },
+ { "label": "Partie classique (20 pts)", "value": "20" },
+ { "label": "Personnalisé", "value": "custom" }
+ ]
+ },
+ {
+ "id": "custom_target_score",
+ "label": "Score personnalisé",
+ "type": "number",
+ "defaultValue": 25
+ }
+ ],
+ "colors": { "primary": "#c2410c" }
+}
+```
+
+### D. Jeu par catégories (le plus de points, décompte final)
+
+```json
+{
+ "schemaVersion": 1,
+ "id": "mon-jeu-categories",
+ "name": "Jeu à catégories",
+ "minPlayers": 1,
+ "maxPlayers": 4,
+ "scoreType": "positive",
+ "scoreFormula": { "strategy": "sum_rounds" },
+ "fixedRounds": 1,
+ "scoringCategories": [
+ { "id": "cat1", "label": "Objectifs" },
+ { "id": "cat2", "label": "Bonus" },
+ { "id": "cat3", "label": "Pénalités" }
+ ],
+ "colors": { "primary": "#16a34a" }
+}
+```
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
index 597f744..36db9d8 100644
Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ
diff --git a/public/favicon-32.png b/public/favicon-32.png
index 1915de8..3b87244 100644
Binary files a/public/favicon-32.png and b/public/favicon-32.png differ
diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png
index 896b0ff..07c1100 100644
Binary files a/public/pwa-192x192.png and b/public/pwa-192x192.png differ
diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png
index 63e5340..f78017c 100644
Binary files a/public/pwa-512x512.png and b/public/pwa-512x512.png differ
diff --git a/public/pwa-maskable-512x512.png b/public/pwa-maskable-512x512.png
index 6b47dcb..85ce1fc 100644
Binary files a/public/pwa-maskable-512x512.png and b/public/pwa-maskable-512x512.png differ
diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx
new file mode 100644
index 0000000..0f24029
--- /dev/null
+++ b/src/components/BottomNav.tsx
@@ -0,0 +1,93 @@
+import { useLocation, useNavigate } from "react-router-dom";
+import { Home, History, BarChart2, Users2 } from "lucide-react";
+import { motion } from "framer-motion";
+import { Avatar } from "./ui/avatar";
+import { useProfileStore } from "../stores/profileStore";
+
+const TABS = [
+ { path: "/", label: "Accueil", icon: Home },
+ { path: "/history", label: "Parties", icon: History },
+ { path: "/stats", label: "Stats", icon: BarChart2 },
+ { path: "/friends", label: "Amis", icon: Users2 },
+];
+
+export function BottomNav() {
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+ const activeProfile = useProfileStore((s) => s.activeProfile);
+
+ const isActive = (path: string) =>
+ path === "/" ? pathname === "/" : pathname.startsWith(path);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/CosmicBackground.tsx b/src/components/CosmicBackground.tsx
index 3f9d8c1..8ef5910 100644
--- a/src/components/CosmicBackground.tsx
+++ b/src/components/CosmicBackground.tsx
@@ -1,25 +1,40 @@
import { motion } from "framer-motion";
+// Refined cosmic backdrop: soft aurora glows tuned to the brand, calm float,
+// coherent in light and dark. Replaces the previous loud sun/cloud blobs.
export const CosmicBackground = () => (
-
- {/* Light Mode Elements (Sun & Clouds) */}
+
+ {/* Base wash */}
+
+
+ {/* Aurora glows */}
-
-
-
+
+
- {/* Dark Mode Elements (Nebulas & Stars) */}
-
-
-
- {/* Twinkling Stars */}
-
-
-
-
+ {/* Twinkling stars (dark only) */}
+ {[
+ { top: "14%", left: "22%", d: 3, delay: 0 },
+ { top: "26%", left: "72%", d: 4, delay: 1 },
+ { top: "44%", left: "12%", d: 5, delay: 2 },
+ { top: "68%", left: "80%", d: 3.5, delay: 0.5 },
+ { top: "78%", left: "34%", d: 4.5, delay: 1.5 },
+ ].map((s, i) => (
+
+ ))}
);
diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx
new file mode 100644
index 0000000..3c140e1
--- /dev/null
+++ b/src/components/Logo.tsx
@@ -0,0 +1,67 @@
+// Skori brand mark — a flat, Kurzgesagt-style ringed planet in brand teal.
+// Geometric, two-tone flat shading, an orbit ring and a sparkle. Pure SVG so it
+// stays crisp at any size and inherits no theming surprises.
+export function PlanetMark({
+ size = 96,
+ className = "",
+}: {
+ size?: number;
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/NavigationMenu.tsx b/src/components/NavigationMenu.tsx
index feb350d..7b168ab 100644
--- a/src/components/NavigationMenu.tsx
+++ b/src/components/NavigationMenu.tsx
@@ -1,15 +1,11 @@
import { useState, useRef, useEffect } from "react";
import {
Menu,
- Home,
- History,
Settings,
Users,
- BarChart2,
MapPin,
- UserCircle,
+ Puzzle,
ChevronRight,
- Users2,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
@@ -17,6 +13,14 @@ import { Button } from "./ui/button";
import { Avatar } from "./ui/avatar";
import { useProfileStore } from "../stores/profileStore";
+// Secondary navigation (the main sections live in the bottom tab bar).
+const SECONDARY = [
+ { path: "/players", label: "Joueurs", icon: Users },
+ { path: "/locations", label: "Emplacements", icon: MapPin },
+ { path: "/games", label: "Jeux importés", icon: Puzzle },
+ { path: "/settings", label: "Paramètres", icon: Settings },
+];
+
export function NavigationMenu() {
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
@@ -78,54 +82,18 @@ export function NavigationMenu() {
-
-
-
-
-
-
-
-
+ {SECONDARY.map((item) => {
+ const Icon = item.icon;
+ return (
+
+ );
+ })}
)}
diff --git a/src/components/PageHeader.tsx b/src/components/PageHeader.tsx
new file mode 100644
index 0000000..6b5ccd4
--- /dev/null
+++ b/src/components/PageHeader.tsx
@@ -0,0 +1,40 @@
+import { ReactNode } from "react";
+import { useNavigate } from "react-router-dom";
+import { ChevronLeft } from "lucide-react";
+import { Button } from "./ui/button";
+import { NavigationMenu } from "./NavigationMenu";
+
+// Consistent sticky page header used across the main screens.
+export function PageHeader({
+ title,
+ back = false,
+ action,
+}: {
+ title: string;
+ back?: boolean;
+ action?: ReactNode;
+}) {
+ const navigate = useNavigate();
+ return (
+
+ );
+}
diff --git a/src/components/SessionCard.tsx b/src/components/SessionCard.tsx
new file mode 100644
index 0000000..f7af6dd
--- /dev/null
+++ b/src/components/SessionCard.tsx
@@ -0,0 +1,121 @@
+import { useNavigate } from "react-router-dom";
+import * as Icons from "lucide-react";
+import { Trophy, MapPin } from "lucide-react";
+import { motion } from "framer-motion";
+import { GameConfig, GameSession, Location } from "../types";
+import { calculatePlayerTotalScore } from "../utils/scoring";
+import { Avatar } from "./ui/avatar";
+
+function formatDate(ms: number) {
+ return new Date(ms).toLocaleDateString("fr-FR", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ });
+}
+
+// A finished-game "activity" card for the Home feed and History list.
+export function SessionCard({
+ session,
+ game,
+ location,
+}: {
+ session: GameSession;
+ game: GameConfig;
+ location?: Location;
+}) {
+ const navigate = useNavigate();
+ const GameIcon = (Icons as any)[game.icon || "Box"] || Icons.Box;
+ const playing = session.status === "playing";
+
+ const ranked = session.players
+ .map((p) => ({
+ p,
+ score: calculatePlayerTotalScore(p.id, session.rounds, game, true),
+ win: session.winnerIds?.includes(p.id),
+ }))
+ .sort((a, b) =>
+ game.scoreType === "negative" ? a.score - b.score : b.score - a.score,
+ );
+ const winner = ranked.find((r) => r.win) || ranked[0];
+
+ return (
+
+ navigate(playing ? `/play/${session.id}` : `/gameover/${session.id}`)
+ }
+ className="w-full text-left rounded-[1.75rem] bg-card/70 backdrop-blur-xl border border-black/5 dark:border-white/10 shadow-sm p-4 space-y-3"
+ >
+
+
+ {game.imagePath ? (
+

+ ) : (
+
+ )}
+
+
+
+ {game.name}
+
+
+ {formatDate(session.dateStart)}
+ {location && (
+
+
+ {location.name}
+
+ )}
+
+
+ {playing && (
+
+ En cours
+
+ )}
+
+
+ {!playing && ranked.length > 0 && (
+ <>
+
+
+ {ranked.length > 1 && (
+
+ {ranked
+ .filter((r) => r !== winner)
+ .map((r) => (
+
+ {r.p.name} · {r.score}
+
+ ))}
+
+ )}
+ >
+ )}
+
+ {playing && (
+
+ {session.players.map((p) => p.name).join(", ")}
+
+ )}
+
+ );
+}
diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx
index 2a301f5..ceefd72 100644
--- a/src/layouts/MainLayout.tsx
+++ b/src/layouts/MainLayout.tsx
@@ -1,13 +1,15 @@
import { Outlet } from "react-router-dom";
import { CosmicBackground } from "../components/CosmicBackground";
+import { BottomNav } from "../components/BottomNav";
export default function MainLayout() {
return (
-
+
+
);
}
diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx
index 7da14a6..6f5090c 100644
--- a/src/pages/Auth.tsx
+++ b/src/pages/Auth.tsx
@@ -3,13 +3,14 @@ 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 { LogIn, UserPlus, Loader2 } 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";
+import { PageHeader } from "../components/PageHeader";
const schema = z.object({
email: z.string().email("Email invalide"),
@@ -69,20 +70,11 @@ export default function Auth() {
};
return (
-
-
-
-
- {mode === "login" ? "Connexion" : "Créer un compte"}
-
-
+
+
{mode === "login"
diff --git a/src/pages/Friends.tsx b/src/pages/Friends.tsx
index c6b5f8e..4868cda 100644
--- a/src/pages/Friends.tsx
+++ b/src/pages/Friends.tsx
@@ -21,7 +21,7 @@ import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Avatar } from "../components/ui/avatar";
-import { NavigationMenu } from "../components/NavigationMenu";
+import { PageHeader } from "../components/PageHeader";
export default function Friends() {
const navigate = useNavigate();
@@ -42,13 +42,8 @@ export default function Friends() {
if (status !== "authenticated") {
return (
-
-
+
+
@@ -122,13 +117,8 @@ export default function Friends() {
};
return (
-
-
+
+
{error && (
diff --git a/src/pages/History.tsx b/src/pages/History.tsx
index 5638b5f..3fa6c3a 100644
--- a/src/pages/History.tsx
+++ b/src/pages/History.tsx
@@ -1,25 +1,12 @@
-import { useNavigate } from "react-router-dom";
import { useLiveQuery } from "dexie-react-hooks";
+import { History as HistoryIcon } from "lucide-react";
import { db } from "../database/db";
import { useAllGames } from "../games";
-import { Card, CardContent } from "../components/ui/card";
-import { NavigationMenu } from "../components/NavigationMenu";
+import { PageHeader } from "../components/PageHeader";
+import { SessionCard } from "../components/SessionCard";
import { useProfileStore } from "../stores/profileStore";
-import { MapPin } from "lucide-react";
-
-import { motion } from "framer-motion";
-
-function formatDate(ms: number) {
- const d = new Date(ms);
- return d.toLocaleDateString("fr-FR", {
- day: "2-digit",
- month: "short",
- year: "numeric",
- });
-}
export default function History() {
- const navigate = useNavigate();
const activeProfileId = useProfileStore((s) => s.activeProfileId);
const games = useAllGames();
const sessions = useLiveQuery(
@@ -37,99 +24,30 @@ export default function History() {
const locations = useLiveQuery(() => db.locations.toArray()) || [];
return (
-
-
+
+
{!sessions || sessions.length === 0 ? (
-
- Aucune partie enregistrée.
+
+
+
Aucune partie enregistrée.
+
Lancez une partie depuis l'accueil.
) : (
-
- {sessions.map((session, index) => {
+
+ {sessions.map((session) => {
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 (
-
-
- navigate(
- session.status === "playing"
- ? `/play/${session.id}`
- : `/gameover/${session.id}`,
- )
- }
- >
-
-
-
-
-
- {game.name}
-
-
- {session.status === "playing"
- ? "En cours"
- : "Terminée"}
-
-
-
- {formatDate(session.dateStart)}
- {session.locationId && (
-
-
- {location?.name ?? "—"}
-
- )}
-
-
- {session.players.map((p) => {
- const isWinner = session.winnerIds?.includes(p.id);
- return (
-
- {isWinner ? "🏆 " : ""}
- {p.name}
-
- );
- })}
-
-
-
-
-
-
+ session={session}
+ game={game}
+ location={location}
+ />
);
})}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 367342d..e634f5a 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -1,200 +1,237 @@
import { useNavigate } from "react-router-dom";
import { useLiveQuery } from "dexie-react-hooks";
import * as Icons from "lucide-react";
+import { Play, Trophy, Gamepad2, CalendarDays, Clock } from "lucide-react";
+import { motion } from "framer-motion";
import { useAllGames } from "../games";
import { db } from "../database/db";
-import { motion } from "framer-motion";
import { NavigationMenu } from "../components/NavigationMenu";
+import { PlanetMark } from "../components/Logo";
+import { SessionCard } from "../components/SessionCard";
+import { Avatar } from "../components/ui/avatar";
import { useProfileStore } from "../stores/profileStore";
+import { GameSession } from "../types";
-// Kurzgesagt-style Planet Logo
-const PlanetLogo = () => (
-
- {/* The Planet Body */}
-
- {/* Craters */}
-
-
-
+function StatTile({
+ icon: Icon,
+ value,
+ label,
+ tint,
+}: {
+ icon: any;
+ value: string | number;
+ label: string;
+ tint: string;
+}) {
+ return (
+
+
+
+
+
+ {value}
+
+
+ {label}
+
-
- {/* The Planet Ring */}
-
-
- {/* Center Icon */}
-
-
-);
+ );
+}
export default function Home() {
const navigate = useNavigate();
const activeProfileId = useProfileStore((s) => s.activeProfileId);
+ const activeProfile = useProfileStore((s) => s.activeProfile);
const games = useAllGames();
- // Load unfinished games for the active profile
- const activeSessions = useLiveQuery(
+ const sessions = useLiveQuery(
async () => {
if (!activeProfileId) return [];
- return db.sessions
+ const arr = await db.sessions
.where("profileId")
.equals(activeProfileId)
- .and((s) => s.status === "playing" && !s.deletedAt)
- .toArray();
+ .and((s) => !s.deletedAt)
+ .sortBy("dateStart");
+ return arr.reverse();
},
[activeProfileId],
);
+ const locations = useLiveQuery(() => db.locations.toArray()) || [];
+
+ const all = sessions || [];
+ const active = all.filter((s) => s.status === "playing");
+ const finished = all.filter((s) => s.status === "finished");
+
+ const startOfMonth = new Date(
+ new Date().getFullYear(),
+ new Date().getMonth(),
+ 1,
+ ).getTime();
+ const thisMonth = finished.filter((s) => s.dateStart >= startOfMonth).length;
+ const totalMinutes = finished.reduce(
+ (sum, s) =>
+ sum + (s.dateEnd ? Math.round((s.dateEnd - s.dateStart) / 60000) : 0),
+ 0,
+ );
+ const playTime =
+ totalMinutes >= 60
+ ? `${Math.round(totalMinutes / 60)}h`
+ : `${totalMinutes}m`;
+
+ const gameOf = (s: GameSession) => games.find((g) => g.id === s.gameId);
return (
-
- {/* Header Section */}
-
-
-
+
-
-
-
-
- Skori
-
+ {activeProfile && (
+
+ )}
- {/* Active Game Section */}
- {activeSessions && activeSessions.length > 0 && (
-
-
-
- Partie en cours
-
-
-
-
+ {/* Stat tiles */}
+
+
+
+
+
+
+ {/* Resume in-progress */}
+ {active.length > 0 && (
+
+
+
+
+
+
Reprendre
-
-
- {activeSessions.map((session) => {
- const game = games.find((g) => g.id === session.gameId);
- if (!game) return null;
- const GameIcon = game.icon
- ? (Icons as any)[game.icon]
- : Icons.Box;
-
- return (
-
- navigate(`/play/${session.id}`)}
- >
-
-
-
- {game.imagePath ? (
-

- ) : (
-
- )}
-
-
-
- {game.name}
-
-
- {session.players.map((p) => p.name).join(", ")}
-
-
-
-
-
-
-
+ {active.map((session) => {
+ const game = gameOf(session);
+ if (!game) return null;
+ const GameIcon = (Icons as any)[game.icon || "Box"] || Icons.Box;
+ return (
+
navigate(`/play/${session.id}`)}
+ className="w-full text-left rounded-[1.75rem] p-4 flex items-center justify-between shadow-lg relative overflow-hidden"
+ style={{
+ backgroundImage: `linear-gradient(120deg, ${game.colors.primary}, ${game.colors.primary}cc)`,
+ }}
+ >
+
+
+
+ {game.imagePath ? (
+

+ ) : (
+
+ )}
-
- );
- })}
-
+
+
+ {game.name}
+
+
+ {session.players.map((p) => p.name).join(", ")}
+
+
+
+
+
+ );
+ })}
)}
- {/* New Game Section */}
-
-
- Nouveau Jeu
-
-
-
- {games.map((game, index) => {
+ {/* Quick start */}
+
+ Nouvelle partie
+
+ {games.map((game) => {
const Icon = (Icons as any)[game.icon || "Box"] || Icons.Box;
return (
-
navigate(`/new/${game.id}`)}
- className="flex flex-col items-center gap-2 cursor-pointer group"
+ className="shrink-0 w-[84px] flex flex-col items-center gap-2 group"
>
{game.imagePath ? (
-
-

-
+

) : (
- <>
-
-
-
- >
+
)}
-
-
-
- {game.name}
-
-
-
+
+ {game.name}
+
+
);
})}
+
+ {/* Recent activity feed */}
+
+ Activité récente
+ {finished.length === 0 ? (
+
+
+
+ Vos parties terminées apparaîtront ici.
+
+
+ ) : (
+
+ {finished.slice(0, 8).map((session) => {
+ const game = gameOf(session);
+ if (!game) return null;
+ const location = session.locationId
+ ? locations.find((l) => l.id === session.locationId)
+ : undefined;
+ return (
+
+ );
+ })}
+
+ )}
+
);
diff --git a/src/pages/ImportedGames.tsx b/src/pages/ImportedGames.tsx
index 18fa424..8df2e51 100644
--- a/src/pages/ImportedGames.tsx
+++ b/src/pages/ImportedGames.tsx
@@ -8,7 +8,7 @@ import { parseImportedGame } from "../utils/gameImportAdapter";
import { ImportedGameDefinition } from "../types/gameImport";
import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
-import { NavigationMenu } from "../components/NavigationMenu";
+import { PageHeader } from "../components/PageHeader";
import { motion } from "framer-motion";
const TEMPLATE: ImportedGameDefinition = {
@@ -98,13 +98,8 @@ export default function ImportedGames() {
};
return (
-
-
+
+
Ajoutez de nouveaux jeux à l'app via un fichier de définition JSON
diff --git a/src/pages/Locations.tsx b/src/pages/Locations.tsx
index 8bfc119..89214c4 100644
--- a/src/pages/Locations.tsx
+++ b/src/pages/Locations.tsx
@@ -5,7 +5,7 @@ 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 { PageHeader } from "../components/PageHeader";
import { generateId } from "../utils/id";
import { useProfileStore } from "../stores/profileStore";
import { motion } from "framer-motion";
@@ -75,13 +75,8 @@ export default function Locations() {
};
return (
-
-
+
+
{isAdding ? (
diff --git a/src/pages/Players.tsx b/src/pages/Players.tsx
index 52c14a0..9d2bda7 100644
--- a/src/pages/Players.tsx
+++ b/src/pages/Players.tsx
@@ -15,7 +15,7 @@ import {
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 { PageHeader } from "../components/PageHeader";
import { Avatar } from "../components/ui/avatar";
import { resizeImage } from "../utils/image";
import { motion } from "framer-motion";
@@ -163,13 +163,8 @@ export default function Players() {
};
return (
-
-
+
+
{!players || players.length === 0 ? (
diff --git a/src/pages/Profiles.tsx b/src/pages/Profiles.tsx
index 90cd986..5d1dc9f 100644
--- a/src/pages/Profiles.tsx
+++ b/src/pages/Profiles.tsx
@@ -15,7 +15,7 @@ import {
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 { PageHeader } from "../components/PageHeader";
import { Avatar } from "../components/ui/avatar";
import { resizeImage } from "../utils/image";
import { motion } from "framer-motion";
@@ -103,13 +103,8 @@ export default function Profiles() {
};
return (
-
-
+
+
Chaque profil possède ses propres parties, joueurs et emplacements.
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 4944df2..c8d32d7 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -25,7 +25,7 @@ import { getGameConfig } from "../games";
import { calculatePlayerTotalScore } from "../utils/scoring";
import { Card, CardContent } from "../components/ui/card";
import { Button } from "../components/ui/button";
-import { NavigationMenu } from "../components/NavigationMenu";
+import { PageHeader } from "../components/PageHeader";
export default function Settings() {
const { theme, setTheme } = useAppStore();
@@ -237,13 +237,8 @@ export default function Settings() {
};
return (
-
-
+
+
Compte
diff --git a/src/pages/Stats/index.tsx b/src/pages/Stats/index.tsx
index a5479ec..666f002 100644
--- a/src/pages/Stats/index.tsx
+++ b/src/pages/Stats/index.tsx
@@ -5,7 +5,7 @@ import { useProfileStore } from "../../stores/profileStore";
import { useAllGames } from "../../games";
import { Card, CardContent } from "../../components/ui/card";
import { Badge } from "../../components/ui/badge";
-import { NavigationMenu } from "../../components/NavigationMenu";
+import { PageHeader } from "../../components/PageHeader";
import { Trophy, Clock, Gamepad2, Medal, Filter } from "lucide-react";
import { Avatar } from "../../components/ui/avatar";
import { motion } from "framer-motion";
@@ -201,13 +201,8 @@ export default function Statistics() {
: leaderboard;
return (
-
-
+
+
{/* Filters */}
@@ -261,27 +256,31 @@ export default function Statistics() {
{/* Hero Stats */}
-
-
-
-
- {totalGames}
- Parties jouées
-
-
+
+
+
+
+
+
+ {totalGames}
+
+
+ Parties jouées
+
+
-
-
-
-
- {averageDuration}
- m
-
-
- Durée moyenne
-
-
-
+
+
+
+
+
+ {averageDuration}
+ m
+
+
+ Durée moyenne
+
+
{favoriteGame && selectedGameId === "all" && (
@@ -364,62 +363,69 @@ export default function Statistics() {
)}
- {/* Leaderboard */}
+ {/* Leaderboard — winrate as horizontal bars (single hue) */}
-
+
- Classement des joueurs (Taux de victoire)
+ Classement (taux de victoire)
-
- {displayLeaderboard.length === 0 ? (
-
+ {displayLeaderboard.length === 0 ? (
+
+
+
{allSessions.length === 0
? "Terminez des parties pour voir le classement."
: "Aucune partie ne correspond à ces filtres."}
-
- ) : (
- displayLeaderboard.map((player, index) => (
+
+
+ ) : (
+
+ {displayLeaderboard.map((player, index) => (
-
-
-
- #{index + 1}
-
-
-
-
-
-
{player.name}
-
- {player.won} victoire{player.won > 1 ? "s" : ""} sur{" "}
- {player.played} partie{player.played > 1 ? "s" : ""}
-
-
-
-
-
- {player.winrate}%
-
-
-
-
-
+
+
+ {index + 1}
+
+
+
{player.name}
+
+ {player.winrate}%
+
+
+ {/* Bar: thin track + rounded teal fill anchored to the baseline */}
+
+
+
+
+ {player.won} victoire{player.won > 1 ? "s" : ""} · {player.played} partie
+ {player.played > 1 ? "s" : ""}
+
- ))
- )}
-
+ ))}
+
+ )}
);
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 5da7d8b..904c258 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -4,65 +4,70 @@
@layer base {
:root {
- --background: 0 0% 100%;
- --foreground: 222.2 84% 4.9%;
+ /* Slightly cool off-white so translucent cards read cleanly */
+ --background: 200 30% 98%;
+ --foreground: 210 30% 12%;
--card: 0 0% 100%;
- --card-foreground: 222.2 84% 4.9%;
+ --card-foreground: 210 30% 12%;
--popover: 0 0% 100%;
- --popover-foreground: 222.2 84% 4.9%;
+ --popover-foreground: 210 30% 12%;
- --primary: 222.2 47.4% 11.2%;
- --primary-foreground: 210 40% 98%;
+ /* Brand accent (teal) — used everywhere except during a game, where the
+ per-game colour overrides --primary. */
+ --primary: 174 72% 30%;
+ --primary-foreground: 0 0% 100%;
- --secondary: 210 40% 96.1%;
- --secondary-foreground: 222.2 47.4% 11.2%;
+ --secondary: 200 24% 94%;
+ --secondary-foreground: 210 30% 20%;
- --muted: 210 40% 96.1%;
- --muted-foreground: 215.4 16.3% 46.9%;
+ --muted: 200 24% 94%;
+ --muted-foreground: 210 12% 42%;
- --accent: 210 40% 96.1%;
- --accent-foreground: 222.2 47.4% 11.2%;
+ --accent: 174 60% 94%;
+ --accent-foreground: 174 72% 24%;
- --destructive: 0 84.2% 60.2%;
- --destructive-foreground: 210 40% 98%;
+ --destructive: 0 78% 58%;
+ --destructive-foreground: 0 0% 100%;
- --border: 214.3 31.8% 91.4%;
- --input: 214.3 31.8% 91.4%;
- --ring: 222.2 84% 4.9%;
+ --border: 210 20% 90%;
+ --input: 210 20% 90%;
+ --ring: 174 72% 30%;
- --radius: 0.5rem;
+ --radius: 1rem;
}
.dark {
- --background: 222.2 84% 4.9%;
- --foreground: 210 40% 98%;
+ /* Deep cosmic navy */
+ --background: 222 40% 8%;
+ --foreground: 210 30% 96%;
- --card: 222.2 84% 4.9%;
- --card-foreground: 210 40% 98%;
+ /* Elevated surface, distinctly lighter than the background */
+ --card: 222 32% 13%;
+ --card-foreground: 210 30% 96%;
- --popover: 222.2 84% 4.9%;
- --popover-foreground: 210 40% 98%;
+ --popover: 222 32% 13%;
+ --popover-foreground: 210 30% 96%;
- --primary: 210 40% 98%;
- --primary-foreground: 222.2 47.4% 11.2%;
+ --primary: 172 62% 44%;
+ --primary-foreground: 200 40% 8%;
- --secondary: 217.2 32.6% 17.5%;
- --secondary-foreground: 210 40% 98%;
+ --secondary: 220 24% 20%;
+ --secondary-foreground: 210 30% 96%;
- --muted: 217.2 32.6% 17.5%;
- --muted-foreground: 215 20.2% 65.1%;
+ --muted: 220 24% 18%;
+ --muted-foreground: 214 15% 65%;
- --accent: 217.2 32.6% 17.5%;
- --accent-foreground: 210 40% 98%;
+ --accent: 172 40% 22%;
+ --accent-foreground: 172 62% 70%;
- --destructive: 0 62.8% 30.6%;
- --destructive-foreground: 210 40% 98%;
+ --destructive: 0 62% 45%;
+ --destructive-foreground: 0 0% 100%;
- --border: 217.2 32.6% 17.5%;
- --input: 217.2 32.6% 17.5%;
- --ring: 212.7 26.8% 83.9%;
+ --border: 220 22% 22%;
+ --input: 220 22% 24%;
+ --ring: 172 62% 44%;
}
}
diff --git a/vite.config.ts b/vite.config.ts
index aed96bf..fc2af35 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -20,7 +20,7 @@ export default defineConfig({
display: "standalone",
orientation: "portrait",
theme_color: "#0f766e",
- background_color: "#ffffff",
+ background_color: "#0b1120",
categories: ["games", "utilities"],
icons: [
{