Navigation par barre du bas + gabarits de jeux natifs
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:
Zed
2026-07-12 21:33:19 +02:00
parent 90fba52a92
commit c96a1627ec
17 changed files with 356 additions and 182 deletions
+12 -4
View File
@@ -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).
+20
View File
@@ -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
}
]
}
+27
View File
@@ -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
}
]
}
+21
View File
@@ -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" }
]
}
+38
View File
@@ -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
}
]
}
+14
View File
@@ -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" }
}
+2
View File
@@ -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() {
<Route element={<MainLayout />}>
<Route path="/" element={<Home />} />
<Route path="/history" element={<History />} />
<Route path="/jeux" element={<Games />} />
<Route path="/players" element={<Players />} />
<Route path="/locations" element={<Locations />} />
<Route path="/profiles" element={<Profiles />} />
+3 -36
View File
@@ -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() {
</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>
</nav>
</div>
-103
View File
@@ -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>
);
}
+14 -9
View File
@@ -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 (
<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 ? (
<Button
variant="ghost"
@@ -28,13 +27,19 @@ export function PageHeader({
<ChevronLeft className="w-6 h-6" />
</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}
</h1>
</div>
{action}
<ProfileMenu />
</header>
);
}
+99
View File
@@ -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>
);
}
+78
View File
@@ -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
View File
@@ -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() {
<div className="relative z-10 px-5 pt-5 pb-28 space-y-6">
{/* Header */}
<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">
<PlanetMark size={30} />
<span className="text-xl font-black tracking-tighter">Skori</span>
<PlanetMark size={34} />
<span className="text-2xl font-black tracking-tighter">Skori</span>
</div>
</div>
{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>
)}
<ProfileMenu />
</header>
{/* Stat tiles */}
+9 -3
View File
@@ -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() {
<div className="relative flex flex-col font-sans pb-24 z-10">
{/* Floating Top Bar (Header replacement) */}
<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">
<NavigationMenu />
<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">
<button
onClick={() => navigate("/")}
className="active:scale-95 transition-transform shrink-0 ml-0.5"
aria-label="Accueil"
>
<PlanetMark size={26} />
</button>
<Button
variant="ghost"
size="icon"
+9 -3
View File
@@ -11,7 +11,7 @@ import { RoundScore } from "../types";
import { Button } from "../components/ui/button";
import { Card, CardContent } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { NavigationMenu } from "../components/NavigationMenu";
import { PlanetMark } from "../components/Logo";
import { motion } from "framer-motion";
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">
{/* 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="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">
<NavigationMenu />
<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">
<button
onClick={() => navigate("/")}
className="active:scale-95 transition-transform"
aria-label="Accueil"
>
<PlanetMark size={26} />
</button>
</div>
<div className="text-center flex-1 flex flex-col items-center pointer-events-none mt-1">
+1
View File
@@ -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
}
+4
View File
@@ -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,
};