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 ( + + + + + + + + + + + + + {/* Orbit ring — back half (behind the planet) */} + + + {/* Planet body */} + + + {/* Flat two-tone shading + craters, clipped to the planet */} + + + + + + + + {/* Orbit ring — front half (in front of the planet) */} + + + {/* Sparkle */} + + + ); +} 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 ( +
+
+ {back ? ( + + ) : ( + + )} +

+ {title} +

+
+ {action} +
+ ); +} 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} + ) : ( + + )} +
+
+

+ {game.name} +

+

+ {formatDate(session.dateStart)} + {location && ( + + + {location.name} + + )} +

+
+ {playing && ( + + En cours + + )} +
+ + {!playing && ranked.length > 0 && ( + <> +
+
+ + + {winner.p.name} +
+ {winner.score} +
+ + {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 ( -

-
- -

- Amis -

-
+
+ @@ -122,13 +117,8 @@ export default function Friends() { }; return ( -
-
- -

- Amis -

-
+
+ {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 ( -

-
- -

- Historique -

-
+
+ {!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 */} -
-
- +
+ {/* Header */} +
+
+
+ +
+
+ + Skori +
-
- -
- -

- 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} - ) : ( - - )} -
-
-

- {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} + ) : ( + + )}
- - ); - })} -
+
+

+ {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 ? ( -
- {`Logo -
+ {game.name} ) : ( - <> -
-
- - + )}
- -
-

- {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 ( -
-
- -

- Jeux importés -

-
+
+

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 ( -

-
- -

- Emplacements -

-
+
+ {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 ( -
-
- -

- Joueurs -

-
+
+
{!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 ( -
-
- -

- Profils -

-
+
+

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 ( -

-
- -

- Paramètres -

-
+
+

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 ( -
-
- -

- Statistiques -

-
+
+ {/* 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: [ {