diff --git a/docs/creation-jeu.md b/docs/creation-jeu.md index 30e9c37..e3ddcae 100644 --- a/docs/creation-jeu.md +++ b/docs/creation-jeu.md @@ -51,7 +51,8 @@ Tout le reste est optionnel. | `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. | +| `icon` | texte | ❌ | Nom d'icône **lucide** (ex: `TreePine`, `Ghost`, `Sword`) utilisée si aucune `iconImage`. Voir les noms sur lucide.dev/icons. | +| `iconImage` | texte | ❌ | Icône du jeu (image en base64 « data URI »). Prioritaire sur `icon` — voir §7. | | `bannerImage` | texte | ❌ | Bannière de l'écran de fin (base64 data URI) — voir §7. | --- @@ -235,12 +236,19 @@ 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" } + { "id": "arbres", "label": "Arbres", "icon": "TreePine", "color": "#16a34a" }, + { "id": "montagnes", "label": "Montagnes", "icon": "Mountain", "color": "#64748b" }, + { "id": "eau", "label": "Rivière", "icon": "Waves", "color": "#0ea5e9" } ] ``` +| Champ | Description | +|---|---| +| `id` | Identifiant de la catégorie. | +| `label` | Libellé affiché. | +| `icon` | *(optionnel)* nom d'icône **lucide** affichée à côté de la catégorie. | +| `color` | *(optionnel)* teinte hex de l'icône. | + > Généralement associé à `fixedRounds: 1` (un seul décompte final réparti en > catégories). diff --git a/public/game-templates/azul.json b/public/game-templates/azul.json new file mode 100644 index 0000000..1ecb237 --- /dev/null +++ b/public/game-templates/azul.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "id": "azul", + "name": "Azul", + "minPlayers": 2, + "maxPlayers": 4, + "scoreType": "positive", + "scoreFormula": { "strategy": "sum_rounds" }, + "description": "Placez des tuiles pour marquer un maximum de points.", + "icon": "Palette", + "colors": { "primary": "#005b8f" }, + "options": [ + { + "id": "variants", + "label": "Variante plateau gris", + "type": "boolean", + "defaultValue": false + } + ] +} diff --git a/public/game-templates/cabo.json b/public/game-templates/cabo.json new file mode 100644 index 0000000..b4b3d85 --- /dev/null +++ b/public/game-templates/cabo.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "id": "cabo", + "name": "Cabo", + "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", + "description": "Trouvez la licorne. La partie se termine si on atteint ou dépasse 100 points. Tomber pile sur 100 fait redescendre le score à 50 (une fois par joueur).", + "icon": "Ghost", + "colors": { "primary": "#6e1d5a" }, + "options": [ + { + "id": "kamikaze", + "label": "Règle Kamikaze (2 cartes 13 + 2 cartes 12)", + "type": "boolean", + "defaultValue": true + } + ] +} diff --git a/public/game-templates/harmonies.json b/public/game-templates/harmonies.json new file mode 100644 index 0000000..168480a --- /dev/null +++ b/public/game-templates/harmonies.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "id": "harmonie", + "name": "Harmonies", + "minPlayers": 1, + "maxPlayers": 4, + "scoreType": "positive", + "scoreFormula": { "strategy": "sum_rounds" }, + "fixedRounds": 1, + "description": "Créez des paysages pour accueillir des animaux.", + "icon": "TreePine", + "colors": { "primary": "#065f46" }, + "scoringCategories": [ + { "id": "arbres", "label": "Arbres", "icon": "TreePine", "color": "#16a34a" }, + { "id": "montagnes", "label": "Montagnes", "icon": "Mountain", "color": "#64748b" }, + { "id": "champs", "label": "Champs", "icon": "Wheat", "color": "#d97706" }, + { "id": "batiments", "label": "Bâtiments", "icon": "Building2", "color": "#dc2626" }, + { "id": "eau", "label": "Rivière", "icon": "Waves", "color": "#0ea5e9" }, + { "id": "animaux", "label": "Animaux", "icon": "Rabbit", "color": "#a16207" } + ] +} diff --git a/public/game-templates/odin.json b/public/game-templates/odin.json new file mode 100644 index 0000000..d362f8e --- /dev/null +++ b/public/game-templates/odin.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "id": "odin_cards", + "name": "Odin", + "minPlayers": 2, + "maxPlayers": 6, + "scoreType": "negative", + "scoreFormula": { "strategy": "sum_rounds" }, + "targetScore": { + "type": "fromOption", + "optionId": "target_score", + "customSentinel": "custom", + "customOptionId": "custom_target_score" + }, + "targetScoreCondition": "reaches", + "description": "Videz votre main le plus vite possible. Un point par carte restante. La partie s'arrête quand le score cible est atteint, le plus petit score gagne.", + "icon": "Sword", + "colors": { "primary": "#c2410c" }, + "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 + } + ] +} diff --git a/public/game-templates/skyjo.json b/public/game-templates/skyjo.json new file mode 100644 index 0000000..0db7fa0 --- /dev/null +++ b/public/game-templates/skyjo.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "skyjo", + "name": "Skyjo", + "minPlayers": 2, + "maxPlayers": 8, + "scoreType": "negative", + "scoreFormula": { "strategy": "sum_rounds" }, + "targetScore": { "type": "fixed", "value": 100 }, + "targetScoreCondition": "reaches", + "description": "Remplacez vos cartes pour avoir le moins de points possible. La partie s'arrête quand un joueur dépasse 100 points.", + "icon": "Layers", + "colors": { "primary": "#0f766e" } +} diff --git a/src/App.tsx b/src/App.tsx index 0009df8..e94951b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import Players from "./pages/Players"; import Locations from "./pages/Locations"; import Profiles from "./pages/Profiles"; import Friends from "./pages/Friends"; +import Games from "./pages/Games"; import ImportedGames from "./pages/ImportedGames"; import Auth from "./pages/Auth"; import Settings from "./pages/Settings"; @@ -42,6 +43,7 @@ function App() { }> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index 0f24029..c637142 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -1,20 +1,18 @@ import { useLocation, useNavigate } from "react-router-dom"; -import { Home, History, BarChart2, Users2 } from "lucide-react"; +import { Home, Gamepad2, History, BarChart2, Users } 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: "/jeux", label: "Mes jeux", icon: Gamepad2 }, { path: "/history", label: "Parties", icon: History }, { path: "/stats", label: "Stats", icon: BarChart2 }, - { path: "/friends", label: "Amis", icon: Users2 }, + { path: "/players", label: "Joueurs", icon: Users }, ]; export function BottomNav() { const navigate = useNavigate(); const { pathname } = useLocation(); - const activeProfile = useProfileStore((s) => s.activeProfile); const isActive = (path: string) => path === "/" ? pathname === "/" : pathname.startsWith(path); @@ -55,37 +53,6 @@ export function BottomNav() { ); })} - - {/* Profile tab */} - diff --git a/src/components/NavigationMenu.tsx b/src/components/NavigationMenu.tsx deleted file mode 100644 index 7b168ab..0000000 --- a/src/components/NavigationMenu.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useState, useRef, useEffect } from "react"; -import { - Menu, - Settings, - Users, - MapPin, - Puzzle, - 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"; - -// 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(); - const menuRef = useRef(null); - const activeProfile = useProfileStore((s) => s.activeProfile); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - const nav = (path: string) => { - setIsOpen(false); - navigate(path); - }; - - return ( -
- - - {isOpen && ( - -
- - {SECONDARY.map((item) => { - const Icon = item.icon; - return ( - - ); - })} -
-
- )} -
-
- ); -} diff --git a/src/components/PageHeader.tsx b/src/components/PageHeader.tsx index 6b5ccd4..1e28e6f 100644 --- a/src/components/PageHeader.tsx +++ b/src/components/PageHeader.tsx @@ -1,23 +1,22 @@ -import { ReactNode } from "react"; import { useNavigate } from "react-router-dom"; import { ChevronLeft } from "lucide-react"; import { Button } from "./ui/button"; -import { NavigationMenu } from "./NavigationMenu"; +import { PlanetMark } from "./Logo"; +import { ProfileMenu } from "./ProfileMenu"; -// Consistent sticky page header used across the main screens. +// Consistent sticky page header: brand logo (or back) + title on the left, +// the profile/settings menu on the right. 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/ProfileMenu.tsx b/src/components/ProfileMenu.tsx new file mode 100644 index 0000000..cf9e112 --- /dev/null +++ b/src/components/ProfileMenu.tsx @@ -0,0 +1,99 @@ +import { useState, useRef, useEffect } from "react"; +import { MapPin, Puzzle, Settings, ChevronRight } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { motion, AnimatePresence } from "framer-motion"; +import { Avatar } from "./ui/avatar"; +import { useProfileStore } from "../stores/profileStore"; + +// Secondary destinations (previously in the hamburger menu). +const SECONDARY = [ + { path: "/locations", label: "Emplacements", icon: MapPin }, + { path: "/games", label: "Jeux importés", icon: Puzzle }, + { path: "/settings", label: "Paramètres", icon: Settings }, +]; + +// Top-right avatar that opens the profile + secondary menu. +export function ProfileMenu() { + const [isOpen, setIsOpen] = useState(false); + const navigate = useNavigate(); + const ref = useRef(null); + const activeProfile = useProfileStore((s) => s.activeProfile); + + useEffect(() => { + const onClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, []); + + const go = (path: string) => { + setIsOpen(false); + navigate(path); + }; + + return ( +
+ + + + {isOpen && ( + + + + {SECONDARY.map((item) => { + const Icon = item.icon; + return ( + + ); + })} + + )} + +
+ ); +} diff --git a/src/pages/Games.tsx b/src/pages/Games.tsx new file mode 100644 index 0000000..4fcc558 --- /dev/null +++ b/src/pages/Games.tsx @@ -0,0 +1,78 @@ +import { useNavigate } from "react-router-dom"; +import * as Icons from "lucide-react"; +import { Puzzle } from "lucide-react"; +import { motion } from "framer-motion"; +import { useAllGames } from "../games"; +import { PageHeader } from "../components/PageHeader"; + +// "Mes jeux" — game library: all games (built-in + imported) to start a game. +export default function Games() { + const navigate = useNavigate(); + const games = useAllGames(); + + return ( +
+ + +
+ {games.map((game, index) => { + const Icon = (Icons as any)[game.icon || "Box"] || Icons.Box; + return ( + navigate(`/new/${game.id}`)} + className="flex flex-col items-center gap-2 group" + > +
+ {game.imagePath ? ( + {game.name} + ) : ( + <> +
+ + + )} +
+ + {game.name} + + + ); + })} +
+ + {/* Link to manage imported games */} + +
+ ); +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index e634f5a..a54a569 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -5,10 +5,9 @@ import { Play, Trophy, Gamepad2, CalendarDays, Clock } from "lucide-react"; import { motion } from "framer-motion"; import { useAllGames } from "../games"; import { db } from "../database/db"; -import { NavigationMenu } from "../components/NavigationMenu"; import { PlanetMark } from "../components/Logo"; +import { ProfileMenu } from "../components/ProfileMenu"; import { SessionCard } from "../components/SessionCard"; -import { Avatar } from "../components/ui/avatar"; import { useProfileStore } from "../stores/profileStore"; import { GameSession } from "../types"; @@ -44,7 +43,6 @@ function StatTile({ export default function Home() { const navigate = useNavigate(); const activeProfileId = useProfileStore((s) => s.activeProfileId); - const activeProfile = useProfileStore((s) => s.activeProfile); const games = useAllGames(); const sessions = useLiveQuery( @@ -88,28 +86,11 @@ export default function Home() {
{/* Header */}
-
-
- -
-
- - Skori -
+
+ + Skori
- {activeProfile && ( - - )} +
{/* Stat tiles */} diff --git a/src/pages/NewGame.tsx b/src/pages/NewGame.tsx index 28c3a33..4b89401 100644 --- a/src/pages/NewGame.tsx +++ b/src/pages/NewGame.tsx @@ -25,7 +25,7 @@ import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Card, CardContent } from "../components/ui/card"; import { Avatar } from "../components/ui/avatar"; -import { NavigationMenu } from "../components/NavigationMenu"; +import { PlanetMark } from "../components/Logo"; import { generateId } from "../utils/id"; function DraggablePlayerItem({ @@ -263,8 +263,14 @@ export default function NewGame() {
{/* Floating Top Bar (Header replacement) */}
-
- +
+
diff --git a/src/types/gameImport.ts b/src/types/gameImport.ts index 6cd93a2..d3e12f9 100644 --- a/src/types/gameImport.ts +++ b/src/types/gameImport.ts @@ -37,6 +37,7 @@ export interface ImportedGameDefinition { options?: GameOption[]; description?: string; colors: { primary: string; secondary?: string }; + icon?: string; // nom d'icône lucide (utilisé si aucune iconImage n'est fournie) iconImage?: string; // data URI (png/svg/webp) -> imagePath bannerImage?: string; // data URI -> bannerPath } diff --git a/src/utils/gameImportAdapter.ts b/src/utils/gameImportAdapter.ts index 8034f63..443d430 100644 --- a/src/utils/gameImportAdapter.ts +++ b/src/utils/gameImportAdapter.ts @@ -5,6 +5,8 @@ import { ImportedGameDefinition, RESERVED_GAME_IDS } from "../types/gameImport"; const scoringCategorySchema = z.object({ id: z.string(), label: z.string(), + icon: z.string().max(40).optional(), + color: z.string().max(30).optional(), }); const gameOptionSchema = z.object({ @@ -67,6 +69,7 @@ export const importedGameSchema = z.object({ primary: z.string(), secondary: z.string().optional(), }), + icon: z.string().max(40).optional(), iconImage: z.string().optional(), bannerImage: z.string().optional(), }); @@ -104,6 +107,7 @@ export function toGameConfig(def: ImportedGameDefinition): GameConfig { options: def.options, description: def.description, colors: def.colors, + icon: def.icon, imagePath: def.iconImage, bannerPath: def.bannerImage, };