Navigation par barre du bas + gabarits de jeux natifs
Build and Publish Docker Image / build-and-push-image (push) Successful in 1m0s
Build and Publish Docker Image / build-and-push-image (push) Successful in 1m0s
Navigation : - Suppression du menu hamburger, remplacé par le logo de l'app dans l'en-tête - Barre du bas à 5 onglets : Accueil, Mes jeux, Parties, Stats, Joueurs - Menu profil sur l'avatar (haut-droite) : profil/switch, Emplacements, Jeux importés, Paramètres - Nouvelle page "Mes jeux" (/jeux) : bibliothèque de tous les jeux Format d'import enrichi + gabarits : - Champ icon (lucide) au niveau du jeu, et icon/color sur les catégories - Gabarits JSON des 5 jeux natifs dans public/game-templates/ (référence, au format d'import) + doc à jour Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+12
-4
@@ -51,7 +51,8 @@ Tout le reste est optionnel.
|
|||||||
| `fixedRounds` | entier | ❌ | La partie se termine après ce nombre de manches — voir §6. |
|
| `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. |
|
| `scoringCategories` | tableau | ❌ | Catégories de score saisies une par une — voir §8. |
|
||||||
| `options` | tableau | ❌ | Options configurables avant la partie — voir §6/§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. |
|
| `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
|
```json
|
||||||
"scoringCategories": [
|
"scoringCategories": [
|
||||||
{ "id": "arbres", "label": "Arbres" },
|
{ "id": "arbres", "label": "Arbres", "icon": "TreePine", "color": "#16a34a" },
|
||||||
{ "id": "montagnes", "label": "Montagnes" },
|
{ "id": "montagnes", "label": "Montagnes", "icon": "Mountain", "color": "#64748b" },
|
||||||
{ "id": "eau", "label": "Champs d'eau" }
|
{ "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
|
> Généralement associé à `fixedRounds: 1` (un seul décompte final réparti en
|
||||||
> catégories).
|
> catégories).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import Players from "./pages/Players";
|
|||||||
import Locations from "./pages/Locations";
|
import Locations from "./pages/Locations";
|
||||||
import Profiles from "./pages/Profiles";
|
import Profiles from "./pages/Profiles";
|
||||||
import Friends from "./pages/Friends";
|
import Friends from "./pages/Friends";
|
||||||
|
import Games from "./pages/Games";
|
||||||
import ImportedGames from "./pages/ImportedGames";
|
import ImportedGames from "./pages/ImportedGames";
|
||||||
import Auth from "./pages/Auth";
|
import Auth from "./pages/Auth";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
@@ -42,6 +43,7 @@ function App() {
|
|||||||
<Route element={<MainLayout />}>
|
<Route element={<MainLayout />}>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/history" element={<History />} />
|
<Route path="/history" element={<History />} />
|
||||||
|
<Route path="/jeux" element={<Games />} />
|
||||||
<Route path="/players" element={<Players />} />
|
<Route path="/players" element={<Players />} />
|
||||||
<Route path="/locations" element={<Locations />} />
|
<Route path="/locations" element={<Locations />} />
|
||||||
<Route path="/profiles" element={<Profiles />} />
|
<Route path="/profiles" element={<Profiles />} />
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { useLocation, useNavigate } from "react-router-dom";
|
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 { motion } from "framer-motion";
|
||||||
import { Avatar } from "./ui/avatar";
|
|
||||||
import { useProfileStore } from "../stores/profileStore";
|
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ path: "/", label: "Accueil", icon: Home },
|
{ path: "/", label: "Accueil", icon: Home },
|
||||||
|
{ path: "/jeux", label: "Mes jeux", icon: Gamepad2 },
|
||||||
{ path: "/history", label: "Parties", icon: History },
|
{ path: "/history", label: "Parties", icon: History },
|
||||||
{ path: "/stats", label: "Stats", icon: BarChart2 },
|
{ path: "/stats", label: "Stats", icon: BarChart2 },
|
||||||
{ path: "/friends", label: "Amis", icon: Users2 },
|
{ path: "/players", label: "Joueurs", icon: Users },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function BottomNav() {
|
export function BottomNav() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const activeProfile = useProfileStore((s) => s.activeProfile);
|
|
||||||
|
|
||||||
const isActive = (path: string) =>
|
const isActive = (path: string) =>
|
||||||
path === "/" ? pathname === "/" : pathname.startsWith(path);
|
path === "/" ? pathname === "/" : pathname.startsWith(path);
|
||||||
@@ -55,37 +53,6 @@ export function BottomNav() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Profile tab */}
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/profiles")}
|
|
||||||
className="relative flex flex-col items-center gap-0.5 px-3 py-1.5 rounded-2xl"
|
|
||||||
>
|
|
||||||
{isActive("/profiles") && (
|
|
||||||
<motion.span
|
|
||||||
layoutId="tab-pill"
|
|
||||||
className="absolute inset-0 bg-primary/10 dark:bg-primary/20 rounded-2xl"
|
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 32 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="relative z-10">
|
|
||||||
<Avatar
|
|
||||||
src={activeProfile?.avatar}
|
|
||||||
name={activeProfile?.name || "?"}
|
|
||||||
size="sm"
|
|
||||||
className={`w-[22px] h-[22px] ${
|
|
||||||
isActive("/profiles") ? "ring-2 ring-primary" : ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`text-[10px] font-bold relative z-10 transition-colors ${
|
|
||||||
isActive("/profiles") ? "text-primary" : "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Profil
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 (
|
|
||||||
<div ref={menuRef} className="relative z-50">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="rounded-full hover:bg-black/10 dark:hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<Menu className="w-6 h-6" />
|
|
||||||
</Button>
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, transformOrigin: "top left" }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
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>
|
|
||||||
{SECONDARY.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.path}
|
|
||||||
onClick={() => nav(item.path)}
|
|
||||||
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
|
||||||
>
|
|
||||||
<Icon className="w-5 h-5 mr-3 text-primary" /> {item.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
import { ReactNode } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ChevronLeft } from "lucide-react";
|
import { ChevronLeft } from "lucide-react";
|
||||||
import { Button } from "./ui/button";
|
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({
|
export function PageHeader({
|
||||||
title,
|
title,
|
||||||
back = false,
|
back = false,
|
||||||
action,
|
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
back?: boolean;
|
back?: boolean;
|
||||||
action?: ReactNode;
|
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-4 z-40 flex items-center justify-between gap-2 mb-2">
|
<header className="sticky top-4 z-40 flex items-center justify-between gap-2 mb-2">
|
||||||
<div className="flex items-center bg-card/60 backdrop-blur-xl rounded-full shadow-sm p-0.5 pr-5 border border-black/5 dark:border-white/10">
|
<div className="flex items-center bg-card/60 backdrop-blur-xl rounded-full shadow-sm p-1 pr-5 border border-black/5 dark:border-white/10">
|
||||||
{back ? (
|
{back ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -28,13 +27,19 @@ export function PageHeader({
|
|||||||
<ChevronLeft className="w-6 h-6" />
|
<ChevronLeft className="w-6 h-6" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<NavigationMenu />
|
<button
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
className="active:scale-95 transition-transform shrink-0"
|
||||||
|
aria-label="Accueil"
|
||||||
|
>
|
||||||
|
<PlanetMark size={30} />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
<h1 className="text-xl font-black tracking-tighter ml-1.5 drop-shadow-sm">
|
<h1 className="text-xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{action}
|
<ProfileMenu />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div ref={ref} className="relative z-50">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
className="active:scale-95 transition-transform rounded-full"
|
||||||
|
aria-label="Profil et réglages"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={activeProfile?.avatar}
|
||||||
|
name={activeProfile?.name || "?"}
|
||||||
|
size="md"
|
||||||
|
className="ring-2 ring-primary/20"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, transformOrigin: "top right" }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="absolute top-full right-0 mt-2 w-60 bg-card border shadow-xl rounded-2xl overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => go("/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>
|
||||||
|
|
||||||
|
{SECONDARY.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => go(item.path)}
|
||||||
|
className="flex items-center w-full p-4 hover:bg-secondary text-left font-medium transition-colors border-t border-border/50"
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 mr-3 text-primary" /> {item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="p-5 space-y-5 relative z-10">
|
||||||
|
<PageHeader title="Mes jeux" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3.5">
|
||||||
|
{games.map((game, index) => {
|
||||||
|
const Icon = (Icons as any)[game.icon || "Box"] || Icons.Box;
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={game.id}
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{
|
||||||
|
delay: index * 0.04,
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 200,
|
||||||
|
damping: 22,
|
||||||
|
}}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={() => navigate(`/new/${game.id}`)}
|
||||||
|
className="flex flex-col items-center gap-2 group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full aspect-square rounded-[1.5rem] shadow-md flex items-center justify-center overflow-hidden ring-1 ring-black/5 dark:ring-white/10 transition-shadow group-hover:shadow-xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: game.imagePath
|
||||||
|
? "transparent"
|
||||||
|
: game.colors.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{game.imagePath ? (
|
||||||
|
<img
|
||||||
|
src={game.imagePath}
|
||||||
|
alt={game.name}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="absolute -top-3 -right-3 w-14 h-14 rounded-full bg-white/10" />
|
||||||
|
<Icon
|
||||||
|
className="w-11 h-11 text-white drop-shadow-md relative z-10"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold leading-tight truncate max-w-full px-0.5 text-foreground/90">
|
||||||
|
{game.name}
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link to manage imported games */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/games")}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-4 rounded-[1.5rem] bg-card/60 backdrop-blur-xl border border-black/5 dark:border-white/10 shadow-sm text-sm font-bold text-muted-foreground active:scale-[0.99] transition-transform"
|
||||||
|
>
|
||||||
|
<Puzzle className="w-4 h-4 text-primary" />
|
||||||
|
Importer / gérer mes jeux
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
-23
@@ -5,10 +5,9 @@ import { Play, Trophy, Gamepad2, CalendarDays, Clock } from "lucide-react";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useAllGames } from "../games";
|
import { useAllGames } from "../games";
|
||||||
import { db } from "../database/db";
|
import { db } from "../database/db";
|
||||||
import { NavigationMenu } from "../components/NavigationMenu";
|
|
||||||
import { PlanetMark } from "../components/Logo";
|
import { PlanetMark } from "../components/Logo";
|
||||||
|
import { ProfileMenu } from "../components/ProfileMenu";
|
||||||
import { SessionCard } from "../components/SessionCard";
|
import { SessionCard } from "../components/SessionCard";
|
||||||
import { Avatar } from "../components/ui/avatar";
|
|
||||||
import { useProfileStore } from "../stores/profileStore";
|
import { useProfileStore } from "../stores/profileStore";
|
||||||
import { GameSession } from "../types";
|
import { GameSession } from "../types";
|
||||||
|
|
||||||
@@ -44,7 +43,6 @@ function StatTile({
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
const activeProfileId = useProfileStore((s) => s.activeProfileId);
|
||||||
const activeProfile = useProfileStore((s) => s.activeProfile);
|
|
||||||
const games = useAllGames();
|
const games = useAllGames();
|
||||||
|
|
||||||
const sessions = useLiveQuery(
|
const sessions = useLiveQuery(
|
||||||
@@ -88,28 +86,11 @@ export default function Home() {
|
|||||||
<div className="relative z-10 px-5 pt-5 pb-28 space-y-6">
|
<div className="relative z-10 px-5 pt-5 pb-28 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="bg-card/60 backdrop-blur-xl rounded-full p-0.5 border border-black/5 dark:border-white/10 shadow-sm">
|
|
||||||
<NavigationMenu />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PlanetMark size={30} />
|
<PlanetMark size={34} />
|
||||||
<span className="text-xl font-black tracking-tighter">Skori</span>
|
<span className="text-2xl font-black tracking-tighter">Skori</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ProfileMenu />
|
||||||
{activeProfile && (
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/profiles")}
|
|
||||||
className="active:scale-95 transition-transform"
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
src={activeProfile.avatar}
|
|
||||||
name={activeProfile.name}
|
|
||||||
size="md"
|
|
||||||
className="ring-2 ring-primary/20"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Stat tiles */}
|
{/* Stat tiles */}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { Button } from "../components/ui/button";
|
|||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Card, CardContent } from "../components/ui/card";
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
import { Avatar } from "../components/ui/avatar";
|
import { Avatar } from "../components/ui/avatar";
|
||||||
import { NavigationMenu } from "../components/NavigationMenu";
|
import { PlanetMark } from "../components/Logo";
|
||||||
import { generateId } from "../utils/id";
|
import { generateId } from "../utils/id";
|
||||||
|
|
||||||
function DraggablePlayerItem({
|
function DraggablePlayerItem({
|
||||||
@@ -263,8 +263,14 @@ export default function NewGame() {
|
|||||||
<div className="relative flex flex-col font-sans pb-24 z-10">
|
<div className="relative flex flex-col font-sans pb-24 z-10">
|
||||||
{/* Floating Top Bar (Header replacement) */}
|
{/* Floating Top Bar (Header replacement) */}
|
||||||
<div className="relative z-50 pt-12 px-4 flex items-center justify-between">
|
<div className="relative z-50 pt-12 px-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1 bg-background/40 backdrop-blur-md rounded-full shadow-sm p-0.5 border border-black/5 dark:border-white/5">
|
<div className="flex items-center gap-1 bg-background/40 backdrop-blur-md rounded-full shadow-sm p-1 border border-black/5 dark:border-white/5">
|
||||||
<NavigationMenu />
|
<button
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
className="active:scale-95 transition-transform shrink-0 ml-0.5"
|
||||||
|
aria-label="Accueil"
|
||||||
|
>
|
||||||
|
<PlanetMark size={26} />
|
||||||
|
</button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { RoundScore } from "../types";
|
|||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Card, CardContent } from "../components/ui/card";
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { NavigationMenu } from "../components/NavigationMenu";
|
import { PlanetMark } from "../components/Logo";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { calculatePlayerTotalScore } from "../utils/scoring";
|
import { calculatePlayerTotalScore } from "../utils/scoring";
|
||||||
|
|
||||||
@@ -232,8 +232,14 @@ export default function PlayGame() {
|
|||||||
<div className="relative flex flex-col min-h-screen pb-32 font-sans selection:bg-white/30 z-10">
|
<div className="relative flex flex-col min-h-screen pb-32 font-sans selection:bg-white/30 z-10">
|
||||||
{/* Floating Top Bar (Header replacement) */}
|
{/* Floating Top Bar (Header replacement) */}
|
||||||
<div className="relative z-50 pt-12 px-4 pb-2 flex items-start justify-between w-full max-w-xl mx-auto">
|
<div className="relative z-50 pt-12 px-4 pb-2 flex items-start justify-between w-full max-w-xl mx-auto">
|
||||||
<div className="flex items-center gap-1 bg-background/40 backdrop-blur-md rounded-full shadow-sm p-0.5 border border-black/5 dark:border-white/5">
|
<div className="flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm p-1.5 border border-black/5 dark:border-white/5">
|
||||||
<NavigationMenu />
|
<button
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
className="active:scale-95 transition-transform"
|
||||||
|
aria-label="Accueil"
|
||||||
|
>
|
||||||
|
<PlanetMark size={26} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center flex-1 flex flex-col items-center pointer-events-none mt-1">
|
<div className="text-center flex-1 flex flex-col items-center pointer-events-none mt-1">
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface ImportedGameDefinition {
|
|||||||
options?: GameOption[];
|
options?: GameOption[];
|
||||||
description?: string;
|
description?: string;
|
||||||
colors: { primary: string; secondary?: 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
|
iconImage?: string; // data URI (png/svg/webp) -> imagePath
|
||||||
bannerImage?: string; // data URI -> bannerPath
|
bannerImage?: string; // data URI -> bannerPath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { ImportedGameDefinition, RESERVED_GAME_IDS } from "../types/gameImport";
|
|||||||
const scoringCategorySchema = z.object({
|
const scoringCategorySchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
|
icon: z.string().max(40).optional(),
|
||||||
|
color: z.string().max(30).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const gameOptionSchema = z.object({
|
const gameOptionSchema = z.object({
|
||||||
@@ -67,6 +69,7 @@ export const importedGameSchema = z.object({
|
|||||||
primary: z.string(),
|
primary: z.string(),
|
||||||
secondary: z.string().optional(),
|
secondary: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
|
icon: z.string().max(40).optional(),
|
||||||
iconImage: z.string().optional(),
|
iconImage: z.string().optional(),
|
||||||
bannerImage: z.string().optional(),
|
bannerImage: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -104,6 +107,7 @@ export function toGameConfig(def: ImportedGameDefinition): GameConfig {
|
|||||||
options: def.options,
|
options: def.options,
|
||||||
description: def.description,
|
description: def.description,
|
||||||
colors: def.colors,
|
colors: def.colors,
|
||||||
|
icon: def.icon,
|
||||||
imagePath: def.iconImage,
|
imagePath: def.iconImage,
|
||||||
bannerPath: def.bannerImage,
|
bannerPath: def.bannerImage,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user