feat: design Obsidienne + composants UI + toast/undo/backup

- Design system glassmorphism avec Material Design 3
- 14 composants : App, Sidebar, Dashboard, EnclosView, DragodindeCard,
  AccouplementView, ReapproView, InventaireView, WorkflowsView,
  StatistiquesView, ParametresView, UpdateBanner, Toast, ConfirmModal
- UndoManager pour annulation des actions destructives (Ctrl+Z)
- Toast notifications (success/error) avec bouton Annuler
- Modale de confirmation glassmorphism (remplace confirm() natif)
- Export/import global des données depuis Paramètres
- Maquettes HTML/PNG de la refonte graphique

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
POL Mickaël 2026-04-06 05:43:20 +02:00
parent c71ad151e0
commit 62ae4c54eb
52 changed files with 14743 additions and 0 deletions

View File

@ -0,0 +1,260 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=Manrope:wght@600;700;800&amp;family=Plus+Jakarta+Sans:wght@400;500;600&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"primary-fixed": "#c185fd",
"on-error": "#490013",
"surface-bright": "#302939",
"primary": "#cb97ff",
"surface-dim": "#100c16",
"inverse-primary": "#7c41b5",
"on-secondary": "#4a002f",
"background": "#0a0a0f",
"on-tertiary-container": "#594700",
"on-primary": "#46007c",
"on-tertiary-fixed": "#433500",
"error-container": "#a70138",
"on-primary-fixed-variant": "#430077",
"secondary-fixed-dim": "#ffabd1",
"surface-container-lowest": "#000000",
"primary-dim": "#be83fa",
"outline": "#7a7380",
"surface-container-highest": "#292332",
"surface-container-low": "#16111d",
"on-secondary-container": "#ffbeda",
"outline-variant": "#4b4652",
"on-surface-variant": "#b0a8b6",
"tertiary-fixed-dim": "#eec200",
"tertiary-dim": "#eec200",
"surface": "#100c16",
"tertiary-container": "#fed01b",
"tertiary-fixed": "#fed01b",
"surface-tint": "#cb97ff",
"primary-fixed-dim": "#b378ef",
"surface-container": "#1c1724",
"error": "#ff6e84",
"error-dim": "#d73357",
"on-secondary-fixed": "#690045",
"secondary-fixed": "#ffc0db",
"inverse-on-surface": "#59535f",
"primary-container": "#c185fd",
"on-background": "#f1e8f7",
"secondary-container": "#85145a",
"surface-container-high": "#231d2b",
"on-error-container": "#ffb2b9",
"on-tertiary-fixed-variant": "#645000",
"on-primary-container": "#360061",
"on-secondary-fixed-variant": "#922164",
"on-tertiary": "#645000",
"surface-variant": "#292332",
"secondary": "#f673b7",
"inverse-surface": "#fef7ff",
"tertiary": "#ffe083",
"on-surface": "#f1e8f7",
"secondary-dim": "#f271b5",
"on-primary-fixed": "#000000"
},
fontFamily: {
"headline": ["Manrope"],
"body": ["Inter"],
"label": ["Plus Jakarta Sans"]
},
borderRadius: {"DEFAULT": "0.125rem", "lg": "0.25rem", "xl": "0.5rem", "full": "0.75rem"},
},
},
}
</script>
<style>
body {
background: radial-gradient(circle at top right, #1e1b4b, #0a0a0f);
min-height: 100vh;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.glass-panel {
background: rgba(23, 23, 33, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(168, 85, 247, 0.15);
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #4b4652;
border-radius: 10px;
}
</style>
</head>
<body class="text-on-surface font-body selection:bg-primary/30">
<!-- TopNavBar (Now full width) -->
<header class="sticky top-0 w-full flex justify-between items-center px-8 z-40 h-16 border-b border-white/5 bg-[#171721]/70 backdrop-blur-md font-manrope font-semibold text-sm uppercase tracking-widest">
<div class="flex items-center gap-12">
<nav class="flex gap-6">
<a class="text-[#b0a8b6] hover:text-[#cb97ff] pb-4 transition-colors" href="#">Dashboard</a>
<a class="text-[#b0a8b6] hover:text-[#cb97ff] pb-4 transition-colors" href="#">Enclos</a>
<a class="text-[#f1e8f7] border-b-2 border-[#cb97ff] pb-4" href="#">Statistiques</a>
</nav>
</div>
<div class="flex items-center gap-4">
</div>
</header>
<!-- Main Content (Full width, no left margin) -->
<main class="p-8 min-h-[calc(100vh-4rem)] max-w-[1600px] mx-auto">
<!-- Parent Selection Layout -->
<div class="grid grid-cols-12 gap-6 mb-8">
<!-- Parent 1 Selection -->
<section class="col-span-12 lg:col-span-5 flex flex-col gap-4">
<div class="flex justify-between items-end px-2">
<h2 class="font-headline text-lg font-bold text-on-surface uppercase tracking-tighter">Sélection du Parent 1</h2>
<span class="text-[10px] text-secondary font-bold font-label px-2 py-0.5 rounded bg-secondary-container/20">MÂLE REQUIS</span>
</div>
<div class="h-48 rounded-2xl glass-panel flex items-center justify-center group cursor-pointer hover:bg-white/10 transition-all">
<div class="text-center">
<div class="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
<span class="material-symbols-outlined text-3xl text-primary opacity-40">add</span>
</div>
<p class="text-xs font-label font-medium text-on-surface-variant">Cliquer pour choisir un mâle</p>
</div>
</div>
</section>
<!-- Pairing Visualization -->
<div class="col-span-12 lg:col-span-2 flex flex-col items-center justify-center gap-4 py-8 lg:py-0">
<div class="w-12 h-12 rounded-full bg-gradient-to-tr from-primary to-secondary flex items-center justify-center shadow-lg shadow-primary/20">
<span class="material-symbols-outlined text-on-primary-fixed" style="font-variation-settings: 'FILL' 1;">favorite</span>
</div>
<div class="w-full flex flex-col gap-4 mb-4">
<div class="space-y-1.5">
<label class="block text-[10px] font-headline font-bold text-on-surface-variant uppercase tracking-widest text-center px-1">Nombre de couples</label>
<input class="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-center text-sm font-bold text-primary focus:ring-1 focus:ring-primary focus:border-primary outline-none transition-all" min="1" type="number" value="1"/>
</div>
<div class="space-y-1.5">
<label class="block text-[10px] font-headline font-bold text-on-surface-variant uppercase tracking-widest text-center px-1">Bébés obtenus</label>
<input class="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-center text-sm font-bold text-secondary focus:ring-1 focus:ring-secondary focus:border-secondary outline-none transition-all" min="0" type="number" value="1"/>
</div>
</div>
<button class="px-6 py-2.5 bg-gradient-to-r from-primary to-primary-container text-on-primary font-headline font-extrabold text-xs rounded-full shadow-lg shadow-primary/30 active:scale-[0.98] transition-all">ENREGISTRER</button>
</div>
<!-- Parent 2 Selection -->
<section class="col-span-12 lg:col-span-5 flex flex-col gap-4">
<div class="flex justify-between items-end px-2">
<h2 class="font-headline text-lg font-bold text-on-surface uppercase tracking-tighter">Sélection du Parent 2</h2>
<span class="text-[10px] text-primary font-bold font-label px-2 py-0.5 rounded bg-primary-container/20">FEMELLE REQUISE</span>
</div>
<div class="h-48 rounded-2xl glass-panel flex items-center justify-center group cursor-pointer hover:bg-white/10 transition-all">
<div class="text-center">
<div class="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
<span class="material-symbols-outlined text-3xl text-secondary opacity-40">add</span>
</div>
<p class="text-xs font-label font-medium text-on-surface-variant">Cliquer pour choisir une femelle</p>
</div>
</div>
</section>
</div>
<!-- Filters & Inventory Grid -->
<div class="glass-panel rounded-3xl p-6 shadow-xl">
<!-- Generation Chips -->
<div class="flex flex-wrap items-center gap-2 mb-8 border-b border-white/10 pb-6">
<span class="text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mr-4">Générations</span>
<button class="px-4 py-1.5 rounded-full bg-primary text-on-primary text-[11px] font-bold font-label">Toutes</button>
<button class="px-4 py-1.5 rounded-full bg-white/5 text-on-surface-variant hover:text-on-surface text-[11px] font-bold font-label transition-colors">Gen 1</button>
<button class="px-4 py-1.5 rounded-full bg-white/5 text-on-surface-variant hover:text-on-surface text-[11px] font-bold font-label transition-colors">Gen 2</button>
<button class="px-4 py-1.5 rounded-full bg-white/5 text-on-surface-variant hover:text-on-surface text-[11px] font-bold font-label transition-colors">Gen 3</button>
<button class="px-4 py-1.5 rounded-full bg-white/5 text-on-surface-variant hover:text-on-surface text-[11px] font-bold font-label transition-colors">Gen 4</button>
<button class="px-4 py-1.5 rounded-full bg-white/5 text-on-surface-variant hover:text-on-surface text-[11px] font-bold font-label transition-colors">Gen 5</button>
<button class="px-4 py-1.5 rounded-full bg-white/5 text-on-surface-variant hover:text-on-surface text-[11px] font-bold font-label transition-colors">Gen 6</button>
<button class="px-4 py-1.5 rounded-full bg-white/5 text-on-surface-variant hover:text-on-surface text-[11px] font-bold font-label transition-colors">Gen 7</button>
<button class="px-4 py-1.5 rounded-full bg-white/5 text-on-surface-variant hover:text-on-surface text-[11px] font-bold font-label transition-colors">Gen 8</button>
<button class="px-4 py-1.5 rounded-full bg-white/5 text-on-surface-variant hover:text-on-surface text-[11px] font-bold font-label transition-colors">Gen 9</button>
<button class="px-4 py-1.5 rounded-full bg-white/5 text-on-surface-variant hover:text-on-surface text-[11px] font-bold font-label transition-colors">Gen 10</button>
</div>
<!-- Dense Grid of Dragodindes -->
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
<!-- Dragon Card -->
<div class="bg-white/5 rounded-2xl p-3 border border-white/5 hover:border-primary/40 group cursor-pointer transition-all hover:shadow-lg hover:shadow-black/40">
<div class="relative aspect-square rounded-xl bg-white/5 mb-3 flex items-center justify-center overflow-hidden">
<img alt="stylized digital illustration of a colorful fantasy creature icon" class="w-16 h-16 opacity-80 group-hover:scale-110 transition-transform" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDRM-afQsjjBpV0HP4BYAI80wKFk5S0M67ep684jxQ2BDLO8JS-DEpHuiAdQgcKcOIkYh05r6ONm9mzqnhjsh6ds_u1_oxPT_naIl7A5jPtaBbzxCodn-dhpT_rmthdfzuIp5VHj4VFowFxL_9IIFFXvKhXYKKyyFhafw2MCF6Y1CIm5Rvk4DRut0eu0xor8BieftbB41EUx4MwxV0N_b0xO9DRFM9OECBXX01uUnpRqADH6aysiMEKNHeikR41DXtKoFt_9ASwDwjt"/>
<div class="absolute top-1 right-1 px-1.5 py-0.5 rounded-md bg-black/60 backdrop-blur text-[8px] font-bold text-tertiary">GEN 1</div>
</div>
<div class="space-y-1">
<p class="text-[11px] font-bold text-on-surface truncate">Rousse</p>
<div class="flex justify-between items-center">
<span class="text-[9px] text-on-surface-variant font-label">Niv. 17</span>
<span class="w-2 h-2 rounded-full bg-tertiary"></span>
</div>
</div>
</div>
<div class="bg-white/5 rounded-2xl p-3 border border-white/5 hover:border-primary/40 group cursor-pointer transition-all hover:shadow-lg hover:shadow-black/40">
<div class="relative aspect-square rounded-xl bg-white/5 mb-3 flex items-center justify-center overflow-hidden">
<img alt="stylized digital illustration" class="w-16 h-16 opacity-80 group-hover:scale-110 transition-transform" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAhsMf1JFbigN3qPl53FehUJQhlj_A6JRTo1pDS0dPzl6iaWGLABthGR3GE_eg7Foio2Mzm8MguOliXQHnteMs5sNAJmoVn2_sJfJq7Hq4LaLedXVuRbvdZIN3honqSubxZlHPUM8A1bwBa3RCJz9yKDmlqoi1x_gOZu6qGbhEBbdwJrEMp4l1VlAxYx7x3zRPjJvbTS3pJWsbFncHCLwhWx_Vf6I21O34kgFInRusWRkbtqpHkYa_sUL_yj-GZTrSqLur2W2dK-XFa"/>
<div class="absolute top-1 right-1 px-1.5 py-0.5 rounded-md bg-black/60 backdrop-blur text-[8px] font-bold text-tertiary">GEN 1</div>
</div>
<div class="space-y-1">
<p class="text-[11px] font-bold text-on-surface truncate">Amande</p>
<div class="flex justify-between items-center">
<span class="text-[9px] text-on-surface-variant font-label">Niv. 94</span>
<span class="w-2 h-2 rounded-full bg-outline-variant"></span>
</div>
</div>
</div>
<div class="bg-white/5 rounded-2xl p-3 border border-white/5 hover:border-primary/40 group cursor-pointer transition-all hover:shadow-lg hover:shadow-black/40">
<div class="relative aspect-square rounded-xl bg-white/5 mb-3 flex items-center justify-center overflow-hidden">
<img alt="stylized digital illustration" class="w-16 h-16 opacity-80 group-hover:scale-110 transition-transform" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDRZsFBYf10uMTQEyysxPn7_Z9SMLi73puPVwzIS6mPEs8k6d4zimbR0zfKm45AiPOzgBFZu0XmUa4njK-UBZYLbhDuVrouSnetSVlSzRSrIYYGS6vt0cmtEmS1BurlJ1RUIjrken1K51z6shq8xwOLa0csjR9P-92FBu3UROyMcXsYErlF3IVtV-zIjiVSaeAnjBKH-lAh68UGkurrzDwGJ6zIJ4_EuBA7jJmT82uJPwFQi6NlS4I-pRfnv0Zy_XEA_cqjjSoOq5Y4"/>
<div class="absolute top-1 right-1 px-1.5 py-0.5 rounded-md bg-black/60 backdrop-blur text-[8px] font-bold text-tertiary">GEN 1</div>
</div>
<div class="space-y-1">
<p class="text-[11px] font-bold text-on-surface truncate">Dorée</p>
<div class="flex justify-between items-center">
<span class="text-[9px] text-on-surface-variant font-label">Niv. 80</span>
<span class="w-2 h-2 rounded-full bg-outline-variant"></span>
</div>
</div>
</div>
<div class="bg-white/5 rounded-2xl p-3 border border-white/5 hover:border-primary/40 group cursor-pointer transition-all hover:shadow-lg hover:shadow-black/40">
<div class="relative aspect-square rounded-xl bg-white/5 mb-3 flex items-center justify-center overflow-hidden">
<img alt="stylized digital illustration" class="w-16 h-16 opacity-80 group-hover:scale-110 transition-transform" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAp7D0WuZUSm9OFonKWZCGCLzuEKAp1xabohZcfhge-ku3-dfwypsxCRiI84B_4794z0-Ws3JtnWzEBTqthQaOqmwsrU4-_A_K1-LGgi9Cn04QdTrq__1yK2zs6CRuRFDYDsIUH5w4asdaof3OY76ZmStYXMQ0UAMk3ezJZ7oM9ZnZav4gRacHMleej6K_jqp__W2ZphV075XgiGQZXgiCtp40l2SM_dX9H1sauDDTNxwK5M7dEvwjGbxPKddcKsCJ-L3FsJkZ1qj1e"/>
<div class="absolute top-1 right-1 px-1.5 py-0.5 rounded-md bg-black/60 backdrop-blur text-[8px] font-bold text-tertiary">GEN 2</div>
</div>
<div class="space-y-1">
<p class="text-[11px] font-bold text-on-surface truncate">Indigo</p>
<div class="flex justify-between items-center">
<span class="text-[9px] text-on-surface-variant font-label">Niv. 46</span>
<span class="w-2 h-2 rounded-full bg-tertiary"></span>
</div>
</div>
</div>
</div>
<!-- Load More / Pagination -->
<div class="mt-8 flex justify-center">
<button class="flex items-center gap-2 px-6 py-2 rounded-xl bg-white/5 text-[11px] font-bold text-on-surface-variant hover:text-on-surface transition-all">
VOIR PLUS DE DRAGODINDES
<span class="material-symbols-outlined text-sm">expand_more</span>
</button>
</div>
</div>
</main>
<!-- Footer Mini Data (Full width) -->
<footer class="w-full px-8 py-4 border-t border-white/5 flex justify-between items-center text-[10px] text-on-surface-variant/60 font-label tracking-widest uppercase bg-black/20">
<div class="flex gap-6">
<span>Archive ID: #882-AMTHST</span>
<span>Total Accouplements: 1,204</span>
</div>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-primary shadow-[0_0_8px_rgba(203,151,255,0.6)]"></span>
SYSTEM ONLINE
</div>
</footer>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

361
refonte_graphique/dashboard.html Executable file
View File

@ -0,0 +1,361 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Minuteur Dragodinde - L'Archive d'Obsidienne</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&amp;family=Cinzel:wght@400;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet"/>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
primary: "#a855f7",
"background-light": "#f8fafc",
"background-dark": "#0a0a0f",
"obsidian": "#121017",
"amethyst": "#a855f7",
"glass": "rgba(23, 23, 33, 0.6)",
},
fontFamily: {
display: ["Cinzel", "serif"],
sans: ["Inter", "sans-serif"],
},
borderRadius: {
DEFAULT: "0.75rem",
},
},
},
};
</script>
<style>
body {
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
background: radial-gradient(circle at top right, #1e1b4b, #0a0a0f);
min-height: 100vh;
}
.glass-panel {
background: rgba(23, 23, 33, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(168, 85, 247, 0.15);
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(168, 85, 247, 0.2);
border-radius: 10px;
}
</style>
</head>
<body class="bg-background-light dark:bg-background-dark text-slate-800 dark:text-slate-200 transition-colors duration-300">
<div class="flex h-screen overflow-hidden">
<main class="flex-1 flex flex-col min-w-0 overflow-hidden">
<header class="h-16 flex-shrink-0 bg-white/5 dark:bg-transparent border-b border-white/5 flex items-center justify-between px-10 glass-panel border-none rounded-none">
<div class="flex items-center gap-10 h-full">
<a class="h-full flex items-center border-b-2 border-primary text-primary font-semibold text-sm relative" href="#">
Tableau de bord
<span class="absolute -bottom-[1px] left-0 w-full h-[2px] bg-primary rounded-full"></span>
</a>
<a class="h-full flex items-center border-b-2 border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-200 font-semibold text-sm transition-all" href="#">Enclos</a>
<a class="h-full flex items-center border-b-2 border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-200 font-semibold text-sm transition-all" href="#">Statistiques</a>
</div>
<div class="flex items-center gap-8">
</div>
</header>
<div class="flex-1 overflow-y-auto custom-scrollbar p-10 space-y-12">
<section>
<div class="flex items-center justify-between mb-6">
<h2 class="text-sm font-bold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400">Statistiques Globales</h2>
<button class="text-sm text-rose-500 hover:text-rose-400 font-bold flex items-center gap-2">
<span class="material-icons-round text-base">restart_alt</span>
Réinitialiser
</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
<div class="glass-panel p-6 rounded-2xl">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Total Bébés</p>
<div class="flex items-baseline gap-3">
<span class="text-5xl font-display font-extrabold text-primary">13</span>
<span class="text-sm text-green-500 font-bold">+2</span>
</div>
</div>
<div class="glass-panel p-6 rounded-2xl">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Dragodindes Actives</p>
<span class="text-5xl font-display font-extrabold text-primary">15</span>
</div>
<div class="glass-panel p-6 rounded-2xl">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Couples Formés</p>
<span class="text-5xl font-display font-extrabold text-primary">35</span>
</div>
<div class="glass-panel p-6 rounded-2xl">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Taux de Réussite</p>
<span class="text-5xl font-display font-extrabold text-primary">37%</span>
</div>
<div class="glass-panel p-6 rounded-2xl border-primary/20 bg-primary/5">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Races Obtenues</p>
<span class="text-5xl font-display font-extrabold text-primary">2</span>
</div>
</div>
</section>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-10 items-stretch">
<div class="lg:col-span-8 space-y-12">
<section>
<h2 class="text-sm font-bold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400 mb-6">Aperçu - Tous les enclos</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Enclos Alpha -->
<div class="glass-panel p-8 rounded-3xl hover:border-primary/40 transition-all flex flex-col gap-6">
<div class="w-full flex flex-col gap-4">
<div class="flex justify-between items-start">
<h3 class="font-display text-2xl font-bold tracking-wide">ENCLOS ALPHA</h3>
<p class="text-xs font-bold flex items-center justify-end gap-2 text-green-500">
<span class="w-2 h-2 rounded-full bg-green-500"></span> Actif
</p>
</div>
<div class="flex justify-between items-end">
<div class="flex flex-col">
<p class="text-4xl font-display font-extrabold leading-none">10 <span class="text-base font-sans text-slate-500 font-medium ml-1">DD</span></p>
<p class="text-[11px] text-slate-400 uppercase tracking-widest mt-1 font-bold">Capacité max</p>
</div>
<div class="space-y-1.5 text-right">
<p class="text-xs font-bold text-primary flex items-center justify-end gap-2">
<span class="material-icons-round text-sm">hourglass_top</span>
Restant: 02:45:00
</p>
<p class="text-xs text-slate-500 flex items-center justify-end gap-2 font-medium">
<span class="material-icons-round text-sm">schedule</span>
Écoulé: 01:15:00
</p>
</div>
</div>
</div>
<div class="flex flex-wrap gap-3">
<span class="px-3 py-1.5 rounded-lg bg-amber-500/10 text-amber-500 text-xs font-bold flex items-center gap-2 border border-amber-500/20">
<span class="material-icons-round text-sm">restaurant</span> Mangeoire
</span>
<span class="px-3 py-1.5 rounded-lg bg-pink-500/10 text-pink-500 text-xs font-bold flex items-center gap-2 border border-pink-500/20">
<span class="material-icons-round text-sm">spa</span> Caresseur
</span>
</div>
<button class="w-full py-3.5 bg-white/5 hover:bg-primary/20 text-slate-300 hover:text-primary rounded-xl text-sm font-bold transition-all flex items-center justify-center gap-3 border border-white/5">
Gérer cet enclos
<span class="material-icons-round text-base">arrow_forward</span>
</button>
</div>
<!-- Enclos Gamma -->
<div class="glass-panel p-8 rounded-3xl hover:border-primary/40 transition-all flex flex-col gap-6 ring-1 ring-primary/20">
<div class="w-full flex flex-col gap-4">
<div class="flex justify-between items-start">
<h3 class="font-display text-2xl font-bold tracking-wide">ENCLOS GAMMA</h3>
<p class="text-xs font-bold flex items-center justify-end gap-2 text-green-500">
<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span> Actif
</p>
</div>
<div class="flex justify-between items-end">
<div class="flex flex-col">
<p class="text-4xl font-display font-extrabold leading-none">5 <span class="text-base font-sans text-slate-500 font-medium ml-1">DD</span></p>
<p class="text-[11px] text-slate-400 uppercase tracking-widest mt-1 font-bold">Capacité max</p>
</div>
<div class="space-y-1.5 text-right">
<p class="text-xs font-bold text-primary flex items-center justify-end gap-2">
<span class="material-icons-round text-sm">hourglass_top</span>
Restant: 12:00:00
</p>
<p class="text-xs text-slate-500 flex items-center justify-end gap-2 font-medium">
<span class="material-icons-round text-sm">schedule</span>
Écoulé: 24:00:00
</p>
</div>
</div>
</div>
<div class="flex flex-wrap gap-3">
<span class="px-3 py-1.5 rounded-lg bg-amber-500/10 text-amber-500 text-xs font-bold flex items-center gap-2 border border-amber-500/20">
<span class="material-icons-round text-sm">restaurant</span> Mangeoire
</span>
</div>
<button class="w-full py-3.5 bg-primary text-white rounded-xl text-sm font-bold transition-all flex items-center justify-center gap-3 shadow-xl shadow-primary/20">
Gérer cet enclos
<span class="material-icons-round text-base">arrow_forward</span>
</button>
</div>
<!-- Enclos Beta (Inactive) -->
<div class="glass-panel p-8 rounded-3xl hover:border-primary/40 transition-all flex flex-col gap-6">
<div class="w-full flex flex-col gap-4">
<div class="flex justify-between items-start">
<h3 class="font-display text-2xl font-bold tracking-wide">ENCLOS BETA</h3>
<p class="text-xs font-bold flex items-center justify-end gap-2 text-slate-500">
<span class="w-2 h-2 rounded-full bg-slate-500"></span> Inactif
</p>
</div>
<div class="flex justify-between items-end">
<div class="flex flex-col">
<p class="text-4xl font-display font-extrabold leading-none text-slate-600">0 <span class="text-base font-sans text-slate-500 font-medium ml-1">DD</span></p>
<p class="text-[11px] text-slate-400 uppercase tracking-widest mt-1 font-bold">Capacité max</p>
</div>
<div class="space-y-1.5 text-right">
<p class="text-xs font-bold text-slate-500 flex items-center justify-end gap-2">
<span class="material-icons-round text-sm">hourglass_empty</span>
--:--:--
</p>
<p class="text-xs text-slate-500 flex items-center justify-end gap-2 font-medium">
<span class="material-icons-round text-sm">schedule</span>
--:--:--
</p>
</div>
</div>
</div>
<div class="flex flex-wrap gap-3">
<span class="px-3 py-1.5 rounded-lg bg-white/5 text-slate-500 text-xs font-bold border border-white/5">
Aucune jauge active
</span>
</div>
<button class="w-full py-3.5 bg-white/5 hover:bg-primary/20 text-slate-300 hover:text-primary rounded-xl text-sm font-bold transition-all flex items-center justify-center gap-3 border border-white/5">
Gérer cet enclos
<span class="material-icons-round text-base">arrow_forward</span>
</button>
</div>
<!-- Enclos Epsilon -->
<div class="glass-panel p-8 rounded-3xl hover:border-primary/40 transition-all flex flex-col gap-6">
<div class="w-full flex flex-col gap-4">
<div class="flex justify-between items-start">
<h3 class="font-display text-2xl font-bold tracking-wide">ENCLOS EPSILON</h3>
<p class="text-xs font-bold flex items-center justify-end gap-2 text-green-500">
<span class="w-2 h-2 rounded-full bg-green-500"></span> Actif
</p>
</div>
<div class="flex justify-between items-end">
<div class="flex flex-col">
<p class="text-4xl font-display font-extrabold leading-none">2 <span class="text-base font-sans text-slate-500 font-medium ml-1">DD</span></p>
<p class="text-[11px] text-slate-400 uppercase tracking-widest mt-1 font-bold">Capacité max</p>
</div>
<div class="space-y-1.5 text-right">
<p class="text-xs font-bold text-primary flex items-center justify-end gap-2">
<span class="material-icons-round text-sm">hourglass_top</span>
Restant: 00:30:15
</p>
<p class="text-xs text-slate-500 flex items-center justify-end gap-2 font-medium">
<span class="material-icons-round text-sm">schedule</span>
Écoulé: 05:20:00
</p>
</div>
</div>
</div>
<div class="flex flex-wrap gap-3">
<span class="px-3 py-1.5 rounded-lg bg-pink-500/10 text-pink-500 text-xs font-bold flex items-center gap-2 border border-pink-500/20">
<span class="material-icons-round text-sm">spa</span> Caresseur
</span>
</div>
<button class="w-full py-3.5 bg-white/5 hover:bg-primary/20 text-slate-300 hover:text-primary rounded-xl text-sm font-bold transition-all flex items-center justify-center gap-3 border border-white/5">
Gérer cet enclos
<span class="material-icons-round text-base">arrow_forward</span>
</button>
</div>
</div>
</section>
</div>
<div class="lg:col-span-4 flex flex-col space-y-10">
<section class="flex flex-col">
<h2 class="text-sm font-bold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400 mb-6">Activité récente</h2>
<div class="glass-panel p-8 rounded-3xl flex-1">
<ul class="space-y-8">
<li class="flex gap-5">
<div class="w-3 h-3 rounded-full bg-green-500 mt-2 flex-shrink-0 shadow-[0_0_10px_rgba(34,197,94,0.4)]"></div>
<div>
<p class="text-base font-bold">Naissance réussie</p>
<p class="text-sm text-slate-500 mt-0.5">2 Dragodindes rousses • Il y a 10 min</p>
</div>
</li>
<li class="flex gap-5">
<div class="w-3 h-3 rounded-full bg-amber-500 mt-2 flex-shrink-0 shadow-[0_0_10px_rgba(245,158,11,0.4)]"></div>
<div>
<p class="text-base font-bold">Enclos Alpha - Mangeoires</p>
<p class="text-sm text-slate-500 mt-0.5">Réapprovisionnement requis • Il y a 1h</p>
</div>
</li>
<li class="flex gap-5">
<div class="w-3 h-3 rounded-full bg-primary mt-2 flex-shrink-0 shadow-[0_0_10px_rgba(168,85,247,0.4)]"></div>
<div>
<p class="text-base font-bold">Nouvelle race découverte</p>
<p class="text-sm text-slate-500 mt-0.5">Ébène enregistrée • Il y a 3h</p>
</div>
</li>
<li class="flex gap-5">
<div class="w-3 h-3 rounded-full bg-blue-500 mt-2 flex-shrink-0 shadow-[0_0_10px_rgba(59,130,246,0.4)]"></div>
<div>
<p class="text-base font-bold">Accouplement programmé</p>
<p class="text-sm text-slate-500 mt-0.5">Enclos Gamma • Il y a 4h</p>
</div>
</li>
<li class="flex gap-5">
<div class="w-3 h-3 rounded-full bg-pink-500 mt-2 flex-shrink-0 shadow-[0_0_10px_rgba(236,72,153,0.4)]"></div>
<div>
<p class="text-base font-bold">Sérénité stabilisée</p>
<p class="text-sm text-slate-500 mt-0.5">Enclos Epsilon • Il y a 5h</p>
</div>
</li>
</ul>
</div>
</section>
<section class="flex flex-col flex-1 min-h-0">
<h2 class="text-sm font-bold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400 mb-6">Progression des races</h2>
<div class="glass-panel p-8 rounded-3xl space-y-7 flex-1 overflow-y-auto custom-scrollbar">
<div>
<div class="flex justify-between text-xs font-extrabold mb-3">
<span class="text-amber-500 uppercase tracking-wider">Dorée et Rousse</span>
<span>10/10 (Terminé)</span>
</div>
<div class="w-full h-2.5 bg-slate-900 rounded-full overflow-hidden border border-white/5">
<div class="h-full bg-amber-500 rounded-full" style="width: 100%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs font-extrabold mb-3">
<span class="text-indigo-400 uppercase tracking-wider">Ébène</span>
<span>3/10</span>
</div>
<div class="w-full h-2.5 bg-slate-900 rounded-full overflow-hidden border border-white/5">
<div class="h-full bg-indigo-500 rounded-full" style="width: 30%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs font-extrabold mb-3">
<span class="text-primary uppercase tracking-wider">Indigo</span>
<span>0/10</span>
</div>
<div class="w-full h-2.5 bg-slate-900 rounded-full overflow-hidden border border-white/5">
<div class="h-full bg-primary rounded-full" style="width: 0%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs font-extrabold mb-3">
<span class="text-pink-400 uppercase tracking-wider">Amande</span>
<span>0/10</span>
</div>
<div class="w-full h-2.5 bg-slate-900 rounded-full overflow-hidden border border-white/5">
<div class="h-full bg-pink-500 rounded-full" style="width: 0%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-xs font-extrabold mb-3">
<span class="text-emerald-400 uppercase tracking-wider">Émeraude</span>
<span>0/10</span>
</div>
<div class="w-full h-2.5 bg-slate-900 rounded-full overflow-hidden border border-white/5">
<div class="h-full bg-emerald-500 rounded-full" style="width: 0%"></div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</main>
</div>
</body></html>

BIN
refonte_graphique/dashboard.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

228
refonte_graphique/enclos.html Executable file
View File

@ -0,0 +1,228 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Minuteur Dragodinde - Enclos 1</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&amp;family=Outfit:wght@400;600;700&amp;family=Material+Icons+Round&amp;display=swap" rel="stylesheet"/>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
primary: "#a855f7",
"background-light": "#f8fafc",
"background-dark": "#0a0a0f",
"glass-dark": "rgba(23, 23, 33, 0.7)",
"glass-border": "rgba(168, 85, 247, 0.2)",
},
fontFamily: {
display: ["Outfit", "sans-serif"],
sans: ["Inter", "sans-serif"],
},
borderRadius: {
DEFAULT: "12px",
},
},
},
};
</script>
<style>
body {
background: radial-gradient(circle at top right, #1e1b4b, #0a0a0f);
min-height: 100vh;
}
.glass-panel {
background: rgba(23, 23, 33, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(168, 85, 247, 0.15);
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.stat-badge {
background: rgba(168, 85, 247, 0.1);
border: 1px solid rgba(168, 85, 247, 0.2);
}
</style>
</head>
<body class="bg-background-light dark:bg-background-dark text-slate-900 dark:text-slate-100 font-sans selection:bg-primary/30">
<div class="flex h-screen overflow-hidden">
<main class="flex-1 flex flex-col overflow-hidden relative">
<header class="h-16 border-b border-white/5 flex items-center px-8 justify-between glass-panel z-10">
<div class="flex items-center gap-8">
<a class="text-sm font-semibold text-slate-400 hover:text-white transition-colors" href="#">Tableau de bord</a>
<a class="text-sm font-semibold text-primary relative" href="#">
Enclos
<span class="absolute -bottom-5 left-0 w-full h-1 bg-primary rounded-full"></span>
</a>
<a class="text-sm font-semibold text-slate-400 hover:text-white transition-colors" href="#">Statistiques</a>
</div>
</header>
<div class="flex-1 overflow-y-auto p-8 scrollbar-hide">
<section class="glass-panel rounded-2xl p-6 mb-8 border border-white/10">
<div class="flex flex-wrap items-center justify-between mb-8 gap-6">
<div class="flex items-center gap-4">
<h2 class="font-display text-4xl font-bold tracking-tight">Enclos 1</h2>
<button class="bg-red-500/10 hover:bg-red-500/20 text-red-500 text-[11px] font-bold px-4 py-1.5 rounded-full uppercase border border-red-500/20 transition-all">
Vider l'enclos
</button>
</div>
<div class="flex items-center gap-6">
<div class="flex flex-col items-end mr-4 opacity-70">
<div class="flex items-center gap-1">
<span class="text-[11px] text-slate-500 font-bold uppercase tracking-widest">Temps Écoulé</span>
</div>
<span class="text-3xl font-display font-bold text-[#cb97ff] leading-none tracking-wider">00:00:00</span>
</div>
<div class="flex flex-col items-end">
<div class="flex items-center gap-1">
<span class="material-icons-round text-[12px] text-[#00FF00]">notifications</span>
<span class="text-[11px] text-slate-500 font-bold uppercase tracking-widest">Alarme dans</span>
</div>
<span class="text-5xl font-display font-bold text-[#00FF00] leading-none drop-shadow-[0_0_12px_rgba(0,255,0,0.4)]">--:--:--</span>
</div>
<button class="bg-primary hover:bg-primary/90 text-white flex items-center gap-3 px-8 py-4 rounded-xl font-bold text-lg shadow-xl shadow-primary/20 transition-all active:scale-95">
<span class="material-icons-round">play_arrow</span>
DÉMARRER
</button>
</div>
</div>
<!-- Jauges Actives Row -->
<div class="flex flex-wrap gap-2 mb-8">
<span class="text-[12px] font-bold text-slate-500 uppercase flex items-center mr-2">Jauges Actives :</span>
<button class="px-4 py-2 rounded-lg bg-slate-800 text-slate-400 text-xs font-bold border border-white/5 hover:bg-slate-700 transition-all flex items-center gap-2">
<span class="material-icons-round text-sm">remove_circle_outline</span> Baffeur
</button>
<button class="px-4 py-2 rounded-lg bg-primary/20 text-primary text-xs font-bold border border-primary/30 transition-all flex items-center gap-2">
<span class="material-icons-round text-sm">add</span> Caresseur
</button>
<button class="px-4 py-2 rounded-lg bg-slate-800 text-slate-400 text-xs font-bold border border-white/5 hover:bg-slate-700 transition-all flex items-center gap-2">
<span class="material-icons-round text-sm">bolt</span> Foudroyeur
</button>
<button class="px-4 py-2 rounded-lg bg-slate-800 text-slate-400 text-xs font-bold border border-white/5 hover:bg-slate-700 transition-all flex items-center gap-2">
<span class="material-icons-round text-sm">water_drop</span> Abreuvoir
</button>
<button class="px-4 py-2 rounded-lg bg-slate-800 text-slate-400 text-xs font-bold border border-white/5 hover:bg-slate-700 transition-all flex items-center gap-2">
<span class="material-icons-round text-sm">favorite</span> Dragofesse
</button>
<button class="px-4 py-2 rounded-lg bg-primary/20 text-primary text-xs font-bold border border-primary/30 transition-all flex items-center gap-2">
<span class="material-icons-round text-sm">restaurant</span> Mangeoire
</button>
</div>
<!-- Mangeoire and Caresseur Cards Row (Inverted layout preserved) -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="p-5 rounded-xl border border-white/5 bg-white/5">
<div class="flex justify-between items-center mb-5">
<div class="flex items-center gap-2">
<span class="material-icons-round text-orange-400">restaurant</span>
<span class="font-bold text-base uppercase tracking-wide">Mangeoire</span>
</div>
<span class="text-[11px] font-bold bg-orange-500/20 text-orange-400 px-3 py-1 rounded border border-orange-500/20 uppercase">Tier 3 · ±30/tick</span>
</div>
<div class="h-5 w-full bg-slate-900 rounded-full overflow-hidden mb-3 p-0.5 border border-white/5">
<div class="h-full w-[90%] bg-gradient-to-r from-orange-600 to-orange-400 rounded-full"></div>
</div>
<div class="flex justify-between text-[12px] font-bold text-slate-500">
<span>90 000 / 100 000</span>
<span>Vide en 17h 07m 50s</span>
</div>
</div>
<div class="p-5 rounded-xl border border-white/5 bg-white/5">
<div class="flex justify-between items-center mb-5">
<div class="flex items-center gap-2">
<span class="material-icons-round text-pink-400">add</span>
<span class="font-bold text-base uppercase tracking-wide">Caresseur</span>
</div>
<span class="text-[11px] font-bold bg-pink-500/20 text-pink-400 px-3 py-1 rounded border border-pink-500/20 uppercase">Tier 3 · ±10/tick</span>
</div>
<div class="h-5 w-full bg-slate-900 rounded-full overflow-hidden mb-3 p-0.5 border border-white/5">
<div class="h-full w-0 bg-gradient-to-r from-pink-600 to-pink-400 rounded-full"></div>
</div>
<div class="flex justify-between text-[12px] font-bold text-slate-500">
<span>0 / 100 000</span>
<span>Vide en 0s</span>
</div>
</div>
</div>
</section>
<div class="flex justify-between items-end mb-6">
<h3 class="font-display text-2xl font-bold flex items-center gap-3">
Dragodindes <span class="text-slate-500 text-lg">10/10</span>
</h3>
<button class="text-sm font-bold text-primary hover:bg-primary/10 px-5 py-2 rounded-lg border border-primary/20 transition-all flex items-center gap-2">
<span class="material-icons-round text-base">add</span>
Ajouter une Dragodinde
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<script>
const stats = [
{ icon: 'sentiment_satisfied_alt', val: '0', color: 'text-blue-400' },
{ icon: 'bolt', val: '0', color: 'text-yellow-400' },
{ icon: 'water_drop', val: '0', color: 'text-cyan-400' },
{ icon: 'favorite', val: '0', color: 'text-red-400' },
{ icon: 'star', val: '1', color: 'text-yellow-200' },
];
for(let i = 1; i <= 10; i++) {
const isDragofesse = i % 2 === 0;
document.write(`
<div class="glass-panel rounded-xl overflow-hidden border border-white/5 hover:border-primary/40 transition-all group">
<div class="p-4 border-b border-white/5 flex justify-between items-center bg-white/5">
<span class="text-sm font-bold uppercase tracking-wide">Dragodinde ${i}</span>
<span class="material-icons-round text-base text-slate-600 hover:text-red-400 cursor-pointer transition-colors">close</span>
</div>
<div class="p-5">
<div class="grid grid-cols-5 gap-1.5 mb-5">
${stats.map(s => `
<div class="flex flex-col items-center p-2 rounded-lg bg-slate-900/50 border border-white/5">
<span class="material-icons-round text-[16px] ${s.color}">${s.icon}</span>
<span class="text-[11px] font-bold mt-1">${s.val}</span>
</div>
`).join('')}
</div>
<div class="space-y-4 mb-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="material-icons-round text-[18px] text-blue-400">sentiment_satisfied_alt</span>
<span class="text-[11px] font-bold text-slate-400">Cible</span>
</div>
<div class="h-2 w-28 bg-slate-900 rounded-full overflow-hidden border border-white/5">
<div class="h-full w-0 bg-pink-400"></div>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="material-icons-round text-[18px] text-yellow-500">stars</span>
<span class="text-[11px] font-bold text-slate-400">Niveau</span>
</div>
<span class="text-[11px] font-bold text-slate-500">17h 07m 50s</span>
</div>
</div>
<div class="flex items-end justify-between border-t border-white/5 pt-4">
<div>
<p class="text-[11px] font-bold text-primary tracking-wider uppercase">Niv. 1</p>
<p class="text-[10px] text-slate-500 uppercase font-medium">XP 0%</p>
</div>
<span class="text-[13px] font-mono font-bold text-slate-300">17:07:50</span>
</div>
<div class="mt-5 pt-4 border-t border-white/5">
<button class="w-full flex items-center justify-between py-2.5 px-4 rounded-lg bg-primary/10 border border-primary/20 text-primary hover:bg-primary/20 transition-all">
<span class="flex items-center gap-2 font-bold text-[11px] uppercase">
<span class="material-icons-round text-sm">${isDragofesse ? 'favorite' : 'add'}</span> ${isDragofesse ? 'Dragofesse' : 'Caresseur'}
</span>
<span class="text-[11px] font-mono opacity-80 font-bold">--:--:--</span>
</button>
</div>
</div>
</div>
`);
}
</script>
</div>
</div>
</main>
</div>
</body></html>

BIN
refonte_graphique/enclos.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

396
refonte_graphique/inventaire.html Executable file
View File

@ -0,0 +1,396 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Obsidian Archive - Inventaire &amp; Breeding Planner</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200;400;600;700;800&amp;family=Inter:wght@300;400;500;600&amp;family=Plus+Jakarta+Sans:wght@400;500;600&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<style>
body {
background: radial-gradient(circle at top right, #1e1b4b, #0a0a0f);
min-height: 100vh;
}
.glass-panel {
background: rgba(41, 35, 50, 0.6);
backdrop-filter: blur(20px);
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0,0,0,0.1);
}
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.2);
}
</style>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"on-tertiary-fixed": "#433500",
"on-primary-fixed": "#000000",
"surface-container-high": "#231d2b",
"on-secondary": "#4a002f",
"secondary-container": "#85145a",
"error-dim": "#d73357",
"tertiary-fixed-dim": "#eec200",
"surface-container-low": "#16111d",
"primary-fixed": "#c185fd",
"on-primary-fixed-variant": "#430077",
"primary-fixed-dim": "#b378ef",
"outline-variant": "#4b4652",
"on-secondary-fixed-variant": "#922164",
"secondary-dim": "#f271b5",
"on-tertiary-fixed-variant": "#645000",
"on-error": "#490013",
"on-primary": "#46007c",
"tertiary-dim": "#eec200",
"primary-dim": "#be83fa",
"on-primary-container": "#360061",
"surface": "#100c16",
"on-tertiary-container": "#594700",
"outline": "#7a7380",
"on-secondary-fixed": "#690045",
"primary": "#cb97ff",
"on-tertiary": "#645000",
"surface-dim": "#100c16",
"on-surface": "#f1e8f7",
"tertiary-fixed": "#fed01b",
"tertiary-container": "#fed01b",
"surface-tint": "#cb97ff",
"on-background": "#f1e8f7",
"inverse-primary": "#7c41b5",
"error": "#ff6e84",
"error-container": "#a70138",
"secondary-fixed": "#ffc0db",
"surface-container-highest": "#292332",
"surface-variant": "#292332",
"background": "#100c16",
"inverse-on-surface": "#59535f",
"on-error-container": "#ffb2b9",
"primary-container": "#c185fd",
"on-surface-variant": "#b0a8b6",
"secondary-fixed-dim": "#ffabd1",
"surface-bright": "#302939",
"on-secondary-container": "#ffbeda",
"surface-container-lowest": "#000000",
"inverse-surface": "#fef7ff",
"tertiary": "#ffe083",
"surface-container": "#1c1724",
"secondary": "#f673b7"
},
fontFamily: {
"headline": ["Manrope"],
"body": ["Inter"],
"label": ["Plus Jakarta Sans"]
},
borderRadius: {"DEFAULT": "0.125rem", "lg": "0.25rem", "xl": "0.5rem", "full": "0.75rem"},
},
},
}
</script>
</head>
<body class="font-body text-on-surface">
<!-- Top Navigation -->
<header class="fixed top-0 right-0 left-0 flex items-center justify-between px-8 h-16 bg-surface/40 backdrop-blur-md border-b border-white/5 z-40">
<div class="flex items-center gap-8">
<nav class="flex gap-6 font-headline font-semibold tracking-wide uppercase text-xs">
<a class="text-slate-400 hover:text-purple-300 transition-colors" href="#">Dashboard</a>
<a class="text-slate-400 hover:text-purple-300 transition-colors" href="#">Enclos</a>
<a class="text-slate-400 hover:text-purple-300 transition-colors" href="#">Statistiques</a>
</nav>
</div>
<div class="flex items-center gap-4">
<button class="px-6 py-2 text-slate-400 font-headline font-semibold uppercase text-xs hover:text-white transition-colors">RÉINITIALISER</button>
<button class="px-8 py-2 bg-primary text-on-primary-fixed rounded-full font-headline font-extrabold uppercase text-xs tracking-widest shadow-lg shadow-primary/30">CALCULER</button>
</div>
</header>
<main class="mx-auto pt-24 p-8 space-y-12 max-w-[1600px]">
<!-- Inventaire Actuel Section -->
<section>
<div class="flex items-baseline justify-between mb-8">
<h2 class="text-3xl font-headline font-extrabold tracking-tight text-white">Inventaire Actuel</h2>
<span class="text-on-surface-variant font-label text-sm uppercase tracking-widest">348 Dragons au total</span>
</div>
<!-- Search and Filter Bar -->
<div class="flex flex-col md:flex-row gap-6 mb-10 items-end">
<div class="flex-1 w-full max-w-md">
<label class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-2 px-1">Rechercher une Dragodinde</label>
<div class="relative group">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-outline group-focus-within:text-primary transition-colors">search</span>
<input class="w-full bg-surface-container-high/60 border border-white/5 rounded-2xl py-3 pl-12 pr-4 text-sm text-white placeholder:text-outline focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all" placeholder="Nom du type..." type="text"/>
</div>
</div>
<div class="flex-1 w-full overflow-x-auto pb-1">
<label class="block text-[10px] font-bold text-on-surface-variant uppercase tracking-widest mb-2 px-1">Filtrer par Génération</label>
<div class="flex gap-2">
<button class="px-4 py-2 rounded-xl bg-primary text-on-primary-fixed font-bold text-xs whitespace-nowrap">TOUTES</button>
<button class="px-4 py-2 rounded-xl bg-surface-container-high/60 border border-white/5 text-on-surface-variant hover:text-white hover:bg-surface-container-highest transition-all font-bold text-xs whitespace-nowrap">GEN 1</button>
<button class="px-4 py-2 rounded-xl bg-surface-container-high/60 border border-white/5 text-on-surface-variant hover:text-white hover:bg-surface-container-highest transition-all font-bold text-xs whitespace-nowrap">GEN 2</button>
<button class="px-4 py-2 rounded-xl bg-surface-container-high/60 border border-white/5 text-on-surface-variant hover:text-white hover:bg-surface-container-highest transition-all font-bold text-xs whitespace-nowrap">GEN 3</button>
<button class="px-4 py-2 rounded-xl bg-surface-container-high/60 border border-white/5 text-on-surface-variant hover:text-white hover:bg-surface-container-highest transition-all font-bold text-xs whitespace-nowrap">GEN 4</button>
<button class="px-4 py-2 rounded-xl bg-surface-container-high/60 border border-white/5 text-on-surface-variant hover:text-white hover:bg-surface-container-highest transition-all font-bold text-xs whitespace-nowrap">GEN 5+</button>
</div>
</div>
<button class="flex items-center gap-2 px-6 py-3 bg-surface-container-high/60 border border-white/5 rounded-2xl text-on-surface-variant hover:text-white transition-all">
<span class="material-symbols-outlined text-sm">filter_list</span>
<span class="font-bold text-xs uppercase">Plus de filtres</span>
</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-8">
<!-- Dragon Card 1 -->
<div class="flex flex-col items-center group cursor-pointer">
<span class="mb-2 px-2 py-0.5 bg-surface-container-highest text-tertiary text-[10px] font-bold rounded-lg border border-tertiary/20">GEN 1</span>
<div class="w-24 h-24 rounded-full border-4 border-white overflow-hidden shadow-2xl group-hover:scale-105 transition-transform">
<img alt="Dragodinde Amande" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAk49rcwXwOwlhp8heebFzmYpdV9rucwpeWCdOtD5PjUxo3DVTHtGp6SySHbKTy4YV1FBRmcbHV45u84LCnN5vB8kNCQT2rqFxBo3OycJriQIaYdOVzEEYrFJF_lTVE-ncf2vdcNnzr_HgSGTDFyI68JfNUQHjnLGEEF_G2PWfFTbSLXM6fpeUDL9t2LgGZ4MTOGA6ul_Q1Bpkl9d84IBa2H4O_ZqKr3rtbZyV1UiYsJ9-u9qbV-HkGq6-BuFNKgWcXfmAvIuu3x96H"/>
</div>
<div class="mt-4 text-center">
<h4 class="font-headline font-bold text-white text-sm">Amande</h4>
<div class="flex gap-3 mt-1 text-xs font-medium text-on-surface-variant">
<span class="text-blue-400">♂ 12</span>
<span class="text-pink-400">♀ 8</span>
</div>
</div>
</div>
<!-- Dragon Card 2 -->
<div class="flex flex-col items-center group cursor-pointer">
<span class="mb-2 px-2 py-0.5 bg-surface-container-highest text-tertiary text-[10px] font-bold rounded-lg border border-tertiary/20">GEN 1</span>
<div class="w-24 h-24 rounded-full border-4 border-white overflow-hidden shadow-2xl group-hover:scale-105 transition-transform">
<img alt="Dragodinde Rousse" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBdavE5jSFTnOENKdZYBWDsqG8NuKUesnMVwACJRWX4fck7y1D2zBLFETt0UJeY_fxRcsAyxX9JMS0IEPJ7-rbduSrMFL292LRD5c4Rf6JlqIuHlD8fUJwGinBBh9D8eNGSW2QR1erRXIbDQxYlQpxBTPBl6i82rQ_Si_Z09f9FZrnm1qouXO8XXbCuhnAar5Rqic_siBxeyUBpV15iLPC5AVf70ryDarB0iex822LoTI25bx_k0X1FhANwIWK4KWzB_buBd7cTUkwH"/>
</div>
<div class="mt-4 text-center">
<h4 class="font-headline font-bold text-white text-sm">Rousse</h4>
<div class="flex gap-3 mt-1 text-xs font-medium text-on-surface-variant">
<span class="text-blue-400">♂ 15</span>
<span class="text-pink-400">♀ 11</span>
</div>
</div>
</div>
<!-- Dragon Card 3 -->
<div class="flex flex-col items-center group cursor-pointer">
<span class="mb-2 px-2 py-0.5 bg-surface-container-highest text-tertiary text-[10px] font-bold rounded-lg border border-tertiary/20">GEN 2</span>
<div class="w-24 h-24 rounded-full border-4 border-white overflow-hidden shadow-2xl group-hover:scale-105 transition-transform">
<img alt="Dragodinde Pourpre" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuATaZa2N5eXJds9_zkdVSkZc2Tj2Ep7UkXWBXJqfrlWvyzPhBcfALl--HbPAd9lMDueoHvs5g0c0Xl1VJqIh4-CqUUAZD9HcZodIhrVbalMy3pIls670GVGUqD7_pcQOz781mxaNjNdugNS7ENZ6st-sxmTy-9fiaBhIVrRliPAdsOjKBghTkIDKGTcLLVzk9GjXDcyRRYMavPkdXhmV49iMKcaclnWR3MRmPdm-lXZ1FuMs9ebwAl-7ebh9cVn0U2oZ6PawK26XgqW"/>
</div>
<div class="mt-4 text-center">
<h4 class="font-headline font-bold text-white text-sm">Pourpre</h4>
<div class="flex gap-3 mt-1 text-xs font-medium text-on-surface-variant">
<span class="text-blue-400">♂ 4</span>
<span class="text-pink-400">♀ 2</span>
</div>
</div>
</div>
<!-- Dragon Card 4 -->
<div class="flex flex-col items-center group cursor-pointer">
<span class="mb-2 px-2 py-0.5 bg-surface-container-highest text-tertiary text-[10px] font-bold rounded-lg border border-tertiary/20">GEN 3</span>
<div class="w-24 h-24 rounded-full border-4 border-white overflow-hidden shadow-2xl group-hover:scale-105 transition-transform">
<img alt="Dragodinde Emeraude" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCA8q9Y2f4f38tD4mOIbK9KGlQCkvQeJ5QNa6uf-_zRYYXRbTou2SbgLuIKOwlSrZ1xvPcUWCvj1r-Am9XAA1H5u9rjZABXFCsUb4phcVmTqMGGciMiDRBHZ0Gug8POHKYK0W_rGU3_FPa9xLlOygj6B5X4pDrcdhTfE2x5tkVtUHH2JE3dy8J1v0ONGuVBw1AZ2dVG35iGmauKOyot4pAHuFANeQT6AMZi6Vq1uRZIKPnzs78lNU3IJbxbBGDY-69_G-fCqP1-OoO1"/>
</div>
<div class="mt-4 text-center">
<h4 class="font-headline font-bold text-white text-sm">Émeraude</h4>
<div class="flex gap-3 mt-1 text-xs font-medium text-on-surface-variant">
<span class="text-blue-400">♂ 8</span>
<span class="text-pink-400">♀ 10</span>
</div>
</div>
</div>
<!-- Dragon Card 5 -->
<div class="flex flex-col items-center group cursor-pointer">
<span class="mb-2 px-2 py-0.5 bg-surface-container-highest text-tertiary text-[10px] font-bold rounded-lg border border-tertiary/20">GEN 4</span>
<div class="w-24 h-24 rounded-full border-4 border-white overflow-hidden shadow-2xl group-hover:scale-105 transition-transform">
<img alt="Dragodinde Ivoire" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuD4AVv_UMaKl5ydywMdMBN_odcH3RqemhzOnJiWGvN7OkfiruZUylLY-OnOC2CZXWxr-AwPfq4NFYOptcERp_C3RjbnGk6icgBMYgMwx-Vu8mfZnYDB4cMopMOVvmMA2peeof6Utp9p47PFffe5zJXJ8yDLrCwCwXdzGA4sSIOdTDAyekvOcG8my20pi_F-Jq75WvZJ3IVXNp3YGn6qskYBQbxYwwFyNzYkPfsbqh8sgQ10VrambO71-zinbVggxkd0l-CcJx3kUnHO"/>
</div>
<div class="mt-4 text-center">
<h4 class="font-headline font-bold text-white text-sm">Ivoire</h4>
<div class="flex gap-3 mt-1 text-xs font-medium text-on-surface-variant">
<span class="text-blue-400">♂ 2</span>
<span class="text-pink-400">♀ 1</span>
</div>
</div>
</div>
<!-- Dragon Card 6 -->
<div class="flex flex-col items-center group cursor-pointer">
<span class="mb-2 px-2 py-0.5 bg-surface-container-highest text-tertiary text-[10px] font-bold rounded-lg border border-tertiary/20">GEN 1</span>
<div class="w-24 h-24 rounded-full border-4 border-white overflow-hidden shadow-2xl group-hover:scale-105 transition-transform">
<img alt="Dragodinde Indigo" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAmywpQJgkDokcBzsndlzSasQswZ78gv18ctUwWFJNjJ1bp6XRjf53ruJE6G-4aOJVXqOo3TXVtZ_dvmyQPnQUcR3VbyGi8eCY3wZd_PCW-l0E-1IJdIVqylna4X2fE0jwiF_WDbzzi8LRtoYFp0-Qhnd04IKYvvn-LjW-cYWRlwkfeeA-Pke7JRirOHqB15yPEmdvrKJ40yTI6Bk9InHVgHGA90OXtKX-Z4E-8QvTgTtXTj5zKHnNe-TdxxWqlMPqQJZSGv0x1wl7w"/>
</div>
<div class="mt-4 text-center">
<h4 class="font-headline font-bold text-white text-sm">Indigo</h4>
<div class="flex gap-3 mt-1 text-xs font-medium text-on-surface-variant">
<span class="text-blue-400">♂ 5</span>
<span class="text-pink-400">♀ 3</span>
</div>
</div>
</div>
<!-- Dragon Card 7 -->
<div class="flex flex-col items-center group cursor-pointer">
<span class="mb-2 px-2 py-0.5 bg-surface-container-highest text-tertiary text-[10px] font-bold rounded-lg border border-tertiary/20">GEN 1</span>
<div class="w-24 h-24 rounded-full border-4 border-white overflow-hidden shadow-2xl group-hover:scale-105 transition-transform">
<img alt="Dragodinde Ébène" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBcVYO2jGvV-mgGh5iiiyUIimizjw6tJsUuK3F3qSz2lHurwhca8WBQX71xOa0P-BOK5Piov3CMbTLTSZ3WVuz_8PSIgsesLrr-8xtaH8-UTthkqg1d5gVtuIPpkLj_iyWHcKHWMho9qjMHYtjEpu7_RmEQy3ky6CYRhAqSPYRqnnu9nbQUDmgRiBJO3tZuVSqRmrlsZlCWmeFl017ub16K-6bPC3mmg3OJserIOpOOTBp9mNnZkoppFjdZXVi_-a9D0uFa4lgIUS6n"/>
</div>
<div class="mt-4 text-center">
<h4 class="font-headline font-bold text-white text-sm">Ébène</h4>
<div class="flex gap-3 mt-1 text-xs font-medium text-on-surface-variant">
<span class="text-blue-400">♂ 7</span>
<span class="text-pink-400">♀ 6</span>
</div>
</div>
</div>
<!-- Dragon Card 8 -->
<div class="flex flex-col items-center group cursor-pointer">
<span class="mb-2 px-2 py-0.5 bg-surface-container-highest text-tertiary text-[10px] font-bold rounded-lg border border-tertiary/20">GEN 2</span>
<div class="w-24 h-24 rounded-full border-4 border-white overflow-hidden shadow-2xl group-hover:scale-105 transition-transform">
<img alt="Dragodinde Turquoise" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAXH8EGWBBEmFntlQJMdD8kZnxM9Q2-GhLWQaV4EFdGkUi__hzsiUDmgHEIwYrYJW86IFXwFFEzlxVAfHqRQE8awwzvtqV-BiGAS4LGowzs3SJvM_699lEXodC-AkSMq4KsLxrjz9G89IGV0fhuoE27wRBwvRGr845dKoCmYmoLP8GK1cOiRV2a-WIjxGmp-3seZuDQ9UkC-Q_k_duSXg7-b3YBdP-FU60hb5ZuUzPQiTgrkZEhdmLfwsNdJD3ABENdaCQTcrvBzYqH"/>
</div>
<div class="mt-4 text-center">
<h4 class="font-headline font-bold text-white text-sm">Turquoise</h4>
<div class="flex gap-3 mt-1 text-xs font-medium text-on-surface-variant">
<span class="text-blue-400">♂ 5</span>
<span class="text-pink-400">♀ 2</span>
</div>
</div>
</div>
</div>
</section>
<!-- Calculateur de Croisements Section -->
<section class="glass-panel rounded-3xl p-10 border border-white/5">
<div class="flex items-center gap-4 mb-10">
<span class="material-symbols-outlined text-primary p-3 bg-primary/10 rounded-2xl">science</span>
<h2 class="text-2xl font-headline font-extrabold text-white">Calculateur de Croisements</h2>
</div>
<div class="space-y-6">
<!-- Step 1 -->
<div class="flex flex-col lg:flex-row items-center gap-8 bg-surface-container-low/40 p-6 rounded-2xl border border-white/[0.02]">
<div class="flex flex-col gap-2 min-w-[120px]">
<span class="text-[10px] font-bold uppercase tracking-widest text-primary-dim">Étape 01</span>
<h3 class="font-headline font-bold text-lg text-white">Parents Requis</h3>
</div>
<div class="flex flex-1 items-center justify-center gap-6">
<div class="flex space-x-4">
<div class="relative w-14 h-14 rounded-full border-2 border-primary overflow-hidden shadow-lg" title="Amande ♂ x3">
<img alt="Dragon" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCuG3w6EZAOoNzP6o5oX6TfRrjddr6QhL0KFGd8g0kHd8D_yz2okPRNSEwFVaqfkVYUiDHeplOESteJjGM8vZfaXV4AYhG8olI0iHx7f7YyPEtk7w9n_v6U38ruF2Pt8BM9rJf5pNRr-cIEfy8huYJaaUvNXD_Q9NNvhkQ7P_nS0w59bh0BbzAkLkYn9jrDpClqaszVk1sOzzj_qWIIK1UbQ9osFvcXGJPAUiEH0W94oGosGamNMSCaYbZnfMfWVAv-8aY7PcDLxwtT"/>
<div class="absolute inset-0 bg-black/40 flex items-center justify-center text-[10px] font-bold">♂ x3</div>
</div>
<div class="relative w-14 h-14 rounded-full border-2 border-primary overflow-hidden shadow-lg" title="Rousse ♀ x3">
<img alt="Dragon" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAxJAsvx2ewZiHBj9hoP-mueRFml_SfpKIuIjxGGxcTP9wI2ZOyXxf-HbvynPsN_HkFk_HIfGGQGWDxvv7MVWkDBT8LT7I3dh9U8CkgFnuGEQvgkV1O5oHhLZ8iUEFjLUmWkg6gqRaEdrlZSgJaYColFBvlZxG7aGRC9VoqprlvlIBcCY08xAglHoJ16wtEq1kD4zbl_OSlRiIiFqM7j5EV9xq-CKYd6lXFF1c0ARkMdCpOmckhZ9bA7MIlzD5e-5GQywcMN36ePE7n"/>
<div class="absolute inset-0 bg-black/40 flex items-center justify-center text-[10px] font-bold">♀ x3</div>
</div>
</div>
<span class="material-symbols-outlined text-on-surface-variant">arrow_forward</span>
<div class="flex flex-col items-center gap-1">
<span class="text-[10px] font-bold text-on-surface-variant">RÉSULTAT</span>
<div class="w-14 h-14 rounded-full border-2 border-secondary overflow-hidden shadow-lg">
<img alt="Résultat" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuD_NB67axTiAn461kidHCU_vmcEkuLSxps7d80A0tZe3_2anRUPDasqqP8vhICUU9Xeu37oW4Aco_NbFYp8PPPWN16g9N-IUZuMK5rRy7Ct-gShT90pOYMYr0gQEj8YVD8jTbgvEnA3WJD4VU7hLzCsl2c97oUAJElfR0PGR31eiAF1HTTk09VrYYU7ENF03mp2uud6EZOPNphimnvPHMhjqznOT-du68Q3-Xt9kMrV2SE_8yc2BhxbLofygIUV5RtLyrQuWPr8zVk8"/>
</div>
<span class="text-[10px] font-bold text-white">Amande &amp; Rousse</span>
</div>
</div>
<div class="flex items-center gap-6 pl-8 lg:border-l border-white/5">
<label class="flex items-center gap-3 cursor-pointer">
<input checked="" class="w-5 h-5 rounded border-outline-variant bg-surface-container text-primary focus:ring-primary/20" type="checkbox"/>
<span class="text-sm font-label text-on-surface-variant">Reproducteur</span>
</label>
<div class="flex flex-col gap-1">
<span class="text-[10px] font-bold text-on-surface-variant uppercase">Nb couples</span>
<input class="w-20 bg-surface-container-high border-none rounded-lg text-white font-bold text-center py-1" type="number" value="3"/>
</div>
</div>
</div>
<!-- Step 2 -->
<div class="flex flex-col lg:flex-row items-center gap-8 bg-surface-container-low/40 p-6 rounded-2xl border border-white/[0.02]">
<div class="flex flex-col gap-2 min-w-[120px]">
<span class="text-[10px] font-bold uppercase tracking-widest text-primary-dim">Étape 02</span>
<h3 class="font-headline font-bold text-lg text-white">Parents Requis</h3>
</div>
<div class="flex flex-1 items-center justify-center gap-6">
<div class="flex space-x-4">
<div class="relative w-14 h-14 rounded-full border-2 border-primary overflow-hidden shadow-lg">
<img alt="Dragon" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAmywpQJgkDokcBzsndlzSasQswZ78gv18ctUwWFJNjJ1bp6XRjf53ruJE6G-4aOJVXqOo3TXVtZ_dvmyQPnQUcR3VbyGi8eCY3wZd_PCW-l0E-1IJdIVqylna4X2fE0jwiF_WDbzzi8LRtoYFp0-Qhnd04IKYvvn-LjW-cYWRlwkfeeA-Pke7JRirOHqB15yPEmdvrKJ40yTI6Bk9InHVgHGA90OXtKX-Z4E-8QvTgTtXTj5zKHnNe-TdxxWqlMPqQJZSGv0x1wl7w"/>
<div class="absolute inset-0 bg-black/40 flex items-center justify-center text-[10px] font-bold">♂ x1</div>
</div>
<div class="relative w-14 h-14 rounded-full border-2 border-primary overflow-hidden shadow-lg">
<img alt="Dragon" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBcVYO2jGvV-mgGh5iiiyUIimizjw6tJsUuK3F3qSz2lHurwhca8WBQX71xOa0P-BOK5Piov3CMbTLTSZ3WVuz_8PSIgsesLrr-8xtaH8-UTthkqg1d5gVtuIPpkLj_iyWHcKHWMho9qjMHYtjEpu7_RmEQy3ky6CYRhAqSPYRqnnu9nbQUDmgRiBJO3tZuVSqRmrlsZlCWmeFl017ub16K-6bPC3mmg3OJserIOpOOTBp9mNnZkoppFjdZXVi_-a9D0uFa4lgIUS6n"/>
<div class="absolute inset-0 bg-black/40 flex items-center justify-center text-[10px] font-bold">♀ x1</div>
</div>
</div>
<span class="material-symbols-outlined text-on-surface-variant">arrow_forward</span>
<div class="flex flex-col items-center gap-1">
<span class="text-[10px] font-bold text-on-surface-variant">RÉSULTAT</span>
<div class="w-14 h-14 rounded-full border-2 border-secondary overflow-hidden shadow-lg">
<img alt="Résultat" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCKZdwb71qmK2SBqKl5oit3pzD1ruttmWA75mUdp1nxJJffPF4SxTU5X5PKP6U5xpqSpGz7oJKtpAoWbN1FG8yZoH6tefDkzRr1vJg4ZyX3LK6U1n1hNj3n7k3iyroUjhXS2x8hwHlxz1PWIPgxjOy-v_8NPnS72_g9iGWE-SgrCBYxlUAjHZTnppNnmYPxLES6sTjjJ8RBc4-Wn_OUmarh7UYdKxAx7ltOmb5wniF3d7LbtnZr4X90UPbJeeLDWPOobxk2dnBxTYyg"/>
</div>
<span class="text-[10px] font-bold text-white">Indigo &amp; Ébène</span>
</div>
</div>
<div class="flex items-center gap-6 pl-8 lg:border-l border-white/5">
<label class="flex items-center gap-3 cursor-pointer">
<input class="w-5 h-5 rounded border-outline-variant bg-surface-container text-primary focus:ring-primary/20" type="checkbox"/>
<span class="text-sm font-label text-on-surface-variant">Reproducteur</span>
</label>
<div class="flex flex-col gap-1">
<span class="text-[10px] font-bold text-on-surface-variant uppercase">Nb couples</span>
<input class="w-20 bg-surface-container-high border-none rounded-lg text-white font-bold text-center py-1 opacity-50" type="number" value="1"/>
</div>
</div>
</div>
</div>
</section>
<!-- Dragodindes Restantes Section -->
<section>
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-headline font-extrabold text-white">Dragodindes Restantes</h3>
<span class="text-xs font-label text-secondary tracking-widest uppercase bg-secondary/10 px-4 py-1 rounded-full">Non-utilisées dans le plan</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div class="flex items-center gap-4 p-4 bg-surface-container-high/40 rounded-2xl border border-white/[0.03]">
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-white/20">
<img alt="Dragon" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAXH8EGWBBEmFntlQJMdD8kZnxM9Q2-GhLWQaV4EFdGkUi__hzsiUDmgHEIwYrYJW86IFXwFFEzlxVAfHqRQE8awwzvtqV-BiGAS4LGowzs3SJvM_699lEXodC-AkSMq4KsLxrjz9G89IGV0fhuoE27wRBwvRGr845dKoCmYmoLP8GK1cOiRV2a-WIjxGmp-3seZuDQ9UkC-Q_k_duSXg7-b3YBdP-FU60hb5ZuUzPQiTgrkZEhdmLfwsNdJD3ABENdaCQTcrvBzYqH"/>
</div>
<div>
<p class="font-bold text-sm text-white">Turquoise</p>
<p class="text-[10px] font-bold text-on-surface-variant">♂ 5 ♀ 2</p>
</div>
</div>
<div class="flex items-center gap-4 p-4 bg-surface-container-high/40 rounded-2xl border border-white/[0.03]">
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-white/20">
<img alt="Dragon" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuB8DuxGlfBtFO61gE3PgrJVPiledqR6SzOdgtDSfykBZmHeudgLIIo8ziEwGnCv9-jc88k3I7alDZs05E_Zsbw6dKVxZOYlbLI36T4U8RCRWj5XCapupE5tttpzdazWE3N9QW8skSBAqVLdm5E0iHyEdN2Wsr-9rv9dlfvNq6mP5C4-iqgYQ-OLfNVnsDGbKyzToR8R3J6UiHQZ688dPVLYdl5uN04Y4uumFuJJh8oLCEJPf9msOo1hqGybjVt2UfbllRLpUpDvpME1"/>
</div>
<div>
<p class="font-bold text-sm text-white">Orchidée</p>
<p class="text-[10px] font-bold text-on-surface-variant">♂ 1 ♀ 4</p>
</div>
</div>
<div class="flex items-center gap-4 p-4 bg-surface-container-high/40 rounded-2xl border border-white/[0.03]">
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-white/20">
<img alt="Dragon" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCXU9A9BJspohmUXlHNOkzHzLoAM_VD5wlriXoSCUzsx32ZVJSIFzcRUpDeLfs9nl-xkdckB2F54JzMSJxWLuX-LrgXgUrGe_lXGFUF0AwE8-7bJ6oY2Kgazk-rE7YAVBEmEv8nWeBTt3wJCzypXQICQP0MTeFVWVLGtY5GUlskyzbNovJdW5AWA9AtnTMJvgLpqV5Cx7uE2ZUJZkYykHAb08AG7HNCmMCLeJ1VDfExCTWEINbD0dVXx7rYXzdHgb4tK8A4250BY2DE"/>
</div>
<div>
<p class="font-bold text-sm text-white">Prune</p>
<p class="text-[10px] font-bold text-on-surface-variant">♂ 3 ♀ 0</p>
</div>
</div>
<div class="flex items-center gap-4 p-4 bg-surface-container-high/40 rounded-2xl border border-white/[0.03]">
<div class="w-12 h-12 rounded-full overflow-hidden border-2 border-white/20">
<img alt="Dragon" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAn7-zVVKN9M5vbMW0UFqJBiKqpZim4gN9pNeMConK0hKsH5N4h2ozIIjCP2lozPWCWL3GIgiLIgVPsSmc2IwFMqCEOLCZ4iJnPXclMmCV9tWKh_MzrE-eee1gRb4u0bPMPflT8xWQyVlB4UH8epaj-oUIkdNOrpC2EcHmHLZDrAlEe2Bj-ArkusbMDgGjLfjHPmAmFpYKa4lc1M3iF8vnI7k1iEqCU0iGDBYkCqjGaW1etIh8OatJBth46wxH4KyMoQyRIBThikHtl"/>
</div>
<div>
<p class="font-bold text-sm text-white">Dorée</p>
<p class="text-[10px] font-bold text-on-surface-variant">♂ 0 ♀ 2</p>
</div>
</div>
</div>
</section>
</main>
</body></html>

BIN
refonte_graphique/inventaire.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

View File

@ -0,0 +1,342 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Obsidian Amethyst - Guide de Style</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&amp;family=Inter:wght@100..900&amp;family=Plus+Jakarta+Sans:wght@200..800&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"on-primary": "#46007c",
"tertiary-fixed-dim": "#eec200",
"secondary-dim": "#f271b5",
"surface-container-lowest": "#000000",
"on-tertiary": "#645000",
"on-primary-fixed-variant": "#430077",
"outline": "#7a7380",
"surface-container-low": "#16111d",
"on-secondary-container": "#ffbeda",
"secondary": "#f673b7",
"primary-dim": "#be83fa",
"on-surface-variant": "#b0a8b6",
"surface-tint": "#cb97ff",
"tertiary-fixed": "#fed01b",
"on-primary-fixed": "#000000",
"on-secondary-fixed-variant": "#922164",
"on-primary-container": "#360061",
"inverse-surface": "#fef7ff",
"surface-container-high": "#231d2b",
"secondary-container": "#85145a",
"error": "#ff6e84",
"surface-container": "#1c1724",
"secondary-fixed-dim": "#ffabd1",
"primary-fixed": "#c185fd",
"surface": "#100c16",
"surface-bright": "#302939",
"on-secondary": "#4a002f",
"on-tertiary-container": "#594700",
"tertiary": "#ffe083",
"error-dim": "#d73357",
"on-error": "#490013",
"error-container": "#a70138",
"surface-container-highest": "#292332",
"tertiary-container": "#fed01b",
"inverse-primary": "#7c41b5",
"background": "#100c16",
"secondary-fixed": "#ffc0db",
"primary": "#cb97ff",
"on-tertiary-fixed": "#433500",
"on-error-container": "#ffb2b9",
"tertiary-dim": "#eec200",
"on-secondary-fixed": "#690045",
"primary-fixed-dim": "#b378ef",
"on-background": "#f1e8f7",
"inverse-on-surface": "#59535f",
"on-surface": "#f1e8f7",
"surface-dim": "#100c16",
"surface-variant": "#292332",
"primary-container": "#c185fd",
"on-tertiary-fixed-variant": "#645000",
"outline-variant": "#4b4652"
},
fontFamily: {
"headline": ["Manrope"],
"body": ["Inter"],
"label": ["Plus Jakarta Sans"]
},
borderRadius: {"DEFAULT": "0.125rem", "lg": "0.25rem", "xl": "0.5rem", "full": "0.75rem"},
},
},
}
</script>
<style>
body {
background: radial-gradient(circle at top right, #1e1b4b, #0a0a0f);
min-height: 100vh;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.glass-card {
background: rgba(41, 35, 50, 0.6);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
}
.no-line-rule { border: none !important; }
</style>
</head>
<body class="font-body text-on-surface">
<!-- TopNavBar -->
<header class="fixed top-0 w-full z-50 bg-[#1c1724]/60 backdrop-blur-xl shadow-[0_24px_40px_rgba(0,0,0,0.15)] flex justify-between items-center px-8 h-20 w-full">
<div class="text-2xl font-black tracking-tighter text-[#cb97ff] uppercase font-headline">Obsidian Amethyst</div>
<nav class="hidden md:flex gap-8 items-center">
<a class="font-headline font-bold text-lg tracking-tight text-[#cb97ff] border-b-2 border-[#cb97ff] pb-1 hover:text-[#f1e8f7] transition-colors duration-300" href="#">Foundations</a>
<a class="font-headline font-bold text-lg tracking-tight text-[#b0a8b6] font-medium hover:text-[#f1e8f7] transition-colors duration-300" href="#">Components</a>
<a class="font-headline font-bold text-lg tracking-tight text-[#b0a8b6] font-medium hover:text-[#f1e8f7] transition-colors duration-300" href="#">Tokens</a>
<a class="font-headline font-bold text-lg tracking-tight text-[#b0a8b6] font-medium hover:text-[#f1e8f7] transition-colors duration-300" href="#">Patterns</a>
</nav>
<div class="flex items-center gap-6">
<span class="material-symbols-outlined text-[#cb97ff] cursor-pointer active:scale-95 transition-transform">contrast</span>
<span class="material-symbols-outlined text-[#cb97ff] cursor-pointer active:scale-95 transition-transform">settings</span>
<div class="w-10 h-10 rounded-full overflow-hidden bg-surface-container-highest">
<img alt="User profile avatar" data-alt="close up of a digital minimalist avatar with violet and black highlights on a dark background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuB7tEgejfWyafFzblGSBwbCNGUVd0T7F2o_Cg1fQ7pnUxXPhw698nGv5VnibVMIVgoKT3sS-arThlXa207nOmTzTe-NtWVrTHeD2v9vvoNkPO_pMRf5Ksx27BQ5crLqdGO2reAwUy9dDntX2Qrez_DReI0HObXCWOPIXdGpSvhjChyNtBwmpuBX9lNi8XDT9XFx2tbvKWQCiAPR5C_kj-a80WE_RD3F2qWXD5S3jk1ivu3Epd7aqUoNpI1tRjtTz3flPkNHylNj3QP4"/>
</div>
</div>
</header>
<!-- SideNavBar -->
<aside class="fixed left-0 top-0 h-screen w-64 z-40 bg-[#1c1724]/60 backdrop-blur-2xl shadow-2xl flex flex-col h-full py-8 hidden md:flex">
<div class="px-8 mb-12 mt-20">
<div class="flex items-center gap-3 mb-1">
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-on-primary">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">auto_awesome</span>
</div>
<h2 class="text-[#f1e8f7] font-bold text-xl">Design System</h2>
</div>
<p class="text-xs text-on-surface-variant uppercase tracking-widest font-label">v1.0.4</p>
</div>
<nav class="flex-1 space-y-1">
<div class="flex items-center gap-3 bg-gradient-to-r from-[#cb97ff]/20 to-transparent text-[#cb97ff] border-l-4 border-[#cb97ff] px-6 py-4 font-['Inter'] text-sm font-semibold cursor-pointer transition-all active:translate-x-1">
<span class="material-symbols-outlined">palette</span>
<span>Colors</span>
</div>
<div class="flex items-center gap-3 text-[#b0a8b6] px-6 py-4 font-['Inter'] text-sm font-semibold hover:bg-[#292332] hover:text-[#f1e8f7] transition-all cursor-pointer active:translate-x-1">
<span class="material-symbols-outlined">text_fields</span>
<span>Typography</span>
</div>
<div class="flex items-center gap-3 text-[#b0a8b6] px-6 py-4 font-['Inter'] text-sm font-semibold hover:bg-[#292332] hover:text-[#f1e8f7] transition-all cursor-pointer active:translate-x-1">
<span class="material-symbols-outlined">layers</span>
<span>Elevation</span>
</div>
<div class="flex items-center gap-3 text-[#b0a8b6] px-6 py-4 font-['Inter'] text-sm font-semibold hover:bg-[#292332] hover:text-[#f1e8f7] transition-all cursor-pointer active:translate-x-1">
<span class="material-symbols-outlined">grid_view</span>
<span>Layout</span>
</div>
<div class="flex items-center gap-3 text-[#b0a8b6] px-6 py-4 font-['Inter'] text-sm font-semibold hover:bg-[#292332] hover:text-[#f1e8f7] transition-all cursor-pointer active:translate-x-1">
<span class="material-symbols-outlined">auto_awesome</span>
<span>Motion</span>
</div>
</nav>
<div class="mt-auto px-6 space-y-4">
<div class="flex items-center gap-3 text-[#b0a8b6] px-6 py-4 font-['Inter'] text-sm font-semibold hover:bg-[#292332] hover:text-[#f1e8f7] transition-all cursor-pointer">
<span class="material-symbols-outlined">description</span>
<span>Documentation</span>
</div>
<button class="w-full py-3 bg-primary text-on-primary rounded-xl font-bold hover:brightness-110 transition-all shadow-lg shadow-primary/20">
Download Assets
</button>
</div>
</aside>
<!-- Main Content Canvas -->
<main class="md:ml-64 pt-28 px-8 pb-12">
<!-- Header Section -->
<section class="mb-16">
<h1 class="font-headline font-extrabold text-5xl tracking-tighter text-on-surface mb-2">Guide de Style</h1>
<p class="text-on-surface-variant font-label text-lg">Spécifications visuelles et identité de l'archive Obsidian.</p>
</section>
<!-- Bento Grid for Sections -->
<div class="grid grid-cols-1 xl:grid-cols-12 gap-8">
<!-- 1. Color Palette Section -->
<section class="xl:col-span-8 flex flex-col gap-6">
<div class="flex items-center gap-4 mb-2">
<span class="material-symbols-outlined text-primary text-3xl">palette</span>
<h2 class="font-headline font-bold text-2xl text-on-surface">Palette de Couleurs</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Primary Amethyst -->
<div class="bg-surface-container rounded-xl overflow-hidden shadow-2xl group transition-all duration-300">
<div class="h-32 bg-[#8B5CF6] group-hover:brightness-110 transition-all"></div>
<div class="p-4">
<p class="font-label text-xs uppercase tracking-widest text-on-surface-variant mb-1">Améthyste Primaire</p>
<div class="flex justify-between items-center">
<span class="font-headline font-bold text-on-surface">#8B5CF6</span>
<span class="material-symbols-outlined text-on-surface-variant text-sm cursor-pointer hover:text-primary">content_copy</span>
</div>
</div>
</div>
<!-- Amethyst Variant -->
<div class="bg-surface-container rounded-xl overflow-hidden shadow-2xl group transition-all duration-300">
<div class="h-32 bg-[#CB97FF] group-hover:brightness-110 transition-all"></div>
<div class="p-4">
<p class="font-label text-xs uppercase tracking-widest text-on-surface-variant mb-1">Améthyste Éclat</p>
<div class="flex justify-between items-center">
<span class="font-headline font-bold text-on-surface">#CB97FF</span>
<span class="material-symbols-outlined text-on-surface-variant text-sm cursor-pointer hover:text-primary">content_copy</span>
</div>
</div>
</div>
<!-- Secondary Pink -->
<div class="bg-surface-container rounded-xl overflow-hidden shadow-2xl group transition-all duration-300">
<div class="h-32 bg-[#F673B7] group-hover:brightness-110 transition-all"></div>
<div class="p-4">
<p class="font-label text-xs uppercase tracking-widest text-on-surface-variant mb-1">Rose Secondaire</p>
<div class="flex justify-between items-center">
<span class="font-headline font-bold text-on-surface">#F673B7</span>
<span class="material-symbols-outlined text-on-surface-variant text-sm cursor-pointer hover:text-primary">content_copy</span>
</div>
</div>
</div>
<!-- Deep Background -->
<div class="bg-surface-container rounded-xl overflow-hidden shadow-2xl group transition-all duration-300">
<div class="h-32 bg-[#0A0A0F] group-hover:brightness-110 transition-all"></div>
<div class="p-4">
<p class="font-label text-xs uppercase tracking-widest text-on-surface-variant mb-1">Noir Profond</p>
<div class="flex justify-between items-center">
<span class="font-headline font-bold text-on-surface">#0A0A0F</span>
<span class="material-symbols-outlined text-on-surface-variant text-sm cursor-pointer hover:text-primary">content_copy</span>
</div>
</div>
</div>
<!-- Surface Deep -->
<div class="bg-surface-container rounded-xl overflow-hidden shadow-2xl group transition-all duration-300">
<div class="h-32 bg-[#1C1724] group-hover:brightness-110 transition-all"></div>
<div class="p-4">
<p class="font-label text-xs uppercase tracking-widest text-on-surface-variant mb-1">Surface Obsidian</p>
<div class="flex justify-between items-center">
<span class="font-headline font-bold text-on-surface">#1C1724</span>
<span class="material-symbols-outlined text-on-surface-variant text-sm cursor-pointer hover:text-primary">content_copy</span>
</div>
</div>
</div>
<!-- UI Grey -->
<div class="bg-surface-container rounded-xl overflow-hidden shadow-2xl group transition-all duration-300">
<div class="h-32 bg-[#4B4652] group-hover:brightness-110 transition-all"></div>
<div class="p-4">
<p class="font-label text-xs uppercase tracking-widest text-on-surface-variant mb-1">Gris d'Interface</p>
<div class="flex justify-between items-center">
<span class="font-headline font-bold text-on-surface">#4B4652</span>
<span class="material-symbols-outlined text-on-surface-variant text-sm cursor-pointer hover:text-primary">content_copy</span>
</div>
</div>
</div>
</div>
</section>
<!-- 2. Typography Section -->
<section class="xl:col-span-4 glass-card rounded-xl p-8 shadow-2xl border-none">
<div class="flex items-center gap-4 mb-8">
<span class="material-symbols-outlined text-secondary text-3xl">text_fields</span>
<h2 class="font-headline font-bold text-2xl text-on-surface">Typographie</h2>
</div>
<div class="space-y-12">
<div>
<p class="text-xs text-on-surface-variant uppercase tracking-widest mb-4 font-label">Police Headline: Manrope</p>
<div class="space-y-4">
<h1 class="font-headline font-extrabold text-4xl tracking-tighter">Affichage Large</h1>
<h2 class="font-headline font-bold text-2xl tracking-tight">Titre de Section</h2>
<h3 class="font-headline font-semibold text-xl tracking-tight">Sous-titre informatif</h3>
</div>
</div>
<div>
<p class="text-xs text-on-surface-variant uppercase tracking-widest mb-4 font-label">Police Body: Inter</p>
<div class="space-y-4">
<p class="font-body text-base text-on-surface leading-relaxed">
Le corps de texte utilise Inter pour une lisibilité maximale. Idéal pour les descriptions longues et les données complexes.
</p>
</div>
</div>
<div>
<p class="text-xs text-on-surface-variant uppercase tracking-widest mb-4 font-label">Police Label: Plus Jakarta Sans</p>
<div class="space-y-2">
<p class="font-label text-sm font-medium text-on-surface">Metadata et petits détails</p>
<p class="font-label text-xs uppercase tracking-widest text-on-surface-variant">Boutons et Indicateurs</p>
</div>
</div>
</div>
</section>
<!-- 3. Components Preview Section -->
<section class="xl:col-span-12 mt-8">
<div class="flex items-center gap-4 mb-8">
<span class="material-symbols-outlined text-tertiary text-3xl">widgets</span>
<h2 class="font-headline font-bold text-2xl text-on-surface">Composants &amp; Glassmorphism</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<!-- Glass Card Example -->
<div class="glass-card p-8 rounded-xl relative overflow-hidden group">
<div class="absolute -right-10 -top-10 w-32 h-32 bg-primary/20 rounded-full blur-3xl transition-all duration-500 group-hover:bg-primary/40"></div>
<span class="material-symbols-outlined text-primary mb-4 text-4xl" style="font-variation-settings: 'FILL' 1;">diamond</span>
<h3 class="font-headline font-bold text-xl mb-3">Carte Vitrée</h3>
<p class="text-on-surface-variant text-sm mb-6 leading-relaxed">Illustration du style glassmorphism avec flou d'arrière-plan et bordures fantômes subtiles.</p>
<div class="w-full h-1.5 bg-surface-container-highest rounded-full overflow-hidden">
<div class="w-2/3 h-full bg-gradient-to-r from-primary to-primary-container shadow-[0_0_12px_rgba(203,151,255,0.4)]"></div>
</div>
<div class="mt-2 flex justify-between">
<span class="text-[10px] font-label uppercase text-on-surface-variant">Progression</span>
<span class="text-[10px] font-label font-bold text-primary">66%</span>
</div>
</div>
<!-- Buttons Section -->
<div class="bg-surface-container p-8 rounded-xl">
<h3 class="font-headline font-bold text-xl mb-6">Boutons &amp; Actions</h3>
<div class="space-y-4">
<button class="w-full py-4 bg-gradient-to-r from-primary to-primary-container text-on-primary font-bold rounded-xl active:scale-95 transition-all shadow-xl shadow-primary/10">
Action Principale
</button>
<button class="w-full py-4 bg-secondary-container text-secondary-fixed font-bold rounded-xl active:scale-95 transition-all flex items-center justify-center gap-2">
<span class="material-symbols-outlined">bolt</span>
Action Secondaire
</button>
<div class="flex gap-2">
<button class="flex-1 py-3 bg-surface-variant/40 hover:bg-surface-variant text-on-surface-variant font-semibold rounded-xl transition-all text-sm">Annuler</button>
<button class="flex-1 py-3 bg-error/10 text-error hover:bg-error/20 font-semibold rounded-xl transition-all text-sm">Supprimer</button>
</div>
</div>
</div>
<!-- Visual Assets / Imagery Style -->
<div class="bg-surface-container rounded-xl overflow-hidden relative group">
<img alt="Imagery Style" class="w-full h-full object-cover opacity-60 group-hover:scale-110 transition-transform duration-700" data-alt="abstract flowing silk textures in deep purple and magenta tones with cinematic atmospheric lighting and soft shadows" src="https://lh3.googleusercontent.com/aida-public/AB6AXuACsNwGWZEtZWVb6zrAF8BxLMSCA7dslwDZV3Ge4P7A79P0aI4dDPzDPZBdldlqbxTeMJjBQWUwQ7aHT8MEOIgASFSLm2fl4LjGLM_VhVpJR4_non8giL3x04BXtndSWE66YMW4HdilOW0MRuhT9-hE4kD48dPbEfb3YUzAC6wpITiaqCTcaHDqEqITdj_Zd76rcJhVskVYOh99YGyco8HT26C7k4Ld_XJUbiQIUn1bMjaI1RKPKrCXpk_vwTkA2pQTr-I-egKxZXuY"/>
<div class="absolute inset-0 bg-gradient-to-t from-[#100c16] via-transparent to-transparent p-8 flex flex-col justify-end">
<h3 class="font-headline font-bold text-xl text-[#f1e8f7]">Style Imagerie</h3>
<p class="text-on-surface-variant text-sm mt-1">Sombres, cinématiques, avec accents néon.</p>
</div>
</div>
</div>
</section>
</div>
<!-- Footer Export Branding -->
<footer class="mt-20 pt-12 border-t border-outline-variant/10 flex flex-col md:flex-row justify-between items-center gap-8">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-surface-container rounded-full flex items-center justify-center text-primary font-black text-xl">Ω</div>
<div>
<p class="font-headline font-bold text-on-surface">Obsidian Amethyst System</p>
<p class="text-xs text-on-surface-variant font-label">Propriété de l'Archive Améthyste © 2024</p>
</div>
</div>
<div class="flex gap-4">
<div class="px-6 py-2 bg-surface-container-highest rounded-full text-xs font-bold text-on-surface tracking-wider uppercase flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-secondary"></span>
Export Prêt
</div>
<div class="px-6 py-2 bg-surface-container-highest rounded-full text-xs font-bold text-on-surface tracking-wider uppercase">
A3 / Digital Standard
</div>
</div>
</footer>
</main>
<!-- Contextual FAB Suppression: Hide for Guide/Style screens as per rules -->
</body></html>

215
refonte_graphique/parametres.html Executable file
View File

@ -0,0 +1,215 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;700;800&amp;family=Inter:wght@400;500;600&amp;family=Plus+Jakarta+Sans:wght@400;500;600&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"inverse-primary": "#7c41b5",
"error-container": "#a70138",
"outline": "#7a7380",
"on-secondary-container": "#ffbeda",
"tertiary-container": "#fed01b",
"surface-bright": "#302939",
"tertiary-dim": "#eec200",
"primary-fixed-dim": "#b378ef",
"on-tertiary-fixed": "#433500",
"secondary-container": "#85145a",
"primary-fixed": "#c185fd",
"outline-variant": "#4b4652",
"on-primary-fixed-variant": "#430077",
"on-error": "#490013",
"surface-container-lowest": "#000000",
"primary": "#cb97ff",
"on-primary-container": "#360061",
"surface-container-low": "#16111d",
"inverse-surface": "#fef7ff",
"background": "#100c16",
"on-secondary-fixed": "#690045",
"on-secondary": "#4a002f",
"on-secondary-fixed-variant": "#922164",
"on-tertiary-fixed-variant": "#645000",
"secondary-fixed-dim": "#ffabd1",
"on-background": "#f1e8f7",
"tertiary-fixed": "#fed01b",
"inverse-on-surface": "#59535f",
"on-tertiary": "#645000",
"secondary-fixed": "#ffc0db",
"secondary-dim": "#f271b5",
"on-primary-fixed": "#000000",
"tertiary-fixed-dim": "#eec200",
"surface-tint": "#cb97ff",
"on-primary": "#46007c",
"secondary": "#f673b7",
"surface": "#100c16",
"on-surface-variant": "#b0a8b6",
"surface-dim": "#100c16",
"on-tertiary-container": "#594700",
"primary-container": "#c185fd",
"surface-variant": "#292332",
"on-surface": "#f1e8f7",
"error-dim": "#d73357",
"surface-container-highest": "#292332",
"on-error-container": "#ffb2b9",
"tertiary": "#ffe083",
"error": "#ff6e84",
"primary-dim": "#be83fa",
"surface-container-high": "#231d2b",
"surface-container": "#1c1724"
},
fontFamily: {
"headline": ["Manrope"],
"body": ["Inter"],
"label": ["Plus Jakarta Sans"]
},
borderRadius: {"DEFAULT": "0.125rem", "lg": "0.25rem", "xl": "0.5rem", "full": "0.75rem"},
},
},
}
</script>
<style>
body {
background: radial-gradient(circle at top right, #1e1b4b, #0a0a0f);
min-height: 100vh;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.glass-panel {
background: rgba(28, 23, 36, 0.6);
backdrop-filter: blur(40px);
}
</style>
</head>
<body class="font-body text-on-surface selection:bg-primary selection:text-on-primary">
<!-- TopNavBar -->
<header class="w-full top-0 sticky bg-[#1c1724]/60 backdrop-blur-xl shadow-[0_24px_40px_rgba(0,0,0,0.15)] z-50">
<div class="flex justify-between items-center px-8 h-16 w-full max-w-none">
<nav class="hidden md:flex items-center space-x-8 h-full">
<a class="text-[#b0a8b6] hover:text-[#f1e8f7] transition-colors font-headline font-bold text-lg h-full flex items-center" href="#">Tableau de bord</a>
<a class="text-[#b0a8b6] hover:text-[#f1e8f7] transition-colors font-headline font-bold text-lg h-full flex items-center" href="#">Enclos</a>
<a class="text-[#b0a8b6] hover:text-[#f1e8f7] transition-colors font-headline font-bold text-lg h-full flex items-center" href="#">Statistiques</a>
</nav>
<div class="flex items-center space-x-4">
</div>
</div>
</header>
<main class="max-w-6xl mx-auto px-8 py-12">
<div class="mb-12">
<h1 class="font-headline font-extrabold text-5xl text-on-surface tracking-tight mb-2">Paramètres</h1>
<p class="font-label text-on-surface-variant text-base">Personnalisez votre expérience au sein du coffre-fort éthéré.</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- Left Column: Navigation Summary (Desktop) -->
<div class="lg:col-span-4 space-y-6">
<div class="surface-container rounded-xl p-6 border border-outline-variant/15">
<nav class="space-y-2">
<button class="w-full flex items-center space-x-3 px-4 py-3 bg-surface-variant rounded-xl text-primary font-medium">
<span class="material-symbols-outlined">tune</span>
<span>Général</span>
</button>
<button class="w-full flex items-center space-x-3 px-4 py-3 hover:bg-surface-variant/40 rounded-xl text-on-surface-variant transition-all">
<span class="material-symbols-outlined">notifications_active</span>
<span>Alerte &amp; Rappels</span>
</button>
<button class="w-full flex items-center space-x-3 px-4 py-3 hover:bg-surface-variant/40 rounded-xl text-on-surface-variant transition-all">
<span class="material-symbols-outlined">shield</span>
<span>Confidentialité</span>
</button>
</nav>
</div>
</div>
<!-- Right Column: Settings Content -->
<div class="lg:col-span-8 space-y-8">
<!-- Section 1: Son de l'alarme -->
<section class="surface-container rounded-xl p-8 border border-outline-variant/15">
<div class="flex items-center space-x-3 mb-6">
<span class="material-symbols-outlined text-primary">campaign</span>
<h2 class="font-headline font-bold text-2xl">Son de l'alarme</h2>
</div>
<p class="font-body text-on-surface-variant mb-6 text-sm">Définissez l'ambiance sonore qui marquera vos rappels d'archivage.</p>
<div class="relative max-w-md">
<label class="font-label text-xs text-on-surface-variant mb-2 block uppercase tracking-wider font-semibold">Sélecteur de mélodie</label>
<div class="relative group">
<select class="w-full bg-surface-container-low border border-outline-variant/30 text-on-surface rounded-xl px-4 py-4 appearance-none focus:outline-none focus:border-primary/50 focus:ring-4 focus:ring-primary/10 transition-all font-medium cursor-pointer">
<option value="arpege">Arpège</option>
<option value="bip_bip">Bip Bip</option>
<option selected="" value="melodie_amethyste">Mélodie Améthyste</option>
<option value="obsidienne">Obsidienne</option>
</select>
<div class="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-primary">
<span class="material-symbols-outlined">expand_more</span>
</div>
</div>
<div class="mt-4 flex items-center space-x-2 text-primary-fixed-dim text-xs bg-primary/10 w-fit px-3 py-1.5 rounded-full font-medium">
<span class="material-symbols-outlined !text-sm" data-weight="fill">play_circle</span>
<span>Écouter l'aperçu</span>
</div>
</div>
</section>
<!-- Section 2: Notifications -->
<section class="surface-container rounded-xl p-8 border border-outline-variant/15">
<div class="flex items-center space-x-3 mb-6">
<span class="material-symbols-outlined text-secondary">notifications_active</span>
<h2 class="font-headline font-bold text-2xl">Notifications</h2>
</div>
<div class="space-y-4">
<!-- Switch Item -->
<div class="flex items-center justify-between p-4 bg-surface-container-high rounded-xl hover:bg-surface-variant/40 transition-all border border-transparent hover:border-outline-variant/10">
<div class="flex items-center space-x-4">
<div class="w-10 h-10 rounded-xl bg-surface-container-low flex items-center justify-center">
<span class="material-symbols-outlined text-on-surface-variant">desktop_windows</span>
</div>
<div>
<p class="font-headline font-bold">Notifications PC Windows</p>
<p class="font-label text-xs text-on-surface-variant">Alertes natives sur votre système d'exploitation.</p>
</div>
</div>
<!-- Toggle Component -->
<label class="relative inline-flex items-center cursor-pointer">
<input checked="" class="sr-only peer" type="checkbox" value=""/>
<div class="w-11 h-6 bg-surface-container-highest peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary shadow-sm"></div>
</label>
</div>
<!-- Modal Trigger Item -->
<div class="flex items-center justify-between p-4 bg-surface-container-high rounded-xl hover:bg-surface-variant/40 transition-all border border-transparent hover:border-outline-variant/10">
<div class="flex items-center space-x-4">
<div class="w-10 h-10 rounded-xl bg-surface-container-low flex items-center justify-center">
<span class="material-symbols-outlined text-on-surface-variant">smartphone</span>
</div>
<div>
<p class="font-headline font-bold">Notifications Mobile</p>
<p class="font-label text-xs text-on-surface-variant">Synchronisez vos alertes sur iOS ou Android.</p>
</div>
</div>
<button class="px-6 py-2.5 bg-gradient-to-r from-primary to-primary-container text-on-primary font-bold rounded-xl text-sm shadow-lg shadow-primary/20 scale-95 hover:scale-100 active:scale-95 transition-all">
Configurer Mobile
</button>
</div>
</div>
</section>
<!-- Additional High-End Detail: Information Card -->
</div>
</div>
</main>
<footer class="w-full border-t border-[#4b4652]/15 bg-[#100c16] mt-24">
<div class="flex flex-row justify-between items-center px-8 py-6 w-full max-w-6xl mx-auto">
<div class="font-['Plus_Jakarta_Sans'] text-xs font-medium text-[#b0a8b6] opacity-80">
© 2024 L'Archive d'Obsidienne. Tous droits réservés.
</div>
<div class="flex items-center space-x-6">
<a class="font-['Plus_Jakarta_Sans'] text-xs font-medium text-[#b0a8b6] hover:text-[#f1e8f7] underline transition-all opacity-80 hover:opacity-100" href="#">Confidentialité</a>
<a class="font-['Plus_Jakarta_Sans'] text-xs font-medium text-[#b0a8b6] hover:text-[#f1e8f7] underline transition-all opacity-80 hover:opacity-100" href="#">Aide</a>
<a class="font-['Plus_Jakarta_Sans'] text-xs font-medium text-[#b0a8b6] hover:text-[#f1e8f7] underline transition-all opacity-80 hover:opacity-100" href="#">Conditions d'utilisation</a>
</div>
</div>
</footer>
</body></html>

BIN
refonte_graphique/parametres.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

View File

@ -0,0 +1,157 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>NOTIFICATIONS MOBILES - The Obsidian Archive</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;700;800&amp;family=Inter:wght@400;500;600&amp;family=Plus+Jakarta+Sans:wght@400;500;600&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"tertiary-fixed": "#fed01b",
"on-tertiary-fixed-variant": "#645000",
"error-container": "#a70138",
"inverse-primary": "#7c41b5",
"on-error-container": "#ffb2b9",
"tertiary-fixed-dim": "#eec200",
"on-tertiary": "#645000",
"on-primary-fixed": "#000000",
"outline": "#7a7380",
"primary-fixed-dim": "#b378ef",
"surface-tint": "#cb97ff",
"background": "#100c16",
"primary-container": "#c185fd",
"surface-variant": "#292332",
"inverse-surface": "#fef7ff",
"on-secondary-fixed-variant": "#922164",
"surface-container-high": "#231d2b",
"primary-dim": "#be83fa",
"outline-variant": "#4b4652",
"on-primary-fixed-variant": "#430077",
"on-primary": "#46007c",
"surface-container": "#1c1724",
"surface-bright": "#302939",
"on-tertiary-fixed": "#433500",
"surface": "#100c16",
"on-error": "#490013",
"on-surface": "#f1e8f7",
"tertiary-container": "#fed01b",
"primary-fixed": "#c185fd",
"tertiary": "#ffe083",
"secondary-container": "#85145a",
"on-primary-container": "#360061",
"on-surface-variant": "#b0a8b6",
"surface-container-low": "#16111d",
"secondary-dim": "#f271b5",
"on-secondary-fixed": "#690045",
"secondary": "#f673b7",
"primary": "#cb97ff",
"on-tertiary-container": "#594700",
"inverse-on-surface": "#59535f",
"secondary-fixed-dim": "#ffabd1",
"error": "#ff6e84",
"on-secondary": "#4a002f",
"error-dim": "#d73357",
"secondary-fixed": "#ffc0db",
"on-secondary-container": "#ffbeda",
"tertiary-dim": "#eec200",
"surface-dim": "#100c16",
"surface-container-lowest": "#000000",
"surface-container-highest": "#292332",
"on-background": "#f1e8f7"
},
fontFamily: {
"headline": ["Manrope"],
"body": ["Inter"],
"label": ["Plus Jakarta Sans"]
},
borderRadius: {"DEFAULT": "0.125rem", "lg": "0.25rem", "xl": "0.5rem", "full": "0.75rem"},
},
},
}
</script>
<style>
body {
background: radial-gradient(circle at top right, #1e1b4b, #0a0a0f);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.glass-panel {
background: rgba(28, 23, 36, 0.6);
backdrop-filter: blur(40px);
border: 1px solid rgba(203, 151, 255, 0.15);
}
.neon-glow {
box-shadow: 0 0 20px rgba(203, 151, 255, 0.2);
}
</style>
</head>
<body class="font-body text-on-surface">
<!-- Modal Container -->
<div class="relative w-full max-w-4xl mx-4">
<!-- Header Shell (Reference TopAppBar Logic) -->
<header class="glass-panel rounded-t-xl px-8 py-6 flex justify-between items-center border-b-0">
<h1 class="font-headline text-2xl font-extrabold text-primary tracking-tight uppercase">NOTIFICATIONS MOBILES</h1>
<button class="text-on-surface-variant hover:text-primary transition-colors">
<span class="material-symbols-outlined" data-icon="close">close</span>
</button>
</header>
<!-- Main Modal Content -->
<main class="glass-panel p-8 flex flex-col md:flex-row gap-8 border-t-0">
<!-- Section 1: Installer l'app -->
<section class="flex-1 bg-surface-container-high/40 rounded-xl p-6 border border-outline-variant/10 flex flex-col items-center text-center">
<h2 class="font-headline text-lg font-bold text-on-surface mb-4">1. Installer l'app ntfy</h2>
<p class="text-on-surface-variant text-sm mb-6 leading-relaxed">
Téléchargez l'application ntfy sur votre appareil mobile. Scannez ce code pour ouvrir directement la page sur l'App Store ou le Play Store.
</p>
<div class="relative p-4 bg-white rounded-xl neon-glow">
<img alt="QR Code" class="w-40 h-40 object-contain" data-alt="high-contrast minimalist black and white QR code on a clean white background with subtle purple accent border" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCoHplz4v5lFmIppdNJovVc5-hSkDERe2wzaISOEQMcC-QcKql7PhubFgs0waRtVzcK-kSj7aZMns7SgSpda3qFMFsejuj3X6-dd4fmYnNxGlH-7D6bqp769kx9eh1RPWh9lPPXOFk6HRMEWaT14Lo7bJNd2r9KV9-L9rs2Maw6xYteeyNHhkjVq8xGs3WCUPtlzw4o2TYPm-AsKtcl0wZ9W708asChAR7XIC8XN9VvrCBjmzZY80FfFqkLRYpEy2Fu436fdYNMi9Gw"/>
</div>
<div class="mt-6 flex gap-3">
<span class="material-symbols-outlined text-primary-dim" data-icon="phone_iphone">phone_iphone</span>
<span class="material-symbols-outlined text-primary-dim" data-icon="shop">shop</span>
</div>
</section>
<!-- Section 2: Scanne pour t'abonner -->
<section class="flex-1 bg-surface-container-high/40 rounded-xl p-6 border border-outline-variant/10 flex flex-col items-center text-center">
<h2 class="font-headline text-lg font-bold text-on-surface mb-4">2. Scanne pour t'abonner</h2>
<p class="text-on-surface-variant text-sm mb-6 leading-relaxed">
Une fois l'application installée, scannez ce code pour ajouter automatiquement le canal de l'Archive et recevoir vos alertes en temps réel.
</p>
<div class="relative p-4 bg-white rounded-xl neon-glow">
<img alt="QR Code" class="w-40 h-40 object-contain" data-alt="complex technical QR code pattern with dark violet details on a bright white square background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBr49FhRSsP6Y6oldsSFE4b3EgyZbvhiIx0AzPULASiY7WfBL4nluQA79isPxpgM6cQ_6mILGmFEwhQuZ4VlnVPpZSf_ZSw1LaJcByhRZjsAfGUPDqz3hYFdK_utRXzSS4fBbCRYCZ7BCHsS1Z1tDPpajw8y8GpaWTxFSyqtTo6DP3KFn1tiaGBhelUWlDSfdtMmpbdDPdR_fiDqHbbpMjYq5_VTNlCtYxdpLZURNIIcffduDakzPrMvUzqYgA39dPGww_fHO_eH-RP"/>
</div>
<div class="mt-6 flex gap-3">
<span class="material-symbols-outlined text-secondary" data-icon="notifications_active">notifications_active</span>
<span class="material-symbols-outlined text-secondary" data-icon="key">key</span>
</div>
</section>
</main>
<!-- Action Footer -->
<footer class="glass-panel rounded-b-xl px-8 py-6 flex justify-end items-center gap-4 border-t-0">
<button class="flex items-center gap-2 px-6 py-2.5 rounded-full font-label font-bold text-sm bg-surface-container-highest text-on-surface hover:bg-surface-bright transition-all active:scale-95">
Fermer
</button>
<button class="flex items-center gap-2 px-8 py-2.5 rounded-full font-label font-bold text-sm bg-gradient-to-r from-primary to-primary-container text-on-primary shadow-lg shadow-primary/20 hover:opacity-90 transition-all active:scale-95">
<span class="material-symbols-outlined text-[20px]" data-icon="notifications">notifications</span>
Tester
</button>
</footer>
<!-- Bottom Accent Line -->
<div class="h-1 w-full bg-gradient-to-r from-transparent via-primary/50 to-transparent absolute -bottom-0.5 left-0 blur-sm"></div>
</div>
<!-- Background Decoration Elements -->
<div class="fixed top-20 left-20 w-64 h-64 bg-primary/10 rounded-full blur-[100px] -z-10"></div>
<div class="fixed bottom-20 right-20 w-96 h-96 bg-secondary/10 rounded-full blur-[120px] -z-10"></div>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

View File

@ -0,0 +1,464 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>L'Archive d'Obsidienne - Réapprovisionnement</title>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&amp;family=Inter:wght@300;400;500;600&amp;family=Material+Icons+Round&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
primary: "#9d4edd", // Amethyst Purple
"background-light": "#f8f9fa",
"background-dark": "#0d0b14", // Deep Obsidian
secondary: "#ff006e", // Pink accent
surface: "#1a1625",
},
fontFamily: {
display: ["Cinzel", "serif"],
sans: ["Inter", "sans-serif"],
},
borderRadius: {
DEFAULT: "0.5rem",
'xl': '1rem',
},
},
},
};
</script>
<style>
body {
font-family: 'Inter', sans-serif;
}
.font-display {
font-family: 'Cinzel', serif;
}
.glass-panel {
background: rgba(26, 22, 37, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(157, 78, 221, 0.2);
}
.scroll-custom::-webkit-scrollbar {
width: 6px;
}
.scroll-custom::-webkit-scrollbar-track {
background: transparent;
}
.scroll-custom::-webkit-scrollbar-thumb {
background: #3c3350;
border-radius: 10px;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
@keyframes pulse-bounce {
0%, 100% {
transform: translateY(0) scale(1);
opacity: 0.4;
}
50% {
transform: translateY(6px) scale(1.1);
opacity: 0.8;
}
}
.animate-pulse-bounce {
animation: pulse-bounce 2s ease-in-out infinite;
}
</style>
</head>
<body class="text-slate-800 dark:text-slate-200 min-h-screen" style="background: radial-gradient(circle at top right, #1e1b4b, #0a0a0f);">
<main class="flex flex-col h-screen overflow-hidden">
<header class="h-16 border-b border-slate-200 dark:border-white/10 flex items-center px-8 bg-white/50 dark:bg-surface/50 backdrop-blur-md sticky top-0 z-50">
<nav class="flex items-center gap-8 h-full">
<a class="h-full flex items-center text-sm font-bold tracking-widest text-slate-400 hover:text-primary transition-colors border-b-2 border-transparent uppercase font-display" href="#" style="">
Tableau de bord
</a>
<a class="h-full flex items-center text-sm font-bold tracking-widest text-slate-400 hover:text-primary transition-colors border-b-2 border-transparent uppercase font-display" href="#" style="">
Enclos
</a>
<a class="h-full flex items-center text-sm font-bold tracking-widest text-slate-400 hover:text-primary transition-colors border-b-2 border-transparent uppercase font-display" href="#" style="">
Statistiques
</a>
</nav>
<div class="ml-auto flex items-center gap-4">
<span class="text-[10px] bg-primary/20 text-primary px-2 py-0.5 rounded font-mono font-bold" style="">v1.1.5</span>
</div>
</header>
<div class="flex-1 overflow-y-auto p-8 scroll-custom space-y-12">
<!-- Target Selection -->
<section class="max-w-[1600px] mx-auto w-full space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xs uppercase tracking-[0.2em] text-slate-400 font-bold" style="">Sélectionne ta cible</h2>
<div class="h-1 w-12 bg-primary mt-1 rounded-full"></div>
</div>
<div class="flex gap-2 overflow-x-auto pb-2 no-scrollbar">
<button class="px-4 py-1.5 rounded-full text-xs font-semibold bg-primary text-white" style="">Toutes</button>
<button class="px-4 py-1.5 rounded-full text-xs font-semibold bg-slate-200 dark:bg-white/5 hover:bg-primary/20 transition-colors text-slate-600 dark:text-slate-400" style="">Gen 1</button>
<button class="px-4 py-1.5 rounded-full text-xs font-semibold bg-slate-200 dark:bg-white/5 hover:bg-primary/20 transition-colors text-slate-600 dark:text-slate-400" style="">Gen 2</button>
<button class="px-4 py-1.5 rounded-full text-xs font-semibold bg-slate-200 dark:bg-white/5 hover:bg-primary/20 transition-colors text-slate-600 dark:text-slate-400" style="">Gen 5</button>
<button class="px-4 py-1.5 rounded-full text-xs font-semibold bg-slate-200 dark:bg-white/5 hover:bg-primary/20 transition-colors text-slate-600 dark:text-slate-400" style="">Gen 10</button>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-10 gap-4" id="target-grid">
<div class="glass-panel p-3 rounded-xl border border-primary ring-2 ring-primary/50 relative cursor-pointer group hover:scale-105 transition-all">
<div class="absolute top-2 right-2 flex gap-1">
<span class="text-[9px] font-bold bg-primary text-white px-1.5 rounded shadow-sm" style="">Gen 5</span>
</div>
<div class="w-full aspect-square bg-slate-800/50 rounded-lg mb-2 flex items-center justify-center overflow-hidden">
<img alt="Dragodinde Pourpre" class="w-16 h-16 object-contain" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAubvgfO9M_y6AI1RIslf8TR7z3dfXXDUaysxgQy9rn-mtjeRREg862eXeC5nazwn5SncSd6jgNY0vXdogwhX9_voY2jWgX836Z3f9zDY2_3tECSqV9xQ805xrIloAj2P8diUPNoH6rI8A1cpFkJlCepeUbic0VdZ5OY60vgWz_46pO2sWy29QZgsVTrgBrzvasr_bRJbqJ23IXw_tWOuSYdGYPI2mQY7M2oaYk6i0ZymH2DNkPzFxU-w0UrdcgTWmHq0doypPjC41D" style=""/>
</div>
<p class="text-xs font-bold text-center truncate" style="">Pourpre</p>
<div class="absolute inset-0 bg-primary/5 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<!-- Other cards are injected via JS -->
</div>
</section>
<!-- Replenishment Plan -->
<section class="max-w-[1600px] mx-auto w-full space-y-6">
<div>
<h2 class="text-xs uppercase tracking-[0.2em] text-slate-400 font-bold" style="">Plan de Croisement</h2>
<div class="h-1 w-12 bg-secondary mt-1 rounded-full"></div>
</div>
<div class="space-y-4 pb-24">
<!-- GEN 1 -->
<div class="glass-panel rounded-2xl overflow-hidden border-l-4 border-l-primary">
<div class="bg-primary/10 px-6 py-3 flex justify-between items-center">
<h3 class="font-display text-sm tracking-wider flex items-center gap-2" style="">
<span class="bg-primary text-white w-6 h-6 rounded flex items-center justify-center text-xs" style="">1</span>
MATIÈRES PREMIÈRES — GÉNÉRATION 1
</h3>
<span class="text-xs text-slate-400 font-bold" style="">Total : 16 dragodindes requises</span>
</div>
<div class="p-8 flex flex-wrap gap-8 justify-center">
<div class="text-center group">
<div class="w-16 h-16 bg-slate-800 rounded-full border-2 border-amber-500 p-1 mb-2 shadow-lg transition-transform group-hover:scale-110">
<div class="w-full h-full rounded-full bg-amber-500/20"></div>
</div>
<p class="text-[10px] font-bold" style="">Amande ♂</p>
<p class="text-secondary font-bold text-sm" style="">×6</p>
</div>
<div class="text-center group">
<div class="w-16 h-16 bg-slate-800 rounded-full border-2 border-orange-500 p-1 mb-2 shadow-lg transition-transform group-hover:scale-110">
<div class="w-full h-full rounded-full bg-orange-500/20"></div>
</div>
<p class="text-[10px] font-bold" style="">Rousse ♂</p>
<p class="text-secondary font-bold text-sm" style="">×2</p>
</div>
<div class="text-center group">
<div class="w-16 h-16 bg-slate-800 rounded-full border-2 border-orange-500 p-1 mb-2 shadow-lg transition-transform group-hover:scale-110">
<div class="w-full h-full rounded-full bg-orange-500/20"></div>
</div>
<p class="text-[10px] font-bold" style="">Rousse ♀</p>
<p class="text-secondary font-bold text-sm" style="">×4</p>
</div>
<div class="text-center group">
<div class="w-16 h-16 bg-slate-800 rounded-full border-2 border-yellow-500 p-1 mb-2 shadow-lg transition-transform group-hover:scale-110">
<div class="w-full h-full rounded-full bg-yellow-500/20"></div>
</div>
<p class="text-[10px] font-bold" style="">Dorée ♀</p>
<p class="text-secondary font-bold text-sm" style="">×4</p>
</div>
</div>
</div>
<div class="flex justify-center py-2">
<span class="material-icons-round text-primary/40 animate-pulse-bounce" style="">expand_more</span>
</div>
<!-- GEN 2 -->
<div class="glass-panel rounded-2xl overflow-hidden border-l-4 border-l-slate-500">
<div class="bg-white/5 px-6 py-3 flex justify-between items-center">
<h3 class="font-display text-sm tracking-wider flex items-center gap-2" style="">
<span class="bg-slate-700 text-white w-6 h-6 rounded flex items-center justify-center text-xs" style="">2</span>
CROISEMENTS — GÉNÉRATION 2
</h3>
</div>
<div class="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Pairing A -->
<div class="flex flex-col items-center gap-4 bg-white/5 p-4 rounded-xl border border-white/5 ring-1 ring-primary/30">
<div class="flex items-center gap-4">
<div class="text-center">
<div class="w-12 h-12 bg-slate-800 rounded-full border border-amber-500/50 mb-1"></div>
<p class="text-[8px] text-slate-400 uppercase" style="">Amande ♂</p>
</div>
<span class="material-icons-round text-slate-600 text-xs" style="">add</span>
<div class="text-center">
<div class="w-12 h-12 bg-slate-800 rounded-full border border-orange-500/50 mb-1"></div>
<p class="text-[8px] text-slate-400 uppercase" style="">Rousse ♀</p>
</div>
<span class="material-icons-round text-primary text-sm" style="">arrow_forward</span>
<div class="text-center">
<div class="w-14 h-14 bg-slate-800 rounded-full border-2 border-primary mb-1 relative flex items-center justify-center">
<span class="absolute -top-1 -right-1 bg-primary text-[7px] px-1 rounded font-bold" style="">G2</span>
<div class="w-8 h-8 rounded-full bg-primary/20"></div>
</div>
<p class="text-[9px] font-bold" style="">Amande &amp; Rousse</p>
</div>
</div>
<div class="flex items-center gap-4 pt-3 border-t border-white/5 w-full justify-center">
<label class="flex items-center gap-2 cursor-pointer group" style="">
<input checked="" class="rounded-sm border-slate-700 bg-slate-900 text-primary focus:ring-primary h-4 w-4" type="checkbox"/>
<span class="text-[10px] font-medium text-slate-400" style="">Reproducteur</span>
</label>
<div class="flex items-center gap-2 bg-slate-900/80 rounded-lg px-3 py-1 border border-white/5">
<input class="bg-transparent border-none text-xs w-8 p-0 focus:ring-0 text-center font-bold text-primary" type="number" value="3"/>
<span class="text-[8px] text-slate-500 uppercase font-bold tracking-wider" style="">Couples</span>
</div>
</div>
</div>
<!-- Pairing B -->
<div class="flex flex-col items-center gap-4 bg-white/5 p-4 rounded-xl border border-white/5 ring-1 ring-primary/30">
<div class="flex items-center gap-4">
<div class="text-center">
<div class="w-12 h-12 bg-slate-800 rounded-full border border-yellow-500/50 mb-1"></div>
<p class="text-[8px] text-slate-400 uppercase" style="">Dorée ♂</p>
</div>
<span class="material-icons-round text-slate-600 text-xs" style="">add</span>
<div class="text-center">
<div class="w-12 h-12 bg-slate-800 rounded-full border border-orange-500/50 mb-1"></div>
<p class="text-[8px] text-slate-400 uppercase" style="">Rousse ♀</p>
</div>
<span class="material-icons-round text-primary text-sm" style="">arrow_forward</span>
<div class="text-center">
<div class="w-14 h-14 bg-slate-800 rounded-full border-2 border-primary mb-1 relative flex items-center justify-center">
<span class="absolute -top-1 -right-1 bg-primary text-[7px] px-1 rounded font-bold" style="">G2</span>
<div class="w-8 h-8 rounded-full bg-amber-500/20"></div>
</div>
<p class="text-[9px] font-bold" style="">Dorée &amp; Rousse</p>
</div>
</div>
<div class="flex items-center gap-4 pt-3 border-t border-white/5 w-full justify-center">
<label class="flex items-center gap-2 cursor-pointer group" style="">
<input checked="" class="rounded-sm border-slate-700 bg-slate-900 text-primary focus:ring-primary h-4 w-4" type="checkbox"/>
<span class="text-[10px] font-medium text-slate-400" style="">Reproducteur</span>
</label>
<div class="flex items-center gap-2 bg-slate-900/80 rounded-lg px-3 py-1 border border-white/5">
<input class="bg-transparent border-none text-xs w-8 p-0 focus:ring-0 text-center font-bold text-primary" type="number" value="2"/>
<span class="text-[8px] text-slate-500 uppercase font-bold tracking-wider" style="">Couples</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-center py-2">
<span class="material-icons-round text-primary/40 animate-pulse-bounce" style="">expand_more</span>
</div>
<!-- GEN 3 -->
<div class="glass-panel rounded-2xl overflow-hidden border-l-4 border-l-slate-500">
<div class="bg-white/5 px-6 py-3 flex justify-between items-center">
<h3 class="font-display text-sm tracking-wider flex items-center gap-2" style="">
<span class="bg-slate-700 text-white w-6 h-6 rounded flex items-center justify-center text-xs" style="">3</span>
CROISEMENTS — GÉNÉRATION 3
</h3>
</div>
<div class="p-6">
<div class="flex flex-col items-center gap-4 bg-white/5 p-4 rounded-xl border border-white/5 max-w-md mx-auto ring-1 ring-primary/30">
<div class="flex items-center gap-4">
<div class="text-center">
<div class="w-12 h-12 bg-slate-800 rounded-full border border-purple-500/50 mb-1"></div>
<p class="text-[8px] text-slate-400 uppercase" style="">Amande/Rousse</p>
</div>
<span class="material-icons-round text-slate-600 text-xs" style="">add</span>
<div class="text-center">
<div class="w-12 h-12 bg-slate-800 rounded-full border border-amber-700/50 mb-1"></div>
<p class="text-[8px] text-slate-400 uppercase" style="">Dorée/Rousse</p>
</div>
<span class="material-icons-round text-primary text-sm" style="">arrow_forward</span>
<div class="text-center">
<div class="w-14 h-14 bg-slate-800 rounded-full border-2 border-primary mb-1 relative flex items-center justify-center">
<span class="absolute -top-1 -right-1 bg-primary text-[7px] px-1 rounded font-bold" style="">G3</span>
<div class="w-8 h-8 rounded-full bg-slate-900"></div>
</div>
<p class="text-[9px] font-bold" style="">Ebène</p>
</div>
</div>
<div class="flex items-center gap-4 pt-3 border-t border-white/5 w-full justify-center">
<label class="flex items-center gap-2 cursor-pointer group" style="">
<input checked="" class="rounded-sm border-slate-700 bg-slate-900 text-primary focus:ring-primary h-4 w-4" type="checkbox"/>
<span class="text-[10px] font-medium text-slate-400" style="">Reproducteur</span>
</label>
<div class="flex items-center gap-2 bg-slate-900/80 rounded-lg px-3 py-1 border border-white/5">
<input class="bg-transparent border-none text-xs w-8 p-0 focus:ring-0 text-center font-bold text-primary" type="number" value="2"/>
<span class="text-[8px] text-slate-500 uppercase font-bold tracking-wider" style="">Couples</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-center py-2">
<span class="material-icons-round text-primary/40 animate-pulse-bounce" style="">expand_more</span>
</div>
<!-- GEN 4 -->
<div class="glass-panel rounded-2xl overflow-hidden border-l-4 border-l-slate-500">
<div class="bg-white/5 px-6 py-3 flex justify-between items-center">
<h3 class="font-display text-sm tracking-wider flex items-center gap-2" style="">
<span class="bg-slate-700 text-white w-6 h-6 rounded flex items-center justify-center text-xs" style="">4</span>
CROISEMENTS — GÉNÉRATION 4
</h3>
</div>
<div class="p-6">
<div class="flex flex-col items-center gap-4 bg-white/5 p-4 rounded-xl border border-white/5 max-w-md mx-auto ring-1 ring-primary/30">
<div class="flex items-center gap-4">
<div class="text-center">
<div class="w-12 h-12 bg-slate-800 rounded-full border border-slate-900 mb-1"></div>
<p class="text-[8px] text-slate-400 uppercase" style="">Ebène ♂</p>
</div>
<span class="material-icons-round text-slate-600 text-xs" style="">add</span>
<div class="text-center">
<div class="w-12 h-12 bg-slate-800 rounded-full border border-blue-600/50 mb-1"></div>
<p class="text-[8px] text-slate-400 uppercase" style="">Indigo ♀</p>
</div>
<span class="material-icons-round text-primary text-sm" style="">arrow_forward</span>
<div class="text-center">
<div class="w-14 h-14 bg-slate-800 rounded-full border-2 border-primary mb-1 relative flex items-center justify-center">
<span class="absolute -top-1 -right-1 bg-primary text-[7px] px-1 rounded font-bold" style="">G4</span>
<div class="w-8 h-8 rounded-full bg-cyan-500/20"></div>
</div>
<p class="text-[9px] font-bold" style="">Turquoise</p>
</div>
</div>
<div class="flex items-center gap-4 pt-3 border-t border-white/5 w-full justify-center">
<label class="flex items-center gap-2 cursor-pointer group" style="">
<input checked="" class="rounded-sm border-slate-700 bg-slate-900 text-primary focus:ring-primary h-4 w-4" type="checkbox"/>
<span class="text-[10px] font-medium text-slate-400" style="">Reproducteur</span>
</label>
<div class="flex items-center gap-2 bg-slate-900/80 rounded-lg px-3 py-1 border border-white/5">
<input class="bg-transparent border-none text-xs w-8 p-0 focus:ring-0 text-center font-bold text-primary" type="number" value="1"/>
<span class="text-[8px] text-slate-500 uppercase font-bold tracking-wider" style="">Couples</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-center py-2">
<span class="material-icons-round text-primary animate-pulse-bounce" style="">expand_more</span>
</div>
<!-- FINAL STEP GEN 5 -->
<div class="glass-panel rounded-2xl overflow-hidden border-l-4 border-l-primary ring-1 ring-primary/20">
<div class="bg-primary/20 px-6 py-3 flex justify-between items-center">
<h3 class="font-display text-sm tracking-wider flex items-center gap-2" style="">
<span class="bg-primary text-white w-6 h-6 rounded flex items-center justify-center text-xs" style="">5</span>
ÉTAPE FINALE — GÉNÉRATION 5
</h3>
<span class="text-[10px] uppercase font-bold tracking-widest text-primary" style="">Objectif final</span>
</div>
<div class="p-8">
<div class="flex flex-col items-center gap-6 bg-primary/5 p-6 rounded-2xl border border-primary/20 opacity-60 grayscale-[0.5]">
<div class="flex items-center gap-8">
<div class="text-center">
<div class="w-16 h-16 bg-slate-800 rounded-full border-2 border-emerald-500/50 mb-1"></div>
<p class="text-[10px] text-slate-400" style="">Émeraude ♂</p>
</div>
<span class="material-icons-round text-slate-500" style="">add</span>
<div class="text-center">
<div class="w-16 h-16 bg-slate-800 rounded-full border-2 border-cyan-500/50 mb-1"></div>
<p class="text-[10px] text-slate-400" style="">Turquoise ♀</p>
</div>
<span class="material-icons-round text-primary" style="">arrow_forward</span>
<div class="text-center">
<div class="w-24 h-24 bg-slate-800 rounded-full border-4 border-primary mb-1 relative flex items-center justify-center shadow-xl shadow-primary/20">
<span class="absolute -top-2 -right-2 bg-primary text-xs px-2 py-0.5 rounded-full font-bold shadow-lg" style="">CIBLE</span>
<img alt="Pourpre" class="w-16 h-16 object-contain" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAubvgfO9M_y6AI1RIslf8TR7z3dfXXDUaysxgQy9rn-mtjeRREg862eXeC5nazwn5SncSd6jgNY0vXdogwhX9_voY2jWgX836Z3f9zDY2_3tECSqV9xQ805xrIloAj2P8diUPNoH6rI8A1cpFkJlCepeUbic0VdZ5OY60vgWz_46pO2sWy29QZgsVTrgBrzvasr_bRJbqJ23IXw_tWOuSYdGYPI2mQY7M2oaYk6i0ZymH2DNkPzFxU-w0UrdcgTWmHq0doypPjC41D" style=""/>
</div>
<p class="text-sm font-bold text-slate-100 mt-2" style="">Pourpre</p>
<p class="text-secondary font-bold text-lg" style="">×1</p>
</div>
</div>
<div class="flex items-center gap-6 pt-4 border-t border-white/5 w-full justify-center">
<label class="flex items-center gap-3 cursor-pointer group" style="">
<input class="rounded-md border-slate-700 bg-slate-900 text-primary focus:ring-primary h-5 w-5 transition-all" type="checkbox"/>
<span class="text-sm font-medium text-slate-400 group-hover:text-slate-200 transition-colors" style="">Garder comme Reproducteur</span>
</label>
<div class="flex items-center gap-3 bg-slate-900/80 rounded-xl px-4 py-2 border border-white/5">
<span class="material-icons-round text-sm text-slate-500" style="">group</span>
<input class="bg-transparent border-none text-sm w-12 p-0 focus:ring-0 text-center font-bold text-primary" type="number" value="1"/>
<span class="text-[10px] text-slate-500 uppercase font-bold tracking-wider" style="">Couples</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
<footer class="p-6 border-t border-slate-200 dark:border-white/10 bg-white dark:bg-surface flex justify-between items-center shadow-2xl relative z-[60]">
<div class="flex items-center gap-4">
<div class="px-4 py-2 bg-slate-100 dark:bg-white/5 rounded-2xl flex items-center gap-4 border border-slate-200 dark:border-white/5">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-purple-700 flex items-center justify-center text-white shadow-lg">
<span class="material-icons-round" style="">auto_awesome</span>
</div>
<div>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-wider leading-none mb-1" style="">Cible de réapprovisionnement</p>
<p class="text-sm font-bold text-primary" style="">Pourpre (Génération 5)</p>
</div>
</div>
</div>
<div class="flex gap-4">
<button class="px-6 py-3 rounded-xl border border-slate-300 dark:border-white/10 font-bold text-sm hover:bg-slate-100 dark:hover:bg-white/5 transition-all text-slate-600 dark:text-slate-400" style="">
Annuler
</button>
<button class="px-10 py-3 rounded-xl bg-gradient-to-r from-primary to-purple-600 text-white font-bold text-sm flex items-center gap-3 shadow-xl shadow-primary/30 hover:shadow-primary/50 hover:-translate-y-1 active:translate-y-0 transition-all" style="">
<span class="material-icons-round text-lg" style="">save</span>
Sauvegarder ce plan
</button>
</div>
</footer>
</main>
<script>
// Mounts data for the grid
const targetGrid = document.getElementById('target-grid');
const mounts = [
{ name: 'Amande et Rousse', gen: 2, color: 'bg-amber-500' },
{ name: 'Dorée et Rousse', gen: 2, color: 'bg-amber-700' },
{ name: 'Ebène', gen: 3, color: 'bg-slate-900' },
{ name: 'Indigo', gen: 3, color: 'bg-blue-600' },
{ name: 'Turquoise', gen: 4, color: 'bg-cyan-500' },
{ name: 'Emeraude', gen: 5, color: 'bg-emerald-600' },
{ name: 'Prune', gen: 6, color: 'bg-fuchsia-800' },
{ name: 'Ivoire', gen: 7, color: 'bg-slate-100' },
{ name: 'Smaragdine', gen: 10, color: 'bg-emerald-400' }
];
mounts.forEach(m => {
const card = document.createElement('div');
card.className = "glass-panel p-3 rounded-xl border border-white/5 hover:border-primary/50 cursor-pointer group transition-all relative";
card.innerHTML = `
<div class="absolute top-2 right-2">
<span class="text-[9px] font-bold bg-slate-700 text-white px-1.5 rounded">Gen ${m.gen}</span>
</div>
<div class="w-full aspect-square bg-slate-800/30 rounded-lg mb-2 flex items-center justify-center overflow-hidden">
<div class="w-12 h-12 rounded-full ${m.color} opacity-40 blur-md group-hover:scale-125 transition-transform"></div>
</div>
<p class="text-xs font-medium text-center truncate dark:text-slate-400 group-hover:text-primary transition-colors">${m.name}</p>
`;
targetGrid.appendChild(card);
});
// Interactive state simulation for checkboxes
document.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
const updateState = (cb) => {
const parent = cb.closest('.flex-col');
if (!parent) return;
if (cb.checked) {
parent.classList.remove('opacity-60', 'grayscale-[0.5]');
parent.classList.add('ring-1', 'ring-primary/30');
} else {
parent.classList.add('opacity-60', 'grayscale-[0.5]');
parent.classList.remove('ring-1', 'ring-primary/30');
}
};
checkbox.addEventListener('change', (e) => updateState(e.target));
// Initial run
updateState(checkbox);
});
</script>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

203
refonte_graphique/sidebar.html Executable file
View File

@ -0,0 +1,203 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&amp;family=Inter:wght@400;500;600&amp;family=Plus+Jakarta+Sans:wght@400;500;600;700&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"surface-dim": "#100c16",
"error-container": "#a70138",
"inverse-on-surface": "#59535f",
"secondary-container": "#85145a",
"outline": "#7a7380",
"error-dim": "#d73357",
"on-surface-variant": "#b0a8b6",
"inverse-primary": "#7c41b5",
"inverse-surface": "#fef7ff",
"on-tertiary-container": "#594700",
"surface-container-low": "#16111d",
"on-primary-fixed": "#000000",
"on-tertiary-fixed-variant": "#645000",
"on-secondary-fixed-variant": "#922164",
"surface": "#100c16",
"on-secondary-fixed": "#690045",
"primary": "#cb97ff",
"surface-container-high": "#231d2b",
"on-primary": "#46007c",
"secondary": "#f673b7",
"on-primary-fixed-variant": "#430077",
"primary-fixed-dim": "#b378ef",
"primary-fixed": "#c185fd",
"background": "#100c16",
"tertiary-dim": "#eec200",
"on-surface": "#f1e8f7",
"on-tertiary-fixed": "#433500",
"on-secondary": "#4a002f",
"secondary-fixed-dim": "#ffabd1",
"on-error-container": "#ffb2b9",
"on-background": "#f1e8f7",
"outline-variant": "#4b4652",
"secondary-fixed": "#ffc0db",
"primary-dim": "#be83fa",
"tertiary": "#ffe083",
"surface-tint": "#cb97ff",
"surface-container-highest": "#292332",
"secondary-dim": "#f271b5",
"on-primary-container": "#360061",
"surface-bright": "#302939",
"surface-container": "#1c1724",
"on-error": "#490013",
"tertiary-fixed": "#fed01b",
"tertiary-fixed-dim": "#eec200",
"on-tertiary": "#645000",
"on-secondary-container": "#ffbeda",
"tertiary-container": "#fed01b",
"primary-container": "#c185fd",
"error": "#ff6e84",
"surface-variant": "#292332",
"surface-container-lowest": "#000000"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"fontFamily": {
"headline": ["Manrope"],
"body": ["Inter"],
"label": ["Plus Jakarta Sans"]
}
},
},
}
</script>
<style>
.sidebar-glass {
background: radial-gradient(circle at top right, #1e1b4b, #0a0a0f);
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(203, 151, 255, 0.1);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(203, 151, 255, 0.2);
}
</style>
</head>
<body class="bg-surface-dim text-on-surface font-body overflow-hidden">
<!-- Wrapper to simulate application layout -->
<div class="flex h-screen w-full">
<!-- SideNavBar Component -->
<!-- style_component_shape: h-screen w-64 fixed left-0 top-0 -->
<!-- style_shell_layout: flex flex-col h-full py-6 border-r border-white/10 -->
<!-- style_bg_color: bg-purple-950/60 backdrop-blur-xl -->
<aside class="h-screen w-64 left-0 top-0 flex flex-col h-full py-6 sidebar-glass backdrop-blur-xl border-r border-white/10 shadow-2xl shadow-black/50 font-headline antialiased tracking-tight">
<!-- Header Section -->
<div class="px-6 mb-10 flex items-center gap-4 group">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-secondary-container flex items-center justify-center p-0.5 shadow-lg shadow-primary/20">
<div class="w-full h-full bg-surface-dim rounded-[7px] flex items-center justify-center overflow-hidden">
<img alt="Logo L'Archive d'Obsidienne" class="w-full h-full object-cover" data-alt="Stylized obsidian crystal shard with glowing violet cracks, deep purple and black color palette, elegant fantasy aesthetic" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCtmw3Vkb-yhKfdwdDCCp_A-crxkp-1Z_h-WSZrhRYh4mZCP4iwtU1KmmNQ-x547g23QAHkfKdyClESpK3My9dh0lgHObrnQiiLLr6o6Xvj5mtsXqurBYVThaWWKtBpLU5qHx52m7ZPwazg2TfvesKszIYwOzR2kAS89BvpMzTIcgyB61FBEr6HbRTDzI1hNuSJfxkbkVEezqqpK_MT3AhQrRg3y8NyCKd8NOrqaACMxvl7SW9OI-1p8gxIS9jfq8a8zFcNT2_NtVrm"/>
</div>
</div>
<div class="flex flex-col">
<span class="text-xl font-bold tracking-tighter text-violet-300">Obsidienne</span>
<span class="text-[10px] font-label font-bold uppercase tracking-widest text-on-surface-variant/60">Gestion d'élevage</span>
</div>
</div>
<!-- Navigation Scroll Area -->
<div class="flex-1 overflow-y-auto px-3 space-y-8 custom-scrollbar">
<!-- PRINCIPAL -->
<div>
<h3 class="px-4 mb-3 text-[11px] font-label font-bold uppercase tracking-[0.2em] text-on-surface-variant/40">Principal</h3>
<nav class="space-y-1">
<!-- Active Item -->
<!-- style_active_navigation: relative flex items-center gap-3 px-4 py-3 text-violet-300 before:absolute before:left-1 before:h-8 before:w-1 before:bg-violet-500 before:rounded-full -->
<a class="relative flex items-center gap-3 px-4 py-3 text-violet-300 bg-white/5 rounded-xl before:absolute before:left-1 before:h-8 before:w-1 before:bg-violet-500 before:rounded-full active:scale-95 transition-all duration-300" href="#">
<span class="material-symbols-outlined text-violet-400" style="font-variation-settings: 'FILL' 1;">dashboard</span>
<span class="text-sm font-semibold">Tableau de bord</span>
</a>
</nav>
</div>
<!-- ENCLOS -->
<div>
<h3 class="px-4 mb-3 text-[11px] font-label font-bold uppercase tracking-[0.2em] text-on-surface-variant/40">Enclos</h3>
<nav class="space-y-1">
<!-- Inactive Items -->
<!-- style_inactive_navigation: flex items-center gap-3 px-4 py-3 text-slate-400 opacity-80 -->
<!-- style_universal_hover: hover:bg-white/5 hover:text-violet-200 transition-all duration-300 -->
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 opacity-80 hover:bg-white/5 hover:text-violet-200 transition-all duration-300 active:scale-95 group" href="#">
<span class="material-symbols-outlined text-violet-400/50 group-hover:text-violet-400">pentagon</span>
<span class="text-sm font-medium">Enclos Alpha</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 opacity-80 hover:bg-white/5 hover:text-violet-200 transition-all duration-300 active:scale-95 group" href="#">
<span class="material-symbols-outlined text-violet-400/50 group-hover:text-violet-400">pentagon</span>
<span class="text-sm font-medium">Enclos Bêta</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 opacity-80 hover:bg-white/5 hover:text-violet-200 transition-all duration-300 active:scale-95 group" href="#">
<span class="material-symbols-outlined text-violet-400/50 group-hover:text-violet-400">pentagon</span>
<span class="text-sm font-medium">Enclos Gamma</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 opacity-80 hover:bg-white/5 hover:text-violet-200 transition-all duration-300 active:scale-95 group" href="#">
<span class="material-symbols-outlined text-violet-400/50 group-hover:text-violet-400">pentagon</span>
<span class="text-sm font-medium">Enclos Delta</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 opacity-80 hover:bg-white/5 hover:text-violet-200 transition-all duration-300 active:scale-95 group" href="#">
<span class="material-symbols-outlined text-violet-400/50 group-hover:text-violet-400">pentagon</span>
<span class="text-sm font-medium">Enclos Epsilon</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 opacity-80 hover:bg-white/5 hover:text-violet-200 transition-all duration-300 active:scale-95 group" href="#">
<span class="material-symbols-outlined text-violet-400/50 group-hover:text-violet-400">pentagon</span>
<span class="text-sm font-medium">Enclos Zêta</span>
</a>
</nav>
</div>
<!-- OUTILS -->
<div>
<h3 class="px-4 mb-3 text-[11px] font-label font-bold uppercase tracking-[0.2em] text-on-surface-variant/40">Outils</h3>
<nav class="space-y-1">
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 opacity-80 hover:bg-white/5 hover:text-violet-200 transition-all duration-300 active:scale-95 group" href="#">
<span class="material-symbols-outlined text-violet-400/50 group-hover:text-violet-400">favorite</span>
<span class="text-sm font-medium">Accouplement</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 opacity-80 hover:bg-white/5 hover:text-violet-200 transition-all duration-300 active:scale-95 group" href="#">
<span class="material-symbols-outlined text-violet-400/50 group-hover:text-violet-400">inventory_2</span>
<span class="text-sm font-medium">Inventaire</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 opacity-80 hover:bg-white/5 hover:text-violet-200 transition-all duration-300 active:scale-95 group" href="#">
<span class="material-symbols-outlined text-violet-400/50 group-hover:text-violet-400">account_tree</span>
<span class="text-sm font-medium">Workflows</span>
</a>
</nav>
</div>
</div>
<!-- Footer Section -->
<div class="mt-auto pt-6 px-3 border-t border-white/5">
<nav class="space-y-1">
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 opacity-80 hover:bg-white/5 hover:text-violet-200 transition-all duration-300 active:scale-95 group" href="#">
<span class="material-symbols-outlined text-violet-400/50 group-hover:text-violet-400">settings</span>
<span class="text-sm font-medium">Paramètres</span>
</a>
<div class="flex items-center gap-3 px-4 py-2 text-on-surface-variant/40">
<span class="material-symbols-outlined text-xs">info</span>
<span class="text-[10px] font-label font-bold uppercase tracking-wider">v1.1.5 DEV</span>
</div>
</nav>
</div>
</aside>
<!-- Main Content Area (Background) -->
</div>
</body></html>

BIN
refonte_graphique/sidebar.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@ -0,0 +1,225 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>L'Archive d'Obsidienne - Statistiques</title>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;700;800&amp;family=Inter:wght@400;500;600&amp;family=Plus+Jakarta+Sans:wght@400;500;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"primary-fixed": "#c185fd",
"on-secondary-container": "#ffbeda",
"on-error": "#490013",
"error-container": "#a70138",
"tertiary-container": "#fed01b",
"primary-fixed-dim": "#b378ef",
"surface": "#100c16",
"inverse-on-surface": "#59535f",
"secondary-fixed": "#ffc0db",
"outline": "#7a7380",
"surface-container-highest": "#292332",
"on-primary-container": "#360061",
"secondary-container": "#85145a",
"primary": "#cb97ff",
"on-secondary-fixed": "#690045",
"on-tertiary-fixed": "#433500",
"on-tertiary": "#645000",
"inverse-surface": "#fef7ff",
"surface-container-low": "#16111d",
"on-error-container": "#ffb2b9",
"on-surface": "#f1e8f7",
"error": "#ff6e84",
"primary-dim": "#be83fa",
"error-dim": "#d73357",
"inverse-primary": "#7c41b5",
"surface-container-lowest": "#000000",
"surface-tint": "#cb97ff",
"primary-container": "#c185fd",
"surface-container": "#1c1724",
"on-secondary-fixed-variant": "#922164",
"on-secondary": "#4a002f",
"surface-container-high": "#231d2b",
"on-background": "#f1e8f7",
"tertiary-fixed-dim": "#eec200",
"background": "#100c16",
"secondary-dim": "#f271b5",
"tertiary-dim": "#eec200",
"surface-bright": "#302939",
"on-primary-fixed": "#000000",
"tertiary-fixed": "#fed01b",
"surface-variant": "#292332",
"tertiary": "#ffe083",
"on-surface-variant": "#b0a8b6",
"secondary": "#f673b7",
"on-tertiary-container": "#594700",
"outline-variant": "#4b4652",
"on-primary-fixed-variant": "#430077",
"on-primary": "#46007c",
"on-tertiary-fixed-variant": "#645000",
"secondary-fixed-dim": "#ffabd1",
"surface-dim": "#100c16"
},
fontFamily: {
"headline": ["Manrope"],
"body": ["Inter"],
"label": ["Plus Jakarta Sans"]
},
borderRadius: {"DEFAULT": "0.125rem", "lg": "0.25rem", "xl": "0.5rem", "full": "0.75rem"},
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.glass-panel {
background: rgba(41, 35, 50, 0.6);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
}
.gradient-primary {
background: linear-gradient(135deg, #cb97ff 0%, #c185fd 100%);
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(122, 115, 128, 0.3);
border-radius: 10px;
}
</style>
</head>
<body class="text-on-surface font-body overflow-x-hidden" style="background: radial-gradient(circle at top right, #1e1b4b, #0a0a0f);">
<div class="flex h-screen w-full overflow-hidden">
<!-- Main Content Area (Sidebar removed, now occupies full width) -->
<main class="flex-1 flex flex-col overflow-y-auto relative">
<!-- Header Navigation Tabs -->
<header class="sticky top-0 z-50 glass-panel border-b border-outline-variant/10 px-8">
<div class="flex items-center h-16 justify-between max-w-7xl mx-auto w-full">
<div class="flex items-center gap-6">
<div class="flex items-center gap-8">
<a class="text-on-surface-variant font-bold text-sm hover:text-primary transition-colors py-5 border-b-2 border-transparent" href="#" style="">Tableau de bord</a>
<a class="text-on-surface-variant font-bold text-sm hover:text-primary transition-colors py-5 border-b-2 border-transparent" href="#" style="">Enclos</a>
<a class="text-on-surface font-bold text-sm py-5 border-b-2 border-primary" href="#" style="">Statistiques</a>
</div>
</div>
<div class="flex items-center gap-4">
</div>
</div>
</header>
<div class="p-8 max-w-7xl mx-auto w-full space-y-8">
<!-- Hero Title Section -->
<div class="space-y-2">
<h2 class="font-headline text-4xl font-extrabold tracking-tight text-on-surface" style="">Statistiques d'Élevage</h2>
<p class="text-on-surface-variant text-base max-w-2xl" style="">Aperçu analytique approfondi des performances biologiques de votre archive.</p>
</div>
<!-- KPI Cards Grid -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-6">
<div class="bg-surface-container p-6 rounded-2xl flex flex-col justify-between border border-outline-variant/5">
<p class="font-label text-on-surface-variant text-xs font-bold uppercase tracking-widest" style="">Total Naissances</p>
<div class="flex items-baseline gap-3 mt-4">
<span class="font-headline text-4xl font-black text-on-surface" style="">1,284</span>
<span class="text-tertiary text-sm font-bold bg-tertiary/10 px-2 py-0.5 rounded" style="">+12%</span>
</div>
</div>
<div class="bg-surface-container p-6 rounded-2xl flex flex-col justify-between border border-outline-variant/5">
<p class="font-label text-on-surface-variant text-xs font-bold uppercase tracking-widest" style="">Taux de Réussite</p>
<div class="flex items-baseline gap-3 mt-4">
<span class="font-headline text-4xl font-black text-on-surface" style="">87%</span>
<span class="text-tertiary text-sm font-bold bg-tertiary/10 px-2 py-0.5 rounded" style="">+3.2%</span>
</div>
</div>
<div class="bg-surface-container p-6 rounded-2xl flex flex-col justify-between border border-outline-variant/5">
<p class="font-label text-on-surface-variant text-xs font-bold uppercase tracking-widest" style="">Couples Actifs</p>
<div class="flex items-baseline gap-3 mt-4">
<span class="font-headline text-4xl font-black text-on-surface" style="">42</span>
<span class="text-error text-sm font-bold bg-error/10 px-2 py-0.5 rounded" style="">-2</span>
</div>
</div>
<div class="bg-surface-container p-6 rounded-2xl flex flex-col justify-between border border-outline-variant/5">
<p class="font-label text-on-surface-variant text-xs font-bold uppercase tracking-widest" style="">Races obtenues</p>
<div class="flex items-baseline gap-3 mt-4">
<span class="font-headline text-4xl font-black text-on-surface" style="">12</span>
<span class="text-secondary text-sm font-bold bg-secondary/10 px-2 py-0.5 rounded" style="">Rare</span>
</div>
</div>
</div>
<!-- Main Chart Area -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Line Chart: Evolution des Naissances -->
<div class="lg:col-span-2 bg-surface-container-high rounded-3xl p-8 flex flex-col gap-8 border border-outline-variant/5">
<div class="flex items-center justify-between">
<h3 class="font-headline text-2xl font-bold" style="">Évolution des Naissances</h3>
<span class="px-3 py-1 bg-surface-container-highest rounded-full text-xs font-bold text-on-surface tracking-wide" style="">30 DERNIERS JOURS</span>
</div>
<!-- Visual Placeholder for Chart -->
<div class="h-64 w-full relative flex items-end justify-between px-4 pb-6 border-b border-l border-outline-variant/20">
<div class="w-2.5 h-[20%] bg-primary/20 rounded-t-lg transition-all hover:h-[25%] hover:bg-primary/40"></div>
<div class="w-2.5 h-[35%] bg-primary/20 rounded-t-lg transition-all hover:h-[40%] hover:bg-primary/40"></div>
<div class="w-2.5 h-[30%] bg-primary/20 rounded-t-lg transition-all hover:h-[35%] hover:bg-primary/40"></div>
<div class="w-2.5 h-[45%] bg-primary/20 rounded-t-lg transition-all hover:h-[50%] hover:bg-primary/40"></div>
<div class="w-2.5 h-[60%] bg-primary/40 rounded-t-lg transition-all hover:h-[65%] hover:bg-primary/60"></div>
<div class="w-2.5 h-[55%] bg-primary/40 rounded-t-lg transition-all hover:h-[60%] hover:bg-primary/60"></div>
<div class="w-2.5 h-[80%] gradient-primary rounded-t-lg shadow-[0_0_20px_rgba(203,151,255,0.4)] transition-all hover:scale-x-110"></div>
<div class="w-2.5 h-[75%] bg-primary/60 rounded-t-lg transition-all hover:h-[80%] hover:bg-primary/80"></div>
<div class="w-2.5 h-[65%] bg-primary/60 rounded-t-lg transition-all hover:h-[70%] hover:bg-primary/80"></div>
<div class="w-2.5 h-[90%] gradient-primary rounded-t-lg shadow-[0_0_20px_rgba(203,151,255,0.4)]"></div>
<div class="w-2.5 h-[85%] bg-primary/80 rounded-t-lg"></div>
<div class="w-2.5 h-[70%] bg-primary/60 rounded-t-lg"></div>
<div class="w-2.5 h-[50%] bg-primary/40 rounded-t-lg"></div>
<div class="w-2.5 h-[40%] bg-primary/30 rounded-t-lg"></div>
<div class="w-2.5 h-[60%] gradient-primary rounded-t-lg"></div>
</div>
</div>
<!-- Pie Chart: Répartition des Races -->
<div class="bg-surface-container-high rounded-3xl p-8 flex flex-col gap-8 border border-outline-variant/5">
<h3 class="font-headline text-2xl font-bold" style="">Répartition des Races</h3>
<div class="flex-1 flex flex-col justify-center items-center gap-10">
<div class="relative w-40 h-40 rounded-full border-[15px] border-surface-container-highest flex items-center justify-center">
<div class="absolute inset-0 border-[15px] border-secondary rounded-full" style="clip-path: polygon(50% 50%, 50% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 50%);"></div>
<div class="absolute inset-0 border-[15px] border-primary rounded-full" style="clip-path: polygon(50% 50%, 50% 0%, 100% 0%, 100% 40%);"></div>
<div class="flex flex-col items-center">
<span class="font-headline text-3xl font-black" style="">12</span>
<span class="font-label text-[10px] uppercase font-bold text-on-surface-variant tracking-widest" style="">TYPES</span>
</div>
</div>
<div class="w-full space-y-4">
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-3">
<div class="w-3 h-3 rounded-full bg-primary"></div>
<span class="font-semibold" style="">Ebène</span>
</div>
<span class="font-black text-lg" style="">45%</span>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-3">
<div class="w-3 h-3 rounded-full bg-secondary"></div>
<span class="font-semibold" style="">Indigo</span>
</div>
<span class="font-black text-lg" style="">35%</span>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-3">
<div class="w-3 h-3 rounded-full bg-tertiary"></div>
<span class="font-semibold" style="">Dores</span>
</div>
<span class="font-black text-lg" style="">20%</span>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

View File

@ -0,0 +1,305 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>L'Archive d'Obsidienne - Suivi de Workflow</title>
<link href="https://fonts.googleapis.com" rel="preconnect"/>
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&amp;family=Inter:wght@300;400;500;600;700&amp;family=JetBrains+Mono:wght@400;500&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
primary: "#a855f7", // Vibrant Purple/Amethyst
secondary: "#ec4899", // Pink accent
"background-light": "#fdfaff",
"background-dark": "#0f0913", // Deep obsidian dark
"surface-dark": "#1a1221",
"border-dark": "#2d1b3d",
},
fontFamily: {
display: ["Cinzel", "serif"],
sans: ["Inter", "sans-serif"],
mono: ["JetBrains Mono", "monospace"],
},
borderRadius: {
DEFAULT: "0.75rem",
'xl': '1rem',
},
},
},
};
</script>
<style>
body {
font-family: 'Inter', sans-serif;
scrollbar-width: thin;
scrollbar-color: #4b2c6d #0f0913;
}
.obsidian-glass {
background: rgba(26, 18, 33, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(168, 85, 247, 0.15);
}
.step-connector {
width: 2px;
background: linear-gradient(to bottom, #a855f7, #ec4899);
height: 40px;
margin: 0 auto;
}
.glow-amethyst {
box-shadow: 0 0 20px rgba(168, 85, 247, 0.15);
}
.text-gradient {
background: linear-gradient(135deg, #e9d5ff, #fbcfe8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
</style>
</head>
<body class="text-slate-800 dark:text-slate-100 min-h-screen flex flex-col overflow-hidden" style="background: radial-gradient(circle at top right, #1e1b4b, #0a0a0f);">
<header class="h-16 border-b border-slate-200 dark:border-border-dark flex items-center justify-between px-8 backdrop-blur-md z-30 bg-transparent">
<div class="flex items-center gap-6">
<div class="h-8 w-px bg-slate-300 dark:bg-border-dark mx-2"></div>
<button class="flex items-center gap-2 text-slate-500 hover:text-primary transition-colors">
<span class="material-icons-round">arrow_back</span>
<span class="text-sm font-medium">Retour au planneur</span>
</button>
<div class="h-4 w-px bg-slate-300 dark:bg-border-dark mx-2"></div>
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
<span class="material-icons-round text-primary text-sm">timeline</span>
</div>
<h2 class="font-display text-lg tracking-wide uppercase text-gradient">Objectif : Pourpre et Rousse</h2>
</div>
</div>
<div class="flex items-center gap-6">
<div class="flex flex-col items-end">
<span class="text-[10px] font-bold uppercase tracking-widest text-slate-500">Progression Globale</span>
<span class="text-lg font-mono font-bold text-secondary">14%</span>
</div>
<div class="w-48 h-2 bg-slate-200 dark:bg-border-dark rounded-full overflow-hidden">
<div class="h-full bg-gradient-to-r from-primary to-secondary w-[14%] rounded-full"></div>
</div>
</div>
</header>
<main class="flex-1 overflow-y-auto scroll-smooth">
<div class="max-w-6xl mx-auto p-8 space-y-6">
<div class="obsidian-glass glow-amethyst p-6 rounded-2xl flex items-center justify-between">
<div class="flex items-center gap-6">
<div class="relative">
<div class="w-20 h-20 rounded-2xl bg-gradient-to-br from-purple-500/20 to-pink-500/20 border border-primary/30 flex items-center justify-center overflow-hidden">
<img alt="Dragodinde Cible" class="w-16 h-16 object-contain" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCoKu4mNqXi35MRSKnXVTjEuySXB77DjEY_yLEczHlrEaSZq1nML0kCKxwokv0FadOz-tPD9IFRhICZqDs7kz3XUVRH_tA2_bccC4eC8O-wfP6EpDLHVYXEVaeLfjgscRdjN5_DvmiY2akAFIB84r6GvsVV4wa5Pqpz68INTvKdJd7fI9JShSMVZW3u-0MC-xHmlSWA94RHuUp2tlQBhl5_-rwf3wIpN4M5tPWP0P0h7Eqqk9kLqQruN-BTIEYaYiIfv5d1UDvyJa-9"/>
</div>
<span class="absolute -top-2 -right-2 bg-secondary px-2 py-0.5 rounded text-[10px] font-bold text-white shadow-lg">GEN 6</span>
</div>
<div>
<h3 class="text-2xl font-display font-bold tracking-wide">1x Pourpre et Rousse</h3>
<p class="text-slate-400 text-sm mt-1">Estimation : ~42 jours de reproduction • Plan de croisement optimisé</p>
</div>
</div>
<div class="flex gap-4">
<div class="text-center px-4 py-2 bg-white/5 rounded-xl border border-white/5">
<p class="text-[10px] uppercase tracking-widest text-slate-500 mb-1">Naissances</p>
<p class="font-mono text-xl font-bold">0 / 21</p>
</div>
<div class="text-center px-4 py-2 bg-white/5 rounded-xl border border-white/5">
<p class="text-[10px] uppercase tracking-widest text-slate-500 mb-1">Croisements</p>
<p class="font-mono text-xl font-bold">0 / 8</p>
</div>
</div>
</div>
<div class="relative">
<div class="flex items-center gap-3 mb-4">
<span class="bg-primary/20 text-primary px-3 py-1 rounded-full text-[10px] font-bold tracking-tighter uppercase border border-primary/30">Étape 1/6</span>
<h4 class="font-display text-sm tracking-[0.2em] uppercase text-slate-300">Géniteurs de Base (Matières Premières)</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="obsidian-glass p-4 rounded-xl flex items-center gap-4 hover:border-primary/50 transition-all cursor-pointer group">
<div class="w-14 h-14 bg-slate-800/50 rounded-lg flex items-center justify-center relative">
<img alt="Rousse" class="w-10 h-10 opacity-80" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBocEHO9lFNQTkugJfpR3cNLYnhj7h16V_DN4IMbeDf8vtGwues9fwpGeUxhMAveO9URvc-E2tc8XWN53BFXdOyYUgJ9VjVjQBTGWN3U9my7ImWn5n40xmnD-J1BNLEgwxslhss50HN82FDrBagfpC_YnKXV_Pj1OHxa5SI822GYJCnbOeNCJmnyH1yq0fD0aVHe6NDY1eC0VEq_WnYBSUVjuZ7raYyTRni2RAN72VvLUwgfo6c065ZB1hF8o1A7bNP64qyYArsl_c0"/>
<span class="absolute bottom-0 right-0 bg-orange-500/20 text-orange-400 text-[8px] px-1 font-bold rounded">G1</span>
</div>
<div class="flex-1">
<p class="font-semibold text-sm">Dragodinde Rousse</p>
<div class="flex items-center justify-between mt-2">
<span class="text-xs text-slate-500">Besoin: <span class="text-slate-200">5</span></span>
<div class="flex items-center gap-2">
<span class="text-xs font-mono font-bold text-primary">0 / 5</span>
<div class="w-12 h-1 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-primary w-0"></div>
</div>
</div>
</div>
</div>
</div>
<div class="obsidian-glass p-4 rounded-xl flex items-center gap-4 hover:border-primary/50 transition-all cursor-pointer group border-emerald-500/20 bg-emerald-500/5">
<div class="w-14 h-14 bg-slate-800/50 rounded-lg flex items-center justify-center relative">
<img alt="Amande" class="w-10 h-10 opacity-80" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAyA9PM-9qDKQec3mMWUdg29cUN3F-4M6ST6sgCAyrXy_vf99mviA33fYNgq-7zt-m5LkWIlApVCM5RiPJuYy9QEy7U7ofnxTq7WO3q7s7L7l5VoyEbroiQdhnHHmGOTAMUAt-zgmizspyiJDoGjeYWv7pH6z0V2kZBMKJVEDxunMTO3ey2HYPoxUYjrC_WKrmiX7gnnnvwN59YbVjb22vQx1DGVDlqze_t-f9VNMTqv1Z6wYs83VP3zkWPj6Z8dn-Ft8bwqLyxKDKo"/>
<span class="absolute bottom-0 right-0 bg-emerald-500/20 text-emerald-400 text-[8px] px-1 font-bold rounded">G1</span>
</div>
<div class="flex-1">
<div class="flex items-center justify-between">
<p class="font-semibold text-sm">Dragodinde Amande</p>
<span class="material-icons-round text-emerald-500 text-sm">check_circle</span>
</div>
<div class="flex items-center justify-between mt-2">
<span class="text-xs text-slate-500">Besoin: <span class="text-slate-200">4</span></span>
<div class="flex items-center gap-2">
<span class="text-xs font-mono font-bold text-emerald-400">4 / 4</span>
<div class="w-12 h-1 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-emerald-500 w-full"></div>
</div>
</div>
</div>
</div>
</div>
<div class="obsidian-glass p-4 rounded-xl flex items-center gap-4 hover:border-primary/50 transition-all cursor-pointer group">
<div class="w-14 h-14 bg-slate-800/50 rounded-lg flex items-center justify-center relative">
<img alt="Dorée" class="w-10 h-10 opacity-80" src="https://lh3.googleusercontent.com/aida-public/AB6AXuA_2jjG8HOhZSNs4GDsGtFUzqjGzSJn1yFMaAtiYIwtSr1i4ukS1lEWFZ079Pv5Tw09mvw2yiBEWOYdFrRwun4LM9i-pGnR7P9b6pYpuNnRSaojkNLh7f6aMPxl2974om_CjM60pObo6XqelcoXnq79lC4cs2R5rtnfz7tSfXuz8gxsXXVJs9BQxNotSFLXBCzWgg4CZXglzo3iVpPrrhXolBt4oiR9bgnlbRWPG1gzg73QrZHF89FdPrSivvoQb8xxqsW38BsNfVHF"/>
<span class="absolute bottom-0 right-0 bg-yellow-500/20 text-yellow-400 text-[8px] px-1 font-bold rounded">G1</span>
</div>
<div class="flex-1">
<p class="font-semibold text-sm">Dragodinde Dorée</p>
<div class="flex items-center justify-between mt-2">
<span class="text-xs text-slate-500">Besoin: <span class="text-slate-200">2</span></span>
<div class="flex items-center gap-2">
<span class="text-xs font-mono font-bold text-primary">0 / 2</span>
<div class="w-12 h-1 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-primary w-0"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="step-connector"></div>
<div>
<div class="flex items-center gap-3 mb-4">
<span class="bg-primary/20 text-primary px-3 py-1 rounded-full text-[10px] font-bold tracking-tighter uppercase border border-primary/30">Étape 2/6</span>
<h4 class="font-display text-sm tracking-[0.2em] uppercase text-slate-300">Croisements — Génération 2</h4>
</div>
<div class="space-y-4">
<div class="obsidian-glass p-6 rounded-xl relative overflow-hidden">
<div class="absolute top-0 right-0 bg-white/5 px-4 py-1 rounded-bl-xl border-l border-b border-white/5 text-[10px] font-bold text-slate-500 uppercase">Couple Actif</div>
<div class="flex items-center justify-center gap-12">
<div class="flex items-center gap-4 text-center">
<div class="w-16 h-16 rounded-xl bg-slate-800 flex items-center justify-center relative border border-white/5">
<img alt="Parent 1" class="w-12 h-12" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDza0z0RrsUScR9foq9x4Id3lHyEHM8b4kjomX9WHXhQkwqYaapBmqunPABwrBo4VqT6B_oVL77KBbTq3MJmjZxFKQDyrvsJM6IoJchPtqMK29E3jhp20kVa2S7inUmI3OWv0VE4lYIn3cteIg6jNAZ_xRFeHjCJhucAJ3q5FtA-cAIUtH35WbvXVSQcRCH0IUHRCxpqo0NfBeH0Ec-N70swYENSJ_PCPRQOkSUYkmHlGjMO5diZybOCqVxxnayVXFslp7EqHvnORla"/>
<span class="absolute -top-2 -left-2 bg-emerald-500/20 text-emerald-400 text-[8px] px-1.5 py-0.5 font-bold rounded">G1</span>
</div>
<span class="text-slate-500 font-bold">+</span>
<div class="w-16 h-16 rounded-xl bg-slate-800 flex items-center justify-center relative border border-white/5">
<img alt="Parent 2" class="w-12 h-12" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBREtxx1FAMTP5eSgKwcHSCmNiDKRZ-8MFTQjUq3BVRKAEl1L21YOP981NLH5GIjtxYGWB4Ar9TPWcpTjwJXVizukh0G3uF81cYALzK40Kicrt-YxC9_21RVBdic1MbQ3Y2A4dXHWLQfA0Hjhn3ykvbx5DXAPQ4ZrcNXksCk0YWEmTS_3Z_AO5NuYedZaEnFyMrNi6pT31M0DISu_RL-1TffWA8DtyTQ98bdAmxrgsds_81OK12pzp4sBwoWSlbiMa38mepfGbryps6"/>
<span class="absolute -top-2 -right-2 bg-orange-500/20 text-orange-400 text-[8px] px-1.5 py-0.5 font-bold rounded">G1</span>
</div>
</div>
<span class="material-icons-round text-primary text-3xl">double_arrow</span>
<div class="flex items-center gap-6">
<div class="w-20 h-20 rounded-2xl bg-primary/10 flex items-center justify-center relative border-2 border-primary/40">
<img alt="Result" class="w-14 h-14" src="https://lh3.googleusercontent.com/aida-public/AB6AXuChxzuJKl7IwLukMnkl63Ea0oMoaB3BMVHFY8Ikktabn8uGhO9XfyBkIEmmnEsa24yvNBJX1GCM8dXVE5WxPiGlpnpid8qCXa9OS6IS1HNwJTeZF6WZh9oMJwInUzjkwFtU__zyn-FPmldyvzAywLnGi36aTYc1YRjZ9Q5k9hmJ7yE0KPSW6J_Kxkw1k_JphFnlgGM76yfe1GIhS2VYjEgY4xYEDdwbQ2PspIKIjRhgVVmyzdh99ZQOLJIus2VoJcz85ASeD9l4KVD_"/>
<span class="absolute -bottom-2 -right-2 bg-primary text-white text-[10px] px-2 py-0.5 font-bold rounded shadow-lg">G2</span>
</div>
<div class="text-left">
<p class="font-bold text-lg">Amande et Rousse</p>
<p class="text-xs text-slate-500 mb-2 font-medium">Objectif : <span class="text-slate-200">3 réussis</span></p>
<div class="flex items-center gap-3">
<div class="flex gap-1">
<span class="w-2 h-2 rounded-full bg-primary shadow-[0_0_8px_rgba(168,85,247,0.6)]"></span>
<span class="w-2 h-2 rounded-full bg-primary/20"></span>
<span class="w-2 h-2 rounded-full bg-primary/20"></span>
</div>
<span class="text-xs font-mono font-bold text-primary">1 / 3</span>
</div>
</div>
</div>
</div>
</div>
<div class="obsidian-glass p-6 rounded-xl opacity-60">
<div class="flex items-center justify-center gap-12">
<div class="flex items-center gap-4 text-center">
<div class="w-16 h-16 rounded-xl bg-slate-800 flex items-center justify-center relative border border-white/5">
<img alt="Parent 1" class="w-12 h-12" src="https://lh3.googleusercontent.com/aida-public/AB6AXuB6J8mSJ9lOBN8lxvUpIGvivBaqALKlL7SHNH8RRrL4m0LWXsquT9c2BVpi2Yz93NAggWc_LyUz4mD7Nw0m35yCAm15sshTcVDkSR5uzDdqYhbytSK0DpMIILsc9A-e2XdfgyqvFTfb7PlSYzZjzswtFRg3MieZrrr9HubftsyXjX3holy2mUZLcfykrv6jWvAha8IWVBkmx3YXtb4YJjK3Sx8PcZpBKhQlBRywOX00flOxAkdX1CHvwyBjA_LIfPsZBS5kQSdGV2dZ"/>
<span class="absolute -top-2 -left-2 bg-orange-500/20 text-orange-400 text-[8px] px-1.5 py-0.5 font-bold rounded">G1</span>
</div>
<span class="text-slate-500 font-bold">+</span>
<div class="w-16 h-16 rounded-xl bg-slate-800 flex items-center justify-center relative border border-white/5">
<img alt="Parent 2" class="w-12 h-12" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAHIkloRWUlYHmSWpbDWUSvdL9avx28sqz463AxAuIIQXWuW5YNMz2DplTGtg7IbTYdnhOFjX9j3kbrI5V3xUEPkxApedxT6nn-xbWI_RbwqvMhNWca1Po0piz6Zdjkdn3RwSiFHr1oqu2XDHFyiVblfiVhdGybI62ckdskq4LFZ6XRQeeAQHZXCcxPvgug4M6fYt-9XKSRqtnh7pVpX3e_CPTlVP7vgWpF_Xrff3UAynpHdH7a-GmdZvwLLcUlXZrlu3odxIG9-5tf"/>
<span class="absolute -top-2 -right-2 bg-yellow-500/20 text-yellow-400 text-[8px] px-1.5 py-0.5 font-bold rounded">G1</span>
</div>
</div>
<span class="material-icons-round text-slate-700 text-3xl">double_arrow</span>
<div class="flex items-center gap-6">
<div class="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center relative border border-white/10">
<img alt="Result" class="w-14 h-14 grayscale opacity-40" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBVW-vdt9zhIcIW99l32-Bv_1s1f1wB83YyC3AVHA7PX7Q9Z707fi91Pxog9Cq1Q4MyeWebnm1u1sUoaBvJet26M-A9ODYazuZXsM_wmebUK4OhlwZ0prjlkSOzrZdCENm6bXiZ-h6HPNazpkvdC10QzodONxcjq0Oeyf4H8xS3rjs6nq2_vWl8mdBwJ70B8GEtyF0bf7tGA4vJW1zW-bSH42eXuSVajkcY9SPcfucdOz5ksnDqQaINJzCLSynKMkb6fq31h6faBWyg"/>
<span class="absolute -bottom-2 -right-2 bg-slate-700 text-white text-[10px] px-2 py-0.5 font-bold rounded">G2</span>
</div>
<div class="text-left">
<p class="font-bold text-lg text-slate-500">Dorée et Rousse</p>
<p class="text-xs text-slate-600 mb-2 font-medium">Objectif : <span class="text-slate-400">1 réussi</span></p>
<div class="flex items-center gap-3">
<div class="flex gap-1">
<span class="w-2 h-2 rounded-full bg-slate-700"></span>
</div>
<span class="text-xs font-mono font-bold text-slate-600">0 / 1</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="step-connector"></div>
<div class="opacity-40">
<div class="flex items-center gap-3 mb-4">
<span class="bg-slate-800 text-slate-500 px-3 py-1 rounded-full text-[10px] font-bold tracking-tighter uppercase border border-white/5">Étape 3/6</span>
<h4 class="font-display text-sm tracking-[0.2em] uppercase text-slate-600">Croisements — Génération 3</h4>
</div>
<div class="obsidian-glass p-12 rounded-xl flex items-center justify-center border-dashed border-slate-800">
<div class="text-center">
<span class="material-icons-round text-slate-700 text-4xl mb-2">lock</span>
<p class="text-sm font-medium text-slate-600">Terminez les étapes précédentes pour débloquer</p>
</div>
</div>
</div>
<div class="h-20"></div>
</div>
</main>
<footer class="bg-surface-dark border-t border-border-dark p-4 flex items-center justify-between px-8 z-30">
<div class="flex items-center gap-8">
<div class="flex flex-col">
<span class="text-[10px] text-slate-500 uppercase font-bold tracking-wider">État du stock global</span>
<div class="flex gap-4 mt-1">
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
<span class="text-xs font-mono">4 Amandes</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-orange-500"></span>
<span class="text-xs font-mono">0/5 Rousses</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-yellow-500"></span>
<span class="text-xs font-mono">0/2 Dorées</span>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div class="px-4 py-1.5 rounded-lg bg-primary/10 border border-primary/20 text-primary text-xs font-bold flex items-center gap-2">
<span class="material-icons-round text-sm">auto_awesome</span>
Calculateur de rentabilité : Optimal
</div>
<button class="bg-primary hover:bg-primary/90 text-white px-6 py-2 rounded-xl font-bold text-sm transition-all shadow-lg shadow-primary/20 flex items-center gap-2">
<span class="material-icons-round text-sm">sync</span>
Mettre à jour le stock
</button>
</div>
</footer>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

View File

@ -0,0 +1,231 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>The Obsidian Archive - Sommaire des Workflows</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;700;800&amp;family=Inter:wght@400;500;600&amp;family=Plus+Jakarta+Sans:wght@400;500;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"on-error-container": "#ffb2b9",
"secondary-fixed-dim": "#ffabd1",
"on-primary-container": "#360061",
"surface-dim": "#100c16",
"on-secondary-fixed-variant": "#922164",
"tertiary-container": "#fed01b",
"secondary-dim": "#f271b5",
"inverse-on-surface": "#59535f",
"on-background": "#f1e8f7",
"inverse-surface": "#fef7ff",
"on-primary": "#46007c",
"error-container": "#a70138",
"surface-container": "#1c1724",
"secondary": "#f673b7",
"on-error": "#490013",
"tertiary": "#ffe083",
"on-primary-fixed": "#000000",
"background": "#0a0a0f",
"tertiary-fixed": "#fed01b",
"tertiary-fixed-dim": "#eec200",
"surface-container-lowest": "#000000",
"surface-container-high": "#171721",
"on-secondary": "#4a002f",
"on-surface-variant": "#b0a8b6",
"surface-container-highest": "#23232d",
"on-secondary-container": "#ffbeda",
"outline-variant": "#4b4652",
"inverse-primary": "#7c41b5",
"surface-bright": "#302939",
"on-surface": "#f1e8f7",
"tertiary-dim": "#eec200",
"secondary-fixed": "#ffc0db",
"primary-container": "#c185fd",
"outline": "#7a7380",
"error-dim": "#d73357",
"primary-fixed-dim": "#b378ef",
"primary": "#cb97ff",
"error": "#ff6e84",
"surface-container-low": "#111119",
"primary-fixed": "#c185fd",
"on-tertiary-fixed-variant": "#645000",
"on-secondary-fixed": "#690045",
"secondary-container": "#85145a",
"primary-dim": "#be83fa",
"on-primary-fixed-variant": "#430077",
"surface-variant": "#292332",
"surface": "#0a0a0f",
"on-tertiary-container": "#594700",
"surface-tint": "#cb97ff",
"on-tertiary-fixed": "#433500",
"on-tertiary": "#645000"
},
fontFamily: {
"headline": ["Manrope"],
"body": ["Inter"],
"label": ["Plus Jakarta Sans"]
},
borderRadius: {"DEFAULT": "0.125rem", "lg": "0.25rem", "xl": "0.5rem", "full": "0.75rem"},
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
vertical-align: middle;
}
body {
font-family: 'Inter', sans-serif;
background: radial-gradient(circle at top right, #1e1b4b, #0a0a0f);
color: #f1e8f7;
min-height: 100vh;
}
.glass-panel {
background: rgba(23, 23, 33, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(168, 85, 247, 0.15);
}
.glow-bar-fill {
box-shadow: 0 0 10px rgba(203, 151, 255, 0.4);
}
</style>
</head>
<body class="flex flex-col min-h-screen">
<!-- TopNavBar -->
<header class="h-20 flex justify-between items-center w-full px-12 bg-[#0a0a0f]/80 backdrop-blur-md sticky top-0 z-30 border-b border-[#4b4652]/15">
<div class="flex items-center gap-12 h-full">
<nav class="flex h-full gap-8">
<a class="flex items-center text-[#b0a8b6] hover:text-[#f1e8f7] transition-colors text-sm font-medium font-body border-b-2 border-transparent" href="#">Tableau de bord</a>
<a class="flex items-center text-[#b0a8b6] hover:text-[#f1e8f7] transition-colors text-sm font-medium font-body border-b-2 border-transparent" href="#">Enclos</a>
<a class="flex items-center hover:text-[#f1e8f7] transition-colors text-sm font-medium font-body border-b-2 text-[#cb97ff] border-[#cb97ff] font-semibold" href="#">Statistiques</a>
</nav>
</div>
<div class="flex items-center gap-4">
</div>
</header>
<!-- Main Content Canvas -->
<main class="flex-1 flex flex-col bg-transparent min-h-screen">
<!-- Content Area -->
<div class="max-w-7xl mx-auto w-full p-12 space-y-12">
<!-- Welcome/Header Section -->
<section class="flex justify-between items-end">
<div>
<h3 class="text-4xl font-extrabold font-headline text-[#f1e8f7] tracking-tight">Sommaire des Plans</h3>
<p class="text-[#b0a8b6] font-body mt-2 text-lg">Gérez et suivez l'évolution de vos plans de reproduction.</p>
</div>
<div class="flex items-center gap-3">
<span class="text-[10px] font-label text-on-surface-variant bg-surface-container-high px-4 py-2 rounded-full uppercase tracking-widest border border-outline-variant/20">3 Plans Actifs</span>
</div>
</section>
<!-- Bento Grid of Breeding Plans -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<!-- Card 1: Dynastie Pourpre G5 -->
<div class="group bg-surface-container-high rounded-xl p-8 transition-all duration-300 hover:bg-surface-container-highest border border-white/5 hover:border-primary/20 relative overflow-hidden shadow-xl shadow-black/20">
<div class="flex justify-between items-start mb-8">
<div class="space-y-1">
<span class="text-[10px] text-primary font-label font-bold uppercase tracking-wider">Lignée Royale</span>
<h4 class="text-2xl font-bold font-headline text-on-surface">Amande/Rousse</h4>
</div>
<div class="w-14 h-14 bg-secondary-container/30 rounded-full flex items-center justify-center text-secondary">
<span class="material-symbols-outlined text-3xl" data-icon="dna" style='font-variation-settings: "FILL" 1;'>genetics</span>
</div>
</div>
<div class="space-y-6">
<div class="flex justify-between items-end text-sm">
<span class="text-on-surface-variant font-body">Progression Actuelle</span>
<span class="text-primary font-bold font-headline text-lg">85%</span>
</div>
<div class="w-full h-2.5 bg-surface-container-highest rounded-full overflow-hidden">
<div class="h-full bg-gradient-to-r from-primary to-primary-container rounded-full glow-bar-fill" style="width: 85%"></div>
</div>
<div class="flex items-center justify-between pt-6 border-t border-outline-variant/10">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-sm text-on-surface-variant" data-icon="history">history</span>
<span class="text-[11px] text-on-surface-variant font-label">Dernière modif : Hier, 14:20</span>
</div>
<button class="p-3 rounded-full bg-surface-container-low text-primary group-hover:bg-primary group-hover:text-on-primary transition-all duration-300 transform group-hover:translate-x-1 shadow-lg">
<span class="material-symbols-outlined" data-icon="arrow_forward">arrow_forward</span>
</button>
</div>
</div>
</div>
<!-- Card 2: Projet Nébuleuse G2 -->
<div class="group bg-surface-container-high rounded-xl p-8 transition-all duration-300 hover:bg-surface-container-highest border border-white/5 hover:border-primary/20 relative overflow-hidden shadow-xl shadow-black/20">
<div class="flex justify-between items-start mb-8">
<div class="space-y-1">
<span class="text-[10px] text-tertiary font-label font-bold uppercase tracking-wider">Lignée Expérimentale</span>
<h4 class="text-2xl font-bold font-headline text-on-surface">Rousse/Dorée</h4>
</div>
<div class="w-14 h-14 bg-tertiary-container/10 rounded-full flex items-center justify-center text-tertiary">
<span class="material-symbols-outlined text-3xl" data-icon="hub" style='font-variation-settings: "FILL" 1;'>hub</span>
</div>
</div>
<div class="space-y-6">
<div class="flex justify-between items-end text-sm">
<span class="text-on-surface-variant font-body">Progression Actuelle</span>
<span class="text-tertiary font-bold font-headline text-lg">32%</span>
</div>
<div class="w-full h-2.5 bg-surface-container-highest rounded-full overflow-hidden">
<div class="h-full bg-tertiary rounded-full glow-bar-fill" style="width: 32%"></div>
</div>
<div class="flex items-center justify-between pt-6 border-t border-outline-variant/10">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-sm text-on-surface-variant" data-icon="history">history</span>
<span class="text-[11px] text-on-surface-variant font-label">Dernière modif : 3 janv.</span>
</div>
<button class="p-3 rounded-full bg-surface-container-low text-tertiary group-hover:bg-tertiary group-hover:text-on-tertiary transition-all duration-300 transform group-hover:translate-x-1 shadow-lg">
<span class="material-symbols-outlined" data-icon="arrow_forward">arrow_forward</span>
</button>
</div>
</div>
</div>
<!-- Card 3: Gardiens Onyx G8 -->
<div class="group bg-surface-container-high rounded-xl p-8 transition-all duration-300 hover:bg-surface-container-highest border border-white/5 hover:border-primary/20 relative overflow-hidden shadow-xl shadow-black/20">
<div class="flex justify-between items-start mb-8">
<div class="space-y-1">
<span class="text-[10px] text-secondary font-label font-bold uppercase tracking-wider">Lignée Défensive</span>
<h4 class="text-2xl font-bold font-headline text-on-surface">Amande/Dorée</h4>
</div>
<div class="w-14 h-14 bg-secondary-container/20 rounded-full flex items-center justify-center text-secondary">
<span class="material-symbols-outlined text-3xl" data-icon="shield" style='font-variation-settings: "FILL" 1;'>shield</span>
</div>
</div>
<div class="space-y-6">
<div class="flex justify-between items-end text-sm">
<span class="text-on-surface-variant font-body">Progression Actuelle</span>
<span class="text-secondary font-bold font-headline text-lg">61%</span>
</div>
<div class="w-full h-2.5 bg-surface-container-highest rounded-full overflow-hidden">
<div class="h-full bg-secondary rounded-full glow-bar-fill" style="width: 61%"></div>
</div>
<div class="flex items-center justify-between pt-6 border-t border-outline-variant/10">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-sm text-on-surface-variant" data-icon="history">history</span>
<span class="text-[11px] text-on-surface-variant font-label">Dernière modif : 22 déc.</span>
</div>
<button class="p-3 rounded-full bg-surface-container-low text-secondary group-hover:bg-secondary group-hover:text-on-secondary transition-all duration-300 transform group-hover:translate-x-1 shadow-lg">
<span class="material-symbols-outlined" data-icon="arrow_forward">arrow_forward</span>
</button>
</div>
</div>
</div>
<!-- Card 4: New Plan Template (Subtle CTA) -->
<div class="group border-2 border-dashed border-outline-variant/30 rounded-xl p-8 transition-all duration-300 hover:border-primary/50 hover:bg-primary/5 flex flex-col items-center justify-center gap-6 cursor-pointer min-h-[300px]">
<div class="w-20 h-20 rounded-full bg-surface-container-highest flex items-center justify-center text-primary-container group-hover:scale-110 transition-transform shadow-inner">
<span class="material-symbols-outlined text-5xl" data-icon="add_circle">add_circle</span>
</div>
<div class="text-center">
<h4 class="text-xl font-bold font-headline text-on-surface">Nouveau Plan</h4>
<p class="text-sm text-on-surface-variant font-body mt-2">Lancer un nouveau cycle de sélection</p>
</div>
</div>
</div>
</div>
</main>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

View File

@ -0,0 +1,458 @@
import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import { RACES_DATA, GEN_COLORS, RACE_GEN, BREEDING_BY_PARENTS, COMPATIBLE_PARTNERS } from '@domain/value-objects/Race';
import { getDDImage } from '@presentation/helpers/dd-image';
import { esc } from '@presentation/helpers/format';
import { Toast } from './Toast';
interface AccoupState {
parent1: string | null;
parent2: string | null;
filterGen: number | null;
search: string;
couples: string;
babies: string;
selectingSlot: 1 | 2;
}
export class AccouplementView {
private el: HTMLElement | null = null;
private accoupState: AccoupState = {
parent1: null, parent2: null,
filterGen: null, search: '',
couples: '', babies: '',
selectingSlot: 1,
};
private dirty = true;
private dragScrollRAF: number | null = null;
private dragMouseY = 0;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
) {}
render(container: HTMLElement): void {
this.el = document.createElement('div');
this.el.className = 'accoup-view';
container.appendChild(this.el);
this.dirty = true;
this.update();
}
update(): void {
if (!this.el || !this.dirty) return;
this.dirty = false;
this.renderSinglePage();
}
destroy(): void {
this.el?.remove();
this.el = null;
}
/* ── All races list ── */
private getAllRaces(): { name: string; gen: number }[] {
const all: { name: string; gen: number }[] = [];
for (const base of ['Rousse', 'Amande', 'Dorée'])
all.push({ name: base, gen: 1 });
for (const [g, rs] of Object.entries(RACES_DATA))
for (const r of rs)
all.push({ name: r.name, gen: parseInt(g) });
return all;
}
/* ── Filtered races (gen filter, search, partner compat) ── */
private getFilteredRaces(): { name: string; gen: number }[] {
const { filterGen, search, parent1, selectingSlot } = this.accoupState;
let races = this.getAllRaces();
// If selecting parent 2, only show compatible partners
if (selectingSlot === 2 && parent1) {
const partners = COMPATIBLE_PARTNERS[parent1] ?? [];
const partnerNames = new Set(partners.map(p => p.partner));
races = races.filter(r => partnerNames.has(r.name));
}
const q = search.trim().toLowerCase();
return races.filter(r =>
(filterGen ? r.gen === filterGen : true) &&
(q ? r.name.toLowerCase().includes(q) : true)
);
}
/* ── Baby name from parents ── */
private getBabyName(): string | null {
const { parent1, parent2 } = this.accoupState;
if (!parent1 || !parent2) return null;
return BREEDING_BY_PARENTS[parent1 + '|' + parent2] ?? null;
}
/* ── Auto-scroll during drag ── */
private getScrollContainer(): HTMLElement | null {
return this.el?.closest('.main-content') as HTMLElement | null;
}
private startDragAutoScroll(): void {
const container = this.getScrollContainer();
if (!container) return;
const onDragOver = (e: DragEvent) => { this.dragMouseY = e.clientY; };
container.addEventListener('dragover', onDragOver);
const EDGE = 80; // zone de déclenchement (px depuis le bord)
const SPEED = 28; // vitesse de scroll (px par frame)
const tick = () => {
const rect = container.getBoundingClientRect();
const y = this.dragMouseY;
if (y > 0 && y < rect.top + EDGE) {
// Proche du haut → scroll vers le haut
container.scrollTop -= SPEED;
} else if (y > rect.bottom - EDGE && y < rect.bottom) {
// Proche du bas → scroll vers le bas
container.scrollTop += SPEED;
}
this.dragScrollRAF = requestAnimationFrame(tick);
};
this.dragScrollRAF = requestAnimationFrame(tick);
// Store cleanup ref
(this as any)._dragOverCleanup = () => container.removeEventListener('dragover', onDragOver);
}
private stopDragAutoScroll(): void {
if (this.dragScrollRAF !== null) {
cancelAnimationFrame(this.dragScrollRAF);
this.dragScrollRAF = null;
}
if ((this as any)._dragOverCleanup) {
(this as any)._dragOverCleanup();
delete (this as any)._dragOverCleanup;
}
}
/* ── Assign a race to a parent slot ── */
private assignRaceToSlot(race: string, forceSlot?: 1 | 2): void {
const slot = forceSlot ?? (this.accoupState.parent1 ? 2 : 1);
if (slot === 1) {
this.accoupState.parent1 = race;
this.accoupState.parent2 = null; // Reset P2 since partners depend on P1
this.accoupState.selectingSlot = 2;
this.accoupState.filterGen = null;
this.accoupState.search = '';
} else {
// Check partner compatibility if P1 is set
if (this.accoupState.parent1) {
const partners = COMPATIBLE_PARTNERS[this.accoupState.parent1] ?? [];
const isCompat = partners.some(p => p.partner === race);
if (!isCompat) return; // Ignore incompatible drop
}
this.accoupState.parent2 = race;
}
this.dirty = true;
this.update();
}
/* ── Single page render ── */
private renderSinglePage(): void {
if (!this.el) return;
const { parent1, parent2, filterGen, search, couples, babies } = this.accoupState;
const baby = this.getBabyName();
const hasBoth = !!(parent1 && parent2 && baby);
let html = '';
/* ── Parent panels row ── */
html += `<div class="accoup-parents">`;
// Parent 1 section
html += `<section class="accoup-parent-section">`;
html += `<div class="accoup-parent-header">
<span class="accoup-parent-title">Parent 1</span>
</div>`;
if (parent1) {
const gen1 = RACE_GEN[parent1] ?? 1;
html += `<div class="accoup-selected-parent accoup-drop-zone" data-drop-slot="1">
${getDDImage(parent1)}
<span class="accoup-selected-parent-name">${esc(parent1)}</span>
<span class="accoup-selected-parent-badge" style="background:${GEN_COLORS[gen1] ?? '#888'}">Gen ${gen1}</span>
<button class="accoup-selected-parent-clear" data-clear="1" title="Retirer">
<span class="material-symbols-outlined" style="font-size:14px">close</span>
</button>
</div>`;
} else {
html += `<div class="accoup-placeholder accoup-drop-zone" data-select-slot="1" data-drop-slot="1">
<div class="accoup-placeholder-inner">
<div class="accoup-placeholder-icon">
<span class="material-symbols-outlined" style="color:var(--md-primary)">add</span>
</div>
<p class="accoup-placeholder-text">Cliquer ou glisser un mâle ici</p>
</div>
</div>`;
}
html += `</section>`;
// Center column
html += `<div class="accoup-center">`;
html += `<div class="accoup-heart">
<span class="material-symbols-outlined mso-fill">favorite</span>
</div>`;
html += `<div class="accoup-center-inputs">
<div class="accoup-center-field">
<label class="accoup-center-label">Nombre de couples</label>
<input class="accoup-center-input" id="accoup-couples" type="number" min="1" value="${esc(couples)}" placeholder="1">
</div>
<div class="accoup-center-field">
<label class="accoup-center-label">Bébés obtenus</label>
<input class="accoup-center-input secondary" id="accoup-babies" type="number" min="0" value="${esc(babies)}" placeholder="0">
</div>
</div>`;
// Baby preview (if both parents selected)
if (hasBoth && baby) {
const babyGen = RACE_GEN[baby] ?? 0;
html += `<div style="text-align:center;margin-top:4px">
<div style="font-size:10px;color:var(--md-on-surface-variant);text-transform:uppercase;letter-spacing:0.08em;margin-bottom:6px">Résultat</div>
${getDDImage(baby)}
<div style="font-size:12px;font-weight:700;color:var(--md-on-surface);margin-top:4px">${esc(baby)}</div>
<span class="accoup-selected-parent-badge" style="background:${GEN_COLORS[babyGen] ?? '#888'};font-size:8px">Gen ${babyGen}</span>
</div>`;
}
html += `<button class="accoup-register-btn" id="accoup-register" ${hasBoth ? '' : 'disabled'}>ENREGISTRER</button>`;
html += `</div>`;
// Parent 2 section
html += `<section class="accoup-parent-section">`;
html += `<div class="accoup-parent-header">
<span class="accoup-parent-title">Parent 2</span>
</div>`;
if (parent2) {
const gen2 = RACE_GEN[parent2] ?? 1;
html += `<div class="accoup-selected-parent accoup-drop-zone" data-drop-slot="2">
${getDDImage(parent2)}
<span class="accoup-selected-parent-name">${esc(parent2)}</span>
<span class="accoup-selected-parent-badge" style="background:${GEN_COLORS[gen2] ?? '#888'}">Gen ${gen2}</span>
<button class="accoup-selected-parent-clear" data-clear="2" title="Retirer">
<span class="material-symbols-outlined" style="font-size:14px">close</span>
</button>
</div>`;
} else {
html += `<div class="accoup-placeholder accoup-drop-zone" data-select-slot="2" data-drop-slot="2">
<div class="accoup-placeholder-inner">
<div class="accoup-placeholder-icon">
<span class="material-symbols-outlined" style="color:var(--md-secondary)">add</span>
</div>
<p class="accoup-placeholder-text">Cliquer ou glisser une femelle ici</p>
</div>
</div>`;
}
html += `</section>`;
html += `</div>`; // .accoup-parents
/* ── Grid panel ── */
html += `<div class="accoup-grid-panel">`;
// Gen chips
html += `<div class="accoup-gen-chips">`;
html += `<span class="accoup-gen-chips-label">Générations</span>`;
html += `<button class="accoup-gen-chip${filterGen === null ? ' active' : ''}" data-gen="all">Toutes</button>`;
for (let g = 1; g <= 10; g++) {
html += `<button class="accoup-gen-chip${filterGen === g ? ' active' : ''}" data-gen="${g}">Gen ${g}</button>`;
}
html += `</div>`;
// Search
html += `<div class="accoup-search-wrap">
<input class="accoup-search" id="accoup-search-input" type="text"
placeholder="Rechercher une race…" value="${esc(search)}" autocomplete="off">
${search ? `<button class="accoup-search-clear" title="Effacer"><span class="material-symbols-outlined" style="font-size:16px">close</span></button>` : ''}
</div>`;
// Race grid
const filtered = this.getFilteredRaces();
if (filtered.length === 0) {
html += `<div class="accoup-empty">Aucune race trouvée</div>`;
} else {
html += `<div class="accoup-race-grid">`;
for (const race of filtered) {
const genCol = GEN_COLORS[race.gen] ?? '#888';
html += `<div class="accoup-race-card" data-race="${esc(race.name)}" draggable="true">
<div class="accoup-race-card-img">
${getDDImage(race.name)}
<div class="accoup-race-card-gen" style="color:${genCol}">GEN ${race.gen}</div>
</div>
<div class="accoup-race-card-name">${esc(race.name)}</div>
</div>`;
}
html += `</div>`;
}
html += `</div>`; // .accoup-grid-panel
this.el.innerHTML = html;
this.bindEvents();
// Restore search focus if active
if (search) {
const inp = this.el.querySelector<HTMLInputElement>('#accoup-search-input');
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
}
}
/* ── Event binding ── */
private bindEvents(): void {
if (!this.el) return;
// Gen chips
this.el.querySelectorAll('.accoup-gen-chip').forEach(btn => {
btn.addEventListener('click', () => {
const val = (btn as HTMLElement).dataset.gen;
this.accoupState.filterGen = val === 'all' ? null : parseInt(val!);
this.dirty = true; this.update();
});
});
// Search
const searchInput = this.el.querySelector<HTMLInputElement>('#accoup-search-input');
if (searchInput) {
searchInput.addEventListener('input', () => {
this.accoupState.search = searchInput.value;
this.dirty = true; this.update();
});
}
const clearBtn = this.el.querySelector('.accoup-search-clear');
if (clearBtn) {
clearBtn.addEventListener('click', () => {
this.accoupState.search = '';
this.dirty = true; this.update();
});
}
// Placeholder clicks (select slot)
this.el.querySelectorAll<HTMLElement>('[data-select-slot]').forEach(ph => {
ph.addEventListener('click', () => {
this.accoupState.selectingSlot = parseInt(ph.dataset.selectSlot!) as 1 | 2;
});
});
// Clear parent buttons
this.el.querySelectorAll<HTMLElement>('.accoup-selected-parent-clear').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const slot = parseInt(btn.dataset.clear!);
if (slot === 1) {
this.accoupState.parent1 = null;
this.accoupState.parent2 = null;
this.accoupState.selectingSlot = 1;
} else {
this.accoupState.parent2 = null;
this.accoupState.selectingSlot = 2;
}
this.dirty = true; this.update();
});
});
// Race card clicks
this.el.querySelectorAll<HTMLElement>('.accoup-race-card').forEach(card => {
card.addEventListener('click', () => {
this.assignRaceToSlot(card.dataset.race!);
});
// Drag start
card.addEventListener('dragstart', (e) => {
e.dataTransfer!.setData('text/plain', card.dataset.race!);
e.dataTransfer!.effectAllowed = 'copy';
card.classList.add('dragging');
this.startDragAutoScroll();
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
this.stopDragAutoScroll();
});
});
// Drop zones
this.el.querySelectorAll<HTMLElement>('.accoup-drop-zone').forEach(zone => {
zone.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer!.dropEffect = 'copy';
});
zone.addEventListener('dragenter', (e) => {
e.preventDefault();
zone.classList.add('drag-over');
});
zone.addEventListener('dragleave', () => {
zone.classList.remove('drag-over');
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('drag-over');
const race = e.dataTransfer!.getData('text/plain');
if (!race) return;
const slot = parseInt(zone.dataset.dropSlot!) as 1 | 2;
this.assignRaceToSlot(race, slot);
});
});
// Couples / babies inputs
const couplesInput = this.el.querySelector<HTMLInputElement>('#accoup-couples');
const babiesInput = this.el.querySelector<HTMLInputElement>('#accoup-babies');
if (couplesInput) {
let prev = couplesInput.value;
couplesInput.addEventListener('focus', () => { prev = couplesInput.value; couplesInput.value = ''; });
couplesInput.addEventListener('blur', () => {
if (couplesInput.value === '') couplesInput.value = prev;
this.accoupState.couples = couplesInput.value;
});
}
if (babiesInput) {
let prev = babiesInput.value;
babiesInput.addEventListener('focus', () => { prev = babiesInput.value; babiesInput.value = ''; });
babiesInput.addEventListener('blur', () => {
if (babiesInput.value === '') babiesInput.value = prev;
this.accoupState.babies = babiesInput.value;
});
}
// Register button
const registerBtn = this.el.querySelector('#accoup-register');
if (registerBtn) {
registerBtn.addEventListener('click', () => {
const { parent1, parent2 } = this.accoupState;
if (!parent1 || !parent2) return;
const baby = BREEDING_BY_PARENTS[parent1 + '|' + parent2] ?? '';
if (!baby) return;
const c = parseInt(this.accoupState.couples) || 0;
const b = parseInt(this.accoupState.babies) || 0;
if (c <= 0) return;
this.commandBus.execute({
type: 'register-accouplement',
parent1,
parent2,
baby,
gen: RACE_GEN[baby] ?? 0,
couples: c,
babiesObtained: b,
});
Toast.show('success', 'Accouplement enregistré.');
// Reset
this.accoupState = {
parent1: null, parent2: null,
filterGen: null, search: '',
couples: '', babies: '',
selectingSlot: 1,
};
this.dirty = true; this.update();
});
}
}
}

View File

@ -0,0 +1,365 @@
import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import type { UIState } from '@presentation/state/UIState';
import type { DashboardResult } from '@application/queries/GetDashboard';
import { Sidebar } from './Sidebar';
import { Dashboard } from './Dashboard';
import { EnclosView } from './EnclosView';
import { AccouplementView } from './AccouplementView';
import { ReapproView } from './ReapproView';
import { InventaireView } from './InventaireView';
import { ParametresView } from './ParametresView';
import { WorkflowsView } from './WorkflowsView';
import { StatistiquesView } from './StatistiquesView';
import { UpdateBanner } from './UpdateBanner';
import { Toast } from './Toast';
import { ConfirmModal } from './ConfirmModal';
import { UndoManager } from '@presentation/services/UndoManager';
import { esc } from '@presentation/helpers/format';
import { enclosGlobalState } from '@presentation/helpers/gauge-live';
import { MAX_ENCLOS } from '@domain/entities/Enclos';
import type { Enclos } from '@domain/entities/Enclos';
type ChildComponent = { update(): void; destroy(): void };
export class App {
private root: HTMLElement;
private sidebar: Sidebar;
private updateBanner: UpdateBanner;
private activeChild: ChildComponent | null = null;
private unsubscribe: (() => void) | null = null;
private rafId: number | null = null;
private completionIntervalId: number | null = null;
private lastView: string | number | null = null;
// Tab drag-and-drop state
private dragSrcIdx: number | null = null;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
private uiState: UIState,
rootElement: HTMLElement,
private playSound?: (name: string) => void,
) {
this.root = rootElement;
this.sidebar = new Sidebar(uiState, queryBus);
this.updateBanner = new UpdateBanner();
}
render(): void {
this.root.innerHTML = `
<div class="app-shell">
<div id="sb-container"></div>
<div class="main-area">
<header class="app-header">
<button class="app-hamburger" id="hamburger-btn">&#9776;</button>
<div class="app-header-text">
<h1 class="app-title"><span class="app-title-icon">&#x2694;</span> Minuteur Dragodinde</h1>
<p class="app-subtitle">Dofus 3 &middot; Gestion multi-enclos en temps r&eacute;el</p>
</div>
<div class="app-hamburger" style="visibility:hidden;pointer-events:none;" aria-hidden="true"></div>
</header>
<div id="update-banner-root"></div>
<div class="tabs-row" id="tabs-row"></div>
<div id="enclos-content" class="main-content custom-scrollbar"></div>
</div>
</div>
`;
// Mount sidebar
const sbContainer = this.root.querySelector('#sb-container') as HTMLElement;
this.sidebar.render(sbContainer);
// Mount update banner
const bannerRoot = this.root.querySelector('#update-banner-root') as HTMLElement;
this.updateBanner.render(bannerRoot);
// Mount toast container
const appShell = this.root.querySelector('.app-shell') as HTMLElement;
Toast.mount(appShell);
// Ctrl+Z → undo
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && UndoManager.canUndo()) {
e.preventDefault();
UndoManager.undo();
}
});
// Hamburger toggle
const hamburgerBtn = this.root.querySelector('#hamburger-btn') as HTMLElement;
hamburgerBtn.addEventListener('click', () => this.uiState.toggleSidebar());
// Subscribe to UI state changes
this.unsubscribe = this.uiState.subscribe(() => this.onStateChange());
// Initial renders
this.renderTabs();
this.renderContent();
this.updateSidebarState();
// Start animation loop
this.startAnimationLoop();
// Interval indépendant du focus fenêtre pour la détection de fin de session
this.completionIntervalId = window.setInterval(() => {
this.checkAllEnclosCompletion();
}, 1000);
}
private onStateChange(): void {
this.renderTabs();
this.renderContent();
this.sidebar.update();
this.updateSidebarState();
}
private updateSidebarState(): void {
const sidebarEl = this.root.querySelector('.sidebar-new') as HTMLElement | null;
if (sidebarEl) {
sidebarEl.classList.toggle('sidebar-closed', !this.uiState.sidebarOpen);
}
}
private getDashboardData(): DashboardResult {
return this.queryBus.execute<DashboardResult>({ type: 'get-dashboard' });
}
// ── Tabs ──────────────────────────────────────────────────────
private renderTabs(): void {
const tabsRow = this.root.querySelector('#tabs-row') as HTMLElement | null;
if (!tabsRow) return;
const data = this.getDashboardData();
const enclosList = data.enclosSummaries;
const activeView = this.uiState.activeView;
let html = '';
// Dashboard tab
const dashActive = activeView === 'dashboard' ? ' active' : '';
html += `<div class="tab${dashActive}" data-view="dashboard"><span><span class="sb-item-icon material-symbols-outlined" style="font-size:15px;vertical-align:middle;margin-right:4px;">dashboard</span>Dashboard</span></div>`;
// Statistiques tab
const statsActive = activeView === 'statistiques' ? ' active' : '';
html += `<div class="tab${statsActive}" data-view="statistiques"><span><span class="sb-item-icon material-symbols-outlined" style="font-size:15px;vertical-align:middle;margin-right:4px;">bar_chart</span>Statistiques</span></div>`;
// Enclos tabs
enclosList.forEach((enc, idx) => {
const isActive = activeView === enc.id ? ' active' : '';
const isRunning = enc.running ? ' running' : '';
const canDelete = enclosList.length > 1;
html += `<div class="tab${isActive}${isRunning}" draggable="true" data-idx="${idx}" data-view="${enc.id}" id="tab-enc-${enc.id}">`;
html += `<span class="tab-dot"></span>`;
html += `<span>${esc(enc.name)}</span>`;
if (canDelete) {
html += `<button class="tab-del" data-delete-id="${enc.id}" title="Supprimer">&#x2715;</button>`;
}
html += `</div>`;
});
// Add enclos button
const disabled = enclosList.length >= MAX_ENCLOS ? ' disabled' : '';
html += `<button class="add-tab" id="add-enclos-btn"${disabled}>+ Enclos</button>`;
tabsRow.innerHTML = html;
// Tab click events
tabsRow.querySelectorAll('.tab[data-view]').forEach(tab => {
tab.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('tab-del')) return;
const view = (tab as HTMLElement).dataset['view']!;
const viewValue = /^\d+$/.test(view) ? Number(view) : view;
this.uiState.setActiveView(viewValue);
});
});
// Delete events
tabsRow.querySelectorAll('.tab-del').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = Number((btn as HTMLElement).dataset['deleteId']);
const ok = await ConfirmModal.show('Supprimer l\'enclos', 'Cette action est irréversible. Continuer ?');
if (!ok) return;
const hasSnap = await UndoManager.snapshotCurrent('Suppression enclos');
this.commandBus.execute({ type: 'delete-enclos', enclosId: id });
Toast.show('success', 'Enclos supprimé.', hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined);
if (this.uiState.activeView === id) {
const newData = this.getDashboardData();
const firstEnclos = newData.enclosSummaries[0];
this.uiState.setActiveView(firstEnclos ? firstEnclos.id : 'dashboard');
} else {
this.uiState.notify();
}
});
});
// Add enclos button
const addBtn = tabsRow.querySelector('#add-enclos-btn') as HTMLElement | null;
if (addBtn) {
addBtn.addEventListener('click', () => {
this.commandBus.execute({ type: 'create-enclos' });
const newData = this.getDashboardData();
const last = newData.enclosSummaries[newData.enclosSummaries.length - 1];
if (last) this.uiState.setActiveView(last.id);
});
}
// Drag and drop
this.setupTabDragAndDrop(tabsRow);
}
private setupTabDragAndDrop(tabsRow: HTMLElement): void {
const tabs = tabsRow.querySelectorAll('.tab[draggable=true]') as NodeListOf<HTMLElement>;
tabs.forEach(tab => {
tab.addEventListener('dragstart', (e) => {
this.dragSrcIdx = Number(tab.dataset['idx']);
(e as DragEvent).dataTransfer!.effectAllowed = 'move';
tab.classList.add('dragging');
});
tab.addEventListener('dragover', (e) => {
e.preventDefault();
(e as DragEvent).dataTransfer!.dropEffect = 'move';
tab.classList.add('drag-over');
});
tab.addEventListener('dragleave', () => {
tab.classList.remove('drag-over');
});
tab.addEventListener('dragend', () => {
tab.classList.remove('dragging');
tabs.forEach(t => t.classList.remove('drag-over'));
});
tab.addEventListener('drop', (e) => {
e.preventDefault();
tab.classList.remove('drag-over');
const destIdx = Number(tab.dataset['idx']);
if (this.dragSrcIdx !== null && this.dragSrcIdx !== destIdx) {
this.commandBus.execute({
type: 'reorder-enclos',
fromIndex: this.dragSrcIdx,
toIndex: destIdx,
});
this.uiState.notify();
}
this.dragSrcIdx = null;
});
});
}
// ── Content routing ───────────────────────────────────────────
private renderContent(): void {
const view = this.uiState.activeView;
if (view === this.lastView && this.activeChild) {
this.activeChild.update();
return;
}
if (this.activeChild) {
this.activeChild.destroy();
this.activeChild = null;
}
const container = this.root.querySelector('#enclos-content') as HTMLElement | null;
if (!container) return;
container.innerHTML = '';
this.lastView = view;
if (view === 'dashboard') {
const child = new Dashboard(this.commandBus, this.queryBus, this.uiState);
child.render(container);
this.activeChild = child;
} else if (view === 'accouplement') {
const child = new AccouplementView(this.commandBus, this.queryBus);
child.render(container);
this.activeChild = child;
} else if (view === 'appro') {
const child = new ReapproView(this.commandBus, this.queryBus);
child.render(container);
this.activeChild = child;
} else if (view === 'inventaire') {
const child = new InventaireView(this.commandBus, this.queryBus);
child.render(container);
this.activeChild = child;
} else if (view === 'workflows') {
const child = new WorkflowsView(this.commandBus, this.queryBus, this.uiState);
child.render(container);
this.activeChild = child;
} else if (view === 'statistiques') {
const child = new StatistiquesView(this.commandBus, this.queryBus);
child.render(container);
this.activeChild = child;
} else if (view === 'parametres') {
const child = new ParametresView(this.commandBus, this.queryBus, this.playSound);
child.render(container);
this.activeChild = child;
} else if (typeof view === 'number') {
const child = new EnclosView(this.commandBus, this.queryBus, this.uiState);
child.render(container, view);
this.activeChild = child;
}
}
// ── Live update loop ──────────────────────────────────────────
private startAnimationLoop(): void {
const loop = () => {
this.updateTabDots();
if (this.activeChild) this.activeChild.update();
this.rafId = requestAnimationFrame(loop);
};
this.rafId = requestAnimationFrame(loop);
}
private updateTabDots(): void {
const data = this.getDashboardData();
data.enclosSummaries.forEach(enc => {
const tab = this.root.querySelector(`#tab-enc-${enc.id}`) as HTMLElement | null;
if (!tab) return;
tab.classList.toggle('running', enc.running);
});
}
/** Appelle complete-timer sur tout enclos dont toutes les cibles sont atteintes. */
private checkAllEnclosCompletion(): void {
const data = this.getDashboardData();
data.enclosSummaries.forEach(summary => {
if (!summary.running) return;
const enc = this.queryBus.execute<Enclos>({ type: 'get-enclos-detail', enclosId: summary.id });
if (!enc.dragodindes.length || !enc.activeGauges.length) return;
const { allDone } = enclosGlobalState(enc);
if (allDone) {
this.commandBus.execute({ type: 'complete-timer', enclosId: summary.id });
}
});
}
destroy(): void {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.completionIntervalId !== null) {
clearInterval(this.completionIntervalId);
this.completionIntervalId = null;
}
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
if (this.activeChild) {
this.activeChild.destroy();
this.activeChild = null;
}
this.sidebar.destroy();
this.updateBanner.destroy();
this.root.innerHTML = '';
}
}

View File

@ -0,0 +1,79 @@
/**
* Modale de confirmation glassmorphism.
*
* Remplace les confirm() / electronAPI.showConfirm() natifs
* par une modale cohérente avec le design Obsidienne.
*/
let overlay: HTMLElement | null = null;
function ensureOverlay(): HTMLElement {
if (overlay && overlay.parentNode) return overlay;
overlay = document.createElement('div');
overlay.className = 'confirm-overlay confirm-hidden';
document.body.appendChild(overlay);
return overlay;
}
export const ConfirmModal = {
/**
* Affiche une modale de confirmation.
* @returns true si l'utilisateur confirme, false sinon.
*/
show(title: string, message: string): Promise<boolean> {
return new Promise(resolve => {
const ov = ensureOverlay();
const box = document.createElement('div');
box.className = 'confirm-box';
const iconEl = document.createElement('span');
iconEl.className = 'confirm-icon material-symbols-outlined';
iconEl.textContent = 'warning';
const titleEl = document.createElement('h3');
titleEl.className = 'confirm-title';
titleEl.textContent = title;
const msgEl = document.createElement('p');
msgEl.className = 'confirm-msg';
msgEl.textContent = message;
const footer = document.createElement('div');
footer.className = 'confirm-footer';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'confirm-btn confirm-btn-cancel';
cancelBtn.textContent = 'Annuler';
const okBtn = document.createElement('button');
okBtn.className = 'confirm-btn confirm-btn-ok';
okBtn.textContent = 'Confirmer';
footer.appendChild(cancelBtn);
footer.appendChild(okBtn);
box.appendChild(iconEl);
box.appendChild(titleEl);
box.appendChild(msgEl);
box.appendChild(footer);
ov.innerHTML = '';
ov.appendChild(box);
ov.classList.remove('confirm-hidden');
const close = (result: boolean) => {
ov.classList.add('confirm-hidden');
resolve(result);
};
cancelBtn.addEventListener('click', () => close(false), { once: true });
okBtn.addEventListener('click', () => close(true), { once: true });
ov.addEventListener('click', (e) => {
if (e.target === ov) close(false);
}, { once: true });
// Focus sur le bouton Annuler pour éviter les confirmations accidentelles
requestAnimationFrame(() => cancelBtn.focus());
});
},
};

View File

@ -0,0 +1,254 @@
import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import type { UIState } from '@presentation/state/UIState';
import type { DashboardResult } from '@application/queries/GetDashboard';
import type { Enclos } from '@domain/entities/Enclos';
import { MAX_DD } from '@domain/entities/Enclos';
import { GAUGE_DEFS } from '@domain/value-objects/GaugeType';
import { raceColor } from '@domain/value-objects/Race';
import { enclosGlobalState, enclosGaugeCurGl } from '@presentation/helpers/gauge-live';
import { esc, fmtClock } from '@presentation/helpers/format';
import { Toast } from './Toast';
import { ConfirmModal } from './ConfirmModal';
import { UndoManager } from '@presentation/services/UndoManager';
export class Dashboard {
private el: HTMLElement | null = null;
private lastRenderTime = 0;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
private uiState: UIState,
) {}
render(container: HTMLElement): void {
this.el = document.createElement('div');
this.el.className = 'dash-new';
container.appendChild(this.el);
this.renderAll();
}
private getData(): DashboardResult {
return this.queryBus.execute<DashboardResult>({ type: 'get-dashboard' });
}
private renderAll(): void {
if (!this.el) return;
const data = this.getData();
// ── KPI Section ──────────────────────────────────────────────
const activeDD = data.enclosSummaries.reduce((s, e) => s + e.ddCount, 0);
const racesObtained = Object.keys(data.raceBreakdown).length;
const kpis = [
{ label: 'Total Bébés', value: String(data.totalBabies) },
{ label: 'Dragodindes Actives', value: String(activeDD) },
{ label: 'Couples Formés', value: String(data.totalCouples) },
{ label: 'Taux de Réussite', value: `${data.successRate}%` },
{ label: 'Races Obtenues', value: String(racesObtained) },
];
let kpiHtml = `
<section>
<div class="dash-section-hd">
<span class="dash-section-lbl">Statistiques Globales</span>
<button class="dash-reset-btn-new" id="dash-reset-btn">
<span class="material-symbols-outlined">restart_alt</span>
Réinitialiser
</button>
</div>
<div class="dash-kpi-grid-new">
`;
for (const kpi of kpis) {
kpiHtml += `
<div class="dash-kpi-card">
<p class="dash-kpi-lbl">${esc(kpi.label)}</p>
<span class="dash-kpi-val">${esc(kpi.value)}</span>
</div>
`;
}
kpiHtml += `</div></section>`;
// ── Two-column section ───────────────────────────────────────
let enclosHtml = `
<section>
<div class="dash-section-hd">
<span class="dash-section-lbl">Aperçu Tous les enclos</span>
</div>
<div class="dash-enc-grid">
`;
for (const summary of data.enclosSummaries) {
const enc = this.queryBus.execute<Enclos | null>({ type: 'get-enclos-detail', enclosId: summary.id });
const gs = enc ? enclosGlobalState(enc) : null;
const started = !!enc?.timer.startTime;
const running = !!enc?.timer.running;
const allDone = !!gs?.allDone && started;
// Status
let statusClass = 'idle';
let statusLabel = 'Inactif';
if (running) { statusClass = 'running'; statusLabel = 'Actif'; }
else if (started) { statusClass = 'paused'; statusLabel = 'Pause'; }
const cardClass = `dash-enc-card${running ? ' running' : ''}${allDone ? ' done-enc' : ''}`;
// Gauge tags — détecte les jauges vides en cours de session
const gaugeTags = summary.activeGauges.map(gid => {
const def = GAUGE_DEFS[gid as keyof typeof GAUGE_DEFS];
if (!def) return '';
const cssVar = `var(${def.cssVar})`;
const curGl = (enc && started) ? enclosGaugeCurGl(enc, gid as any) : (enc?.gaugeLevels[gid as keyof typeof enc.gaugeLevels] ?? 0);
const isEmpty = curGl <= 0 && started;
if (isEmpty) {
return `<span class="dash-enc-gauge-tag dash-gauge-empty"
style="background:rgba(234,179,8,0.12);border-color:rgba(234,179,8,0.3);color:#eab308">
${esc(def.label)}
</span>`;
}
return `<span class="dash-enc-gauge-tag"
style="background:color-mix(in srgb, ${cssVar} 10%, transparent);border-color:color-mix(in srgb, ${cssVar} 30%, transparent);color:${cssVar}">
${def.icon} ${esc(def.label)}
</span>`;
}).join('');
const gaugesRow = summary.activeGauges.length > 0
? `<div class="dash-enc-gauges-row">${gaugeTags}</div>`
: `<div class="dash-enc-gauges-row"><span class="dash-enc-no-gauge">Aucune jauge active</span></div>`;
// Sous-label capacité max (uniquement quand l'enclos est plein)
const capaciteLabel = summary.ddCount >= MAX_DD ? 'Capacit\u00e9 max' : '';
const cdText = started && gs ? (gs.allDone ? '✅' : (!isFinite(gs.globalMax) ? '∞' : fmtClock(gs.globalMax))) : '--:--:--';
const elText = started && gs ? fmtClock(gs.el) : '--:--:--';
// Button style: active = primary button if running and approaching end
const btnClass = allDone ? 'dash-enc-btn btn-active' : 'dash-enc-btn';
enclosHtml += `
<div class="${cardClass}" id="dash-enc-${summary.id}" data-enc-id="${summary.id}">
<div class="dash-enc-header-row">
<span class="dash-enc-name-new">${esc(summary.name.toUpperCase())}</span>
<div class="dash-enc-status-badge ${statusClass}">
<span class="dash-enc-dot ${statusClass}"></span>
${esc(statusLabel)}
</div>
</div>
<div class="dash-enc-meta-row">
<div class="dash-enc-dd-block">
<div class="dd-count-big">${summary.ddCount} <span style="font-family:'Inter',sans-serif;font-size:14px;color:rgba(176,168,182,0.6);font-weight:500;margin-left:4px">DD</span></div>
${capaciteLabel ? `<div class="dd-count-sub">${capaciteLabel}</div>` : ''}
</div>
<div class="dash-enc-times">
<div class="dash-enc-time-row primary">
<span class="material-symbols-outlined">hourglass_top</span>
Restant : <span id="dash-cd-${summary.id}">${cdText}</span>
</div>
<div class="dash-enc-time-row muted">
<span class="material-symbols-outlined">schedule</span>
Écoulé : <span id="dash-el-${summary.id}">${elText}</span>
</div>
</div>
</div>
${gaugesRow}
<button class="${btnClass}" data-enc-id="${summary.id}">
Gérer cet enclos
<span class="material-symbols-outlined">arrow_forward</span>
</button>
</div>
`;
}
enclosHtml += `</div></section>`;
// ── Race progression panel (right col) ───────────────────────
const raceEntries = Object.entries(data.raceBreakdown).sort((a, b) => b[1] - a[1]);
const maxCount = raceEntries.length > 0 ? raceEntries[0][1] : 1;
let raceHtml = `
<div>
<div class="dash-section-hd">
<span class="dash-section-lbl">Progression des races</span>
</div>
<div class="dash-race-panel">
`;
if (raceEntries.length === 0) {
raceHtml += `<p class="dash-race-empty">Aucune race enregistrée</p>`;
} else {
for (const [race, count] of raceEntries) {
const pct = maxCount > 0 ? (count / maxCount) * 100 : 0;
const col = raceColor(race);
raceHtml += `
<div class="dash-race-row">
<div class="dash-race-row-hd">
<span style="color:${col}">${esc(race)}</span>
<span style="color:rgba(176,168,182,0.7)">${count}</span>
</div>
<div class="dash-race-bar-bg">
<div class="dash-race-bar-fill" style="width:${pct}%;background:${col}"></div>
</div>
</div>
`;
}
}
raceHtml += `</div></div>`;
// ── Assemble ──────────────────────────────────────────────────
this.el.innerHTML =
kpiHtml +
`<div class="dash-two-col">` +
`<div>${enclosHtml}</div>` +
raceHtml +
`</div>`;
this.bindEvents();
}
private bindEvents(): void {
if (!this.el) return;
// "Gérer" buttons — navigate with stopPropagation
this.el.querySelectorAll<HTMLElement>('button[data-enc-id]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const id = Number(btn.dataset['encId']);
if (id) this.uiState.setActiveView(id);
});
});
// Card click (excluding button area)
this.el.querySelectorAll<HTMLElement>('.dash-enc-card[id^="dash-enc-"]').forEach(card => {
card.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('button')) return;
const id = Number(card.id.replace('dash-enc-', ''));
if (id) this.uiState.setActiveView(id);
});
});
// Reset stats
this.el.querySelector('#dash-reset-btn')?.addEventListener('click', async () => {
const ok = await ConfirmModal.show('Réinitialiser les statistiques', 'Toutes les statistiques seront effacées. Continuer ?');
if (!ok) return;
const hasSnap = await UndoManager.snapshotCurrent('Reset statistiques');
this.commandBus.execute({ type: 'reset-stats' });
Toast.show('success', 'Statistiques réinitialisées.', hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined);
this.renderAll();
});
}
update(): void {
if (!this.el) return;
const now = Date.now();
if (now - this.lastRenderTime >= 1000) {
this.lastRenderTime = now;
this.renderAll();
}
}
destroy(): void {
this.el?.remove();
this.el = null;
}
}

View File

@ -0,0 +1,466 @@
import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import type { Enclos } from '@domain/entities/Enclos';
import type { Dragodinde } from '@domain/entities/Dragodinde';
import type { GaugeType, StatType } from '@domain/value-objects/GaugeType';
import { GAUGE_DEFS, STAT_DEFS } from '@domain/value-objects/GaugeType';
import { tierNum, tierRate } from '@domain/value-objects/Tier';
import { computeGaugeLive, calcSerenEtaLive, calcLevelEtaLive, calcLevel200EtaLive, elapsedLive } from '@presentation/helpers/gauge-live';
import { esc, fmt, fmtClock } from '@presentation/helpers/format';
import { Toast } from './Toast';
import { ConfirmModal } from './ConfirmModal';
import { UndoManager } from '@presentation/services/UndoManager';
interface StatPillDef {
key: StatType;
icon: string;
color: string;
min: number;
max: number;
}
const STAT_PILLS: StatPillDef[] = [
{ key: 'serenite', icon: 'sentiment_satisfied_alt', color: '96,165,250', min: -5000, max: 5000 },
{ key: 'endurance', icon: 'bolt', color: '250,204,21', min: 0, max: 20000 },
{ key: 'maturite', icon: 'water_drop', color: '34,211,238', min: 0, max: 20000 },
{ key: 'amour', icon: 'favorite', color: '248,113,113', min: 0, max: 20000 },
{ key: 'xp', icon: 'star', color: '254,240,138', min: 1, max: 200 },
];
export class DragodindeCard {
private el: HTMLElement | null = null;
private enclosId = 0;
private ddId = 0;
private lastTick = -1;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
private onReorder?: () => void,
) {}
render(container: HTMLElement, enclosId: number, ddId: number): void {
this.enclosId = enclosId;
this.ddId = ddId;
this.el = document.createElement('div');
this.el.className = 'dd-card enc-dd-card';
this.el.id = `ddc-${enclosId}-${ddId}`;
this.el.draggable = true;
this.el.addEventListener('dragstart', (e) => {
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer?.setData('text/dd-id', String(ddId));
e.dataTransfer?.setData('text/enc-id', String(enclosId));
// Délai pour que le navigateur capture le snapshot avant d'appliquer l'opacité
requestAnimationFrame(() => this.el!.classList.add('dragging'));
});
this.el.addEventListener('dragend', () => {
this.el!.classList.remove('dragging');
this.el!.classList.remove('drag-over');
});
this.el.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
this.el!.classList.add('drag-over');
});
this.el.addEventListener('dragleave', (e) => {
// Ignorer si on entre dans un élément enfant
if (this.el!.contains(e.relatedTarget as Node)) return;
this.el!.classList.remove('drag-over');
});
this.el.addEventListener('drop', (e) => {
e.preventDefault();
this.el!.classList.remove('drag-over');
const srcDdId = e.dataTransfer?.getData('text/dd-id');
const srcEncId = e.dataTransfer?.getData('text/enc-id');
if (srcEncId === String(enclosId) && srcDdId && srcDdId !== String(ddId)) {
this.commandBus.execute({
type: 'reorder-dragodinde',
enclosId,
fromDdId: Number(srcDdId),
toDdId: ddId,
});
this.onReorder?.();
}
});
container.appendChild(this.el);
this.renderInner();
}
private renderInner(): void {
if (!this.el) return;
const enc = this.queryBus.execute<Enclos>({ type: 'get-enclos-detail', enclosId: this.enclosId });
const dd = enc.dragodindes.find(d => d.id === this.ddId);
if (!dd) return;
const eId = this.enclosId;
const dId = this.ddId;
/* stat pills */
const pillsHtml = STAT_PILLS.map(sp => {
const val = dd.stats[sp.key];
const atMax = val >= sp.max;
const atMin = sp.key === 'serenite' && val <= sp.min;
const atLimit = atMax || atMin;
return `<div class="dd-stat-pill enc-dd-stat-badge${atLimit ? ' at-max' : ''}" style="border-color:rgba(${sp.color},0.5)${atLimit ? `;box-shadow:0 0 8px rgba(${sp.color},0.5);background:rgba(${sp.color},0.18)` : ''}">
<span class="material-symbols-outlined enc-dd-stat-icon" style="color:rgb(${sp.color})">${sp.icon}</span>
<input type="number" class="pill-input enc-dd-stat-input"
id="pstat-${eId}-${dId}-${sp.key}"
data-stat="${sp.key}" data-prev="${val}"
min="${sp.min}" max="${sp.max}"
value="${val}">
<span class="pill-delta" id="pill-delta-${eId}-${dId}-${sp.key}"></span>
</div>`;
}).join('');
/* Mapping icônes Material Symbols pour boutons jauges */
const GAUGE_MS_ICONS: Partial<Record<string, string>> = {
baffeur: 'remove', caresseur: 'add', foudroyeur: 'bolt',
abreuvoir: 'water_drop', dragofesse: 'favorite',
};
/* active gauge blocks */
const gaugeBlocksHtml = enc.activeGauges.map(gid => {
const def = GAUGE_DEFS[gid];
if (gid === 'mangeoire') {
return `<div class="enc-dd-gauge-block enc-dd-gauge-xp" data-gid="${gid}">
<div class="enc-dd-xp-main">
<div class="enc-dd-xp-left">
<span class="enc-dd-xp-niv" id="slv-${eId}-${dId}-${gid}" style="color:var(${def.cssVar})">NIV. 1</span>
<span class="enc-dd-xp-sub">XP <span id="eta200-pct-${eId}-${dId}">0%</span></span>
</div>
<span class="live-cd enc-dd-xp-cd" id="scd-${eId}-${dId}-${gid}">--:--:--</span>
</div>
<span id="eta200-${eId}-${dId}" class="enc-dd-xp-eta200"> NIV. 200 : </span>
<div class="enc-dd-bar-bg">
<div class="enc-dd-bar-fill" id="eta200-bar-${eId}-${dId}" style="width:0%;background:var(${def.cssVar})"></div>
</div>
<div class="enc-dd-bar-bg" style="margin-top:4px">
<div class="enc-dd-bar-fill" id="spb-${eId}-${dId}-${gid}" style="width:0%;background:var(${def.cssVar})"></div>
</div>
<span class="live-delta" id="sdelta-${eId}-${dId}-${gid}" style="display:none"></span>
</div>`;
}
const msIcon = GAUGE_MS_ICONS[gid] ?? 'circle';
return `<div class="enc-dd-gauge-block" data-gid="${gid}">
<div class="enc-dd-gauge-btn">
<span class="enc-dd-gauge-btn-left">
<span class="material-symbols-outlined" style="font-size:14px">${msIcon}</span>
<span class="enc-dd-gauge-btn-name">${def.label.toUpperCase()}</span>
</span>
<span class="live-val" id="slv-${eId}-${dId}-${gid}" style="display:none"></span>
<span class="live-delta" id="sdelta-${eId}-${dId}-${gid}" style="display:none"></span>
<span class="live-cd enc-dd-gauge-btn-cd" id="scd-${eId}-${dId}-${gid}">--:--:--</span>
</div>
<div class="enc-dd-bar-bg">
<div class="enc-dd-bar-fill" id="spb-${eId}-${dId}-${gid}" style="width:0%;background:var(${def.cssVar})"></div>
</div>
</div>`;
}).join('');
this.el.innerHTML = `
<div class="enc-dd-done-badge" id="dd-done-${eId}-${dId}" style="display:none"> TERMINÉ</div>
<div class="enc-dd-card-head">
<span class="dd-drag-handle enc-dd-drag-handle" title="Déplacer"></span>
<input type="text" class="dd-name enc-dd-name-input" value="${esc(dd.name)}"
data-prev="${esc(dd.name)}" id="ddname-${eId}-${dId}">
<button class="dd-del enc-dd-del-btn" title="Supprimer">
<span class="material-symbols-outlined" style="font-size:16px">close</span>
</button>
</div>
<div class="enc-dd-body">
<div class="enc-dd-stats-grid">${pillsHtml}</div>
<div class="enc-dd-cibles">
<div class="enc-dd-cible-row">
<div class="enc-dd-cible-left">
<span class="material-symbols-outlined" style="font-size:18px;color:rgb(96,165,250)">sentiment_satisfied_alt</span>
<span class="enc-dd-cible-lbl">Cible</span>
</div>
<div class="enc-dd-cible-right">
<input type="number" class="enc-dd-cible-inp" id="ser-tgt-${eId}-${dId}"
min="${enc.activeGauges.includes('baffeur') ? '-5000' : '0'}"
max="${enc.activeGauges.includes('caresseur') ? '5000' : '0'}"
value="${dd.sereniteTarget ?? ''}" placeholder="${enc.activeGauges.includes('baffeur') ? '-5000…0' : enc.activeGauges.includes('caresseur') ? '0…5000' : '—'}">
<button class="enc-dd-cible-clr" id="ser-clr-${eId}-${dId}" title="Réinitialiser" ${dd.sereniteTarget == null ? 'style="visibility:hidden"' : ''}></button>
<span class="enc-dd-cible-eta" id="ser-eta-${eId}-${dId}"></span>
</div>
</div>
<div class="enc-dd-cible-row">
<div class="enc-dd-cible-left">
<span class="material-symbols-outlined" style="font-size:18px;color:rgb(234,179,8)">stars</span>
<span class="enc-dd-cible-lbl">Niveau</span>
</div>
<div class="enc-dd-cible-right">
<input type="number" class="enc-dd-cible-inp" id="lvl-tgt-${eId}-${dId}"
min="1" max="200"
value="${dd.levelTarget ?? ''}" placeholder="—">
<button class="enc-dd-cible-clr" id="lvl-clr-${eId}-${dId}" title="Réinitialiser" ${dd.levelTarget == null ? 'style="visibility:hidden"' : ''}></button>
<span class="enc-dd-cible-eta" id="lvl-eta-${eId}-${dId}"></span>
</div>
</div>
</div>
<div class="enc-dd-gauge-blocks">${gaugeBlocksHtml}</div>
</div>
`;
this.bindEvents(dd);
}
private bindEvents(dd: Dragodinde): void {
if (!this.el) return;
const eId = this.enclosId;
const dId = this.ddId;
/* Delete button */
const delBtn = this.el.querySelector('.dd-del');
delBtn?.addEventListener('click', async () => {
const ok = await ConfirmModal.show('Retirer la dragodinde', 'Retirer cette dragodinde de l\'enclos ?');
if (!ok) return;
const hasSnap = await UndoManager.snapshotCurrent('Dragodinde retirée');
this.commandBus.execute({ type: 'remove-dragodinde', enclosId: eId, ddId: dId });
Toast.show('success', 'Dragodinde retirée.', hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined);
});
/* Name input */
const nameInput = this.el.querySelector<HTMLInputElement>('.dd-name');
if (nameInput) {
nameInput.addEventListener('focus', () => {
nameInput.dataset.prev = nameInput.value;
nameInput.value = '';
});
nameInput.addEventListener('blur', () => {
const v = nameInput.value.trim();
if (!v) nameInput.value = nameInput.dataset.prev || dd.name;
else this.commandBus.execute({ type: 'rename-dragodinde', enclosId: eId, ddId: dId, name: v });
});
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { nameInput.value = nameInput.dataset.prev || dd.name; nameInput.blur(); }
else if (e.key === 'Enter') nameInput.blur();
});
}
/* Stat pill inputs */
this.el.querySelectorAll<HTMLInputElement>('.pill-input').forEach(inp => {
const stat = inp.dataset.stat as StatType;
inp.addEventListener('focus', () => {
inp.dataset.prev = inp.value;
inp.value = '';
});
inp.addEventListener('input', () => {
if (!inp.value) return;
const v = Number(inp.value);
if (!isNaN(v)) this.commandBus.execute({ type: 'update-dd-stat', enclosId: eId, ddId: dId, stat, value: v });
});
inp.addEventListener('blur', () => {
if (inp.value === '') { inp.value = inp.dataset.prev || '0'; return; }
const v = Number(inp.value);
if (!isNaN(v)) this.commandBus.execute({ type: 'update-dd-stat', enclosId: eId, ddId: dId, stat, value: v });
});
inp.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { inp.value = inp.dataset.prev || '0'; inp.blur(); }
else if (e.key === 'Enter') inp.blur();
});
});
/* Clear buttons */
this.el.querySelector(`#ser-clr-${eId}-${dId}`)?.addEventListener('click', () => {
this.commandBus.execute({ type: 'update-dd-seren-target', enclosId: eId, ddId: dId, target: null });
const inp = this.el?.querySelector<HTMLInputElement>(`#ser-tgt-${eId}-${dId}`);
if (inp) inp.value = '';
const btn = this.el?.querySelector<HTMLElement>(`#ser-clr-${eId}-${dId}`);
if (btn) btn.style.visibility = 'hidden';
});
this.el.querySelector(`#lvl-clr-${eId}-${dId}`)?.addEventListener('click', () => {
this.commandBus.execute({ type: 'update-dd-level-target', enclosId: eId, ddId: dId, target: null });
const inp = this.el?.querySelector<HTMLInputElement>(`#lvl-tgt-${eId}-${dId}`);
if (inp) inp.value = '';
const btn = this.el?.querySelector<HTMLElement>(`#lvl-clr-${eId}-${dId}`);
if (btn) btn.style.visibility = 'hidden';
});
/* Serenite target — clamp selon la jauge active (baffeur → négatif, caresseur → positif) */
const serTgt = this.el.querySelector<HTMLInputElement>(`#ser-tgt-${eId}-${dId}`);
if (serTgt) {
const clampSeren = (raw: number): number => {
const enc = this.queryBus.execute<Enclos>({ type: 'get-enclos-detail', enclosId: eId });
if (enc.activeGauges.includes('baffeur')) return Math.min(0, Math.max(-5000, raw));
if (enc.activeGauges.includes('caresseur')) return Math.max(0, Math.min(5000, raw));
return Math.min(5000, Math.max(-5000, raw));
};
serTgt.addEventListener('focus', () => { serTgt.dataset.prev = serTgt.value; serTgt.value = ''; });
serTgt.addEventListener('input', () => {
if (!serTgt.value) return;
const v = Number(serTgt.value);
if (isNaN(v)) return;
const clamped = clampSeren(v);
this.commandBus.execute({ type: 'update-dd-seren-target', enclosId: eId, ddId: dId, target: clamped });
});
serTgt.addEventListener('blur', () => {
if (serTgt.value === '') { serTgt.value = serTgt.dataset.prev || ''; return; }
const v = Number(serTgt.value);
if (isNaN(v)) { serTgt.value = serTgt.dataset.prev || ''; return; }
const clamped = clampSeren(v);
this.commandBus.execute({ type: 'update-dd-seren-target', enclosId: eId, ddId: dId, target: clamped });
serTgt.value = String(clamped);
});
serTgt.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { serTgt.value = serTgt.dataset.prev || ''; serTgt.blur(); }
else if (e.key === 'Enter') serTgt.blur();
});
}
/* Level target */
const lvlTgt = this.el.querySelector<HTMLInputElement>(`#lvl-tgt-${eId}-${dId}`);
if (lvlTgt) {
lvlTgt.addEventListener('focus', () => { lvlTgt.dataset.prev = lvlTgt.value; lvlTgt.value = ''; });
lvlTgt.addEventListener('input', () => {
if (!lvlTgt.value) return;
const v = Number(lvlTgt.value);
this.commandBus.execute({ type: 'update-dd-level-target', enclosId: eId, ddId: dId, target: isNaN(v) ? null : v });
});
lvlTgt.addEventListener('blur', () => {
if (lvlTgt.value === '') { lvlTgt.value = lvlTgt.dataset.prev || ''; return; }
const v = Number(lvlTgt.value);
this.commandBus.execute({ type: 'update-dd-level-target', enclosId: eId, ddId: dId, target: isNaN(v) ? null : v });
});
lvlTgt.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { lvlTgt.value = lvlTgt.dataset.prev || ''; lvlTgt.blur(); }
else if (e.key === 'Enter') lvlTgt.blur();
});
}
}
update(enc: Enclos, dd: Dragodinde, el: number, started: boolean): void {
if (!this.el) return;
const eId = this.enclosId;
const dId = this.ddId;
/* Détection de tick (toutes les 10 sec).
* Après complétion automatique, le temps réel continue pour que les animations
* continuent sur toutes les jauges qui se vident en fond. */
const elForTick = started ? elapsedLive(enc) : 0;
const tick = started ? Math.floor(elForTick / 10) : -1;
if (!started) {
this.lastTick = -1;
} else if (this.lastTick === -1) {
this.lastTick = tick; // initialise sans déclencher au démarrage
}
const newTick = started && tick !== this.lastTick;
if (newTick) this.lastTick = tick;
let allDone = enc.activeGauges.length > 0;
/* Update active gauge blocks */
enc.activeGauges.forEach(gid => {
const r = computeGaugeLive(enc, dd, gid, el, started);
const def = GAUGE_DEFS[gid];
// Toutes les jauges comptent pour le badge "✓ TERMINÉ"
if (!r.done) allDone = false;
const lvEl = this.el!.querySelector(`#slv-${eId}-${dId}-${gid}`);
if (lvEl) lvEl.textContent = r.liveText;
/* Delta live-delta : pop à chaque tick.
* Continue jusqu'au cap absolu de la stat (pas juste la cible). */
const sd = def.isXp ? null : STAT_DEFS[def.stat as keyof typeof STAT_DEFS];
const atAbsCap = def.isXp
? (r.estStat as number) >= 200
: (def.dir > 0 ? (r.estStat as number) >= sd!.max : (r.estStat as number) <= sd!.min);
const deltaActive = started && !atAbsCap;
const deltaEl = this.el!.querySelector<HTMLElement>(`#sdelta-${eId}-${dId}-${gid}`);
if (deltaEl) {
deltaEl.textContent = r.deltaText;
if (newTick && deltaActive) {
deltaEl.classList.remove('show');
void deltaEl.offsetWidth; // force reflow pour relancer l'animation
deltaEl.classList.add('show');
} else if (!deltaActive) {
deltaEl.classList.remove('show');
}
}
const cdEl = this.el!.querySelector(`#scd-${eId}-${dId}-${gid}`);
if (cdEl) cdEl.textContent = r.done ? '✅' : (!isFinite(r.cntDown) ? '∞' : fmtClock(r.cntDown));
/* ETA + barre de progression niveau 200 (mangeoire uniquement) */
if (gid === 'mangeoire') {
const eta200El = this.el!.querySelector(`#eta200-${eId}-${dId}`);
if (eta200El) {
const eta = calcLevel200EtaLive(enc, dd, el, started);
eta200El.textContent = `→ NIV. 200 : ${eta || '—'}`;
}
const pct200 = Math.min(100, Math.max(0, ((r.estStat as number) - 1) / 199 * 100));
const barFillEl = this.el!.querySelector<HTMLElement>(`#eta200-bar-${eId}-${dId}`);
if (barFillEl) barFillEl.style.width = `${pct200.toFixed(1)}%`;
const pctEl = this.el!.querySelector<HTMLElement>(`#eta200-pct-${eId}-${dId}`);
if (pctEl) pctEl.textContent = `${Math.round(pct200)}%`;
}
const pbEl = this.el!.querySelector<HTMLElement>(`#spb-${eId}-${dId}-${gid}`);
if (pbEl) pbEl.style.width = `${r.progPct.toFixed(1)}%`;
/* Mise à jour live du badge de stat correspondant.
* Désactivée après complétion (__done__) : dd.stats est déjà à jour
* et l'utilisateur doit pouvoir corriger les valeurs à la main. */
if (started && !enc.alerted['__done__']) {
const pillInput = this.el!.querySelector<HTMLInputElement>(`#pstat-${eId}-${dId}-${def.stat}`);
if (pillInput && document.activeElement !== pillInput) {
pillInput.value = String(Math.round(r.estStat as number));
}
const sp = STAT_PILLS.find(p => p.key === def.stat);
if (sp) {
const val = Math.round(r.estStat as number);
const atLimit = val >= sp.max || (sp.key === 'serenite' && val <= sp.min);
const pill = pillInput?.closest<HTMLElement>('.dd-stat-pill');
if (pill) {
pill.classList.toggle('at-max', atLimit);
pill.style.background = atLimit ? `rgba(${sp.color},0.18)` : '';
pill.style.boxShadow = atLimit ? `0 0 8px rgba(${sp.color},0.5)` : '';
}
}
}
/* Pill delta : pop à chaque tick — uniquement endurance, maturite, amour */
const pillDeltaStats = ['endurance', 'maturite', 'amour'];
if (pillDeltaStats.includes(def.stat)) {
const pillDelta = this.el!.querySelector<HTMLElement>(`#pill-delta-${eId}-${dId}-${def.stat}`);
if (pillDelta) {
pillDelta.textContent = r.deltaText;
if (newTick && started && !r.done) {
pillDelta.classList.remove('show');
void pillDelta.offsetWidth;
pillDelta.classList.add('show');
} else if (!started || r.done) {
pillDelta.classList.remove('show');
}
}
}
});
/* Done badge */
const doneBadge = this.el.querySelector<HTMLElement>(`#dd-done-${eId}-${dId}`);
if (doneBadge) {
doneBadge.style.display = (allDone && started && enc.activeGauges.length > 0) ? '' : 'none';
}
/* Serenity ETA */
const serEta = this.el.querySelector(`#ser-eta-${eId}-${dId}`);
if (serEta) serEta.innerHTML = calcSerenEtaLive(enc, dd, el, started);
const serClr = this.el.querySelector<HTMLElement>(`#ser-clr-${eId}-${dId}`);
if (serClr) serClr.style.visibility = dd.sereniteTarget == null ? 'hidden' : 'visible';
/* Level ETA */
const lvlEta = this.el.querySelector(`#lvl-eta-${eId}-${dId}`);
if (lvlEta) lvlEta.innerHTML = calcLevelEtaLive(enc, dd, el, started);
const lvlClr = this.el.querySelector<HTMLElement>(`#lvl-clr-${eId}-${dId}`);
if (lvlClr) lvlClr.style.visibility = dd.levelTarget == null ? 'hidden' : 'visible';
}
destroy(): void {
this.el?.remove();
this.el = null;
}
}

View File

@ -0,0 +1,445 @@
import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import type { UIState } from '@presentation/state/UIState';
import type { Enclos } from '@domain/entities/Enclos';
import type { GaugeType } from '@domain/value-objects/GaugeType';
import { GAUGE_DEFS } from '@domain/value-objects/GaugeType';
import { tierNum, tierRate } from '@domain/value-objects/Tier';
import { elapsed, timeToGain } from '@domain/services/GaugeCalculator';
import { enclosGlobalState, enclosGaugeCurGl, computeGaugeLive, calcSerenEtaLive, calcLevelEtaLive } from '@presentation/helpers/gauge-live';
import { esc, fmt, fmtClock } from '@presentation/helpers/format';
import { DragodindeCard } from './DragodindeCard';
import { Toast } from './Toast';
import { ConfirmModal } from './ConfirmModal';
import { UndoManager } from '@presentation/services/UndoManager';
const ALL_GAUGES: GaugeType[] = ['baffeur', 'caresseur', 'foudroyeur', 'abreuvoir', 'dragofesse', 'mangeoire'];
export class EnclosView {
private el: HTMLElement | null = null;
private enclosId = 0;
private ddCards: Map<number, DragodindeCard> = new Map();
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
private uiState: UIState,
) {}
render(container: HTMLElement, enclosId: number): void {
this.enclosId = enclosId;
this.el = document.createElement('div');
this.el.className = 'enclos-view';
container.appendChild(this.el);
this.renderInner();
}
private getEnc(): Enclos {
return this.queryBus.execute<Enclos>({ type: 'get-enclos-detail', enclosId: this.enclosId });
}
private renderInner(): void {
if (!this.el) return;
const enc = this.getEnc();
const eId = this.enclosId;
const started = !!enc.timer.startTime;
const running = enc.timer.running;
/* Gauge toggle buttons */
const SEREN_PAIR: Record<string, GaugeType> = { baffeur: 'caresseur', caresseur: 'baffeur' };
const gaugeBtnsHtml = ALL_GAUGES.map(gid => {
const def = GAUGE_DEFS[gid];
const active = enc.activeGauges.includes(gid);
const locked = started && !enc.alerted['__done__'];
// Exclusion mutuelle : si la jauge opposée (baffeur↔caresseur) est active, on bloque
const opposite = SEREN_PAIR[gid];
const blocked = !active && !!opposite && enc.activeGauges.includes(opposite);
const cls = `gauge-btn enc-gauge-toggle${active ? ' on' : ''}${locked || blocked ? ' locked' : ''}`;
return `<button class="${cls}" data-gid="${gid}" style="${active ? `border-color:var(${def.cssVar})` : ''}"
${locked || blocked ? 'disabled' : ''}>${def.icon} ${def.label}</button>`;
}).join('');
/* Active gauge config blocks */
const gaugeConfigsHtml = enc.activeGauges.map(gid => {
const def = GAUGE_DEFS[gid];
const lvl = enc.gaugeLevels[gid] || 0;
const tn = tierNum(lvl);
const tr = tierRate(lvl);
const pct = Math.min(100, (lvl / 100000) * 100);
const emptyTime = timeToGain(lvl, lvl);
const emptyStr = emptyTime === Infinity ? '∞' : fmt(emptyTime);
return `<div class="enc-gauge-card" data-gid="${gid}" id="gcfg-${eId}-${gid}">
<div class="enc-gauge-card-head">
<div class="enc-gauge-card-name" style="color:var(${def.cssVar})">${def.icon} ${def.label.toUpperCase()}</div>
<span class="enc-tier-badge" id="gtier-${eId}-${gid}"
style="color:var(${def.cssVar});border-color:color-mix(in srgb,var(${def.cssVar}) 40%,transparent);background:color-mix(in srgb,var(${def.cssVar}) 15%,transparent)">Tier ${tn} · ±${tr}/tick</span>
</div>
<div class="enc-gauge-bar-bg">
<div class="enc-gauge-bar-inner">
<div class="enc-gauge-bar-fill" id="gbar-${eId}-${gid}" style="width:${pct.toFixed(1)}%;background:linear-gradient(to right,color-mix(in srgb,var(${def.cssVar}) 70%,#000),var(${def.cssVar}))"></div>
</div>
</div>
<div class="enc-gauge-bottom">
<div class="enc-gauge-val-group">
<input type="number" class="gauge-inp enc-gauge-inp${started ? ' gauge-inp-recharge recharge' : ''}" id="glvl-${eId}-${gid}"
data-gid="${gid}" data-prev="${lvl}" data-running="${started ? '1' : '0'}"
min="0" max="100000" step="1000" value="${lvl}"
title="${started ? 'Recharger la jauge en cours de session' : ''}">
<span class="enc-gauge-inp-max">/ 100 000</span>
</div>
<div class="enc-gauge-info" id="gempty-${eId}-${gid}">Vide en ${emptyStr}</div>
</div>
</div>`;
}).join('');
/* Timer button */
const timerIcon = running ? 'pause' : 'play_arrow';
const timerBtnClass = running ? 'enc-start-btn enc-btn-pause' : 'enc-start-btn';
const timerBtnText = running ? 'PAUSE' : (enc.timer.pausedAt && !enc.alerted['__done__'] ? 'REPRENDRE' : 'DÉMARRER');
/* DD count */
const ddCount = enc.dragodindes.length;
const ddMax = 10;
this.el.innerHTML = `
<div class="enc-view-inner">
<!-- Panel glassmorphism principal -->
<div class="enc-panel">
<!-- Ligne principale : [nom + vider] [elapsed | alarme | bouton] -->
<div class="enc-header-row">
<!-- Gauche : nom + vider -->
<div class="enc-header-left">
<input type="text" class="enc-name-input" value="${esc(enc.name)}"
size="${Math.max(4, enc.name.length + 1)}"
data-prev="${esc(enc.name)}" id="ename-${eId}">
<button class="enc-clear-btn" id="eclear-${eId}">
<span class="material-symbols-outlined" style="font-size:13px">delete</span>
Vider l'enclos
</button>
</div>
<!-- Droite : temps écoulé + alarme + démarrer -->
<div class="enc-header-right">
<div class="enc-time-block">
<div class="enc-time-lbl">Temps Écoulé</div>
<div class="enc-elapsed" id="elapsed-${eId}">00:00:00</div>
</div>
<div class="enc-time-block">
<div class="enc-time-lbl enc-alarm-lbl">
<span class="material-symbols-outlined mso-fill" style="font-size:12px;color:#00ff00">notifications</span>
Alarme dans
</div>
<div class="enc-alarm" id="gcd-${eId}">--:--:--</div>
</div>
<button class="${timerBtnClass}" id="tbtn-${eId}">
<span class="material-symbols-outlined">${timerIcon}</span>${timerBtnText}
</button>
<button class="enc-reset-btn" id="treset-${eId}" ${!started ? 'style="display:none"' : ''} title="Réinitialiser le timer">
<span class="material-symbols-outlined" style="font-size:18px">restart_alt</span>
</button>
</div>
</div>
<!-- Jauges actives -->
<div class="enc-gauge-label">Jauges Actives :</div>
<div class="enc-gauge-toggles" id="gtoggle-${eId}">${gaugeBtnsHtml}</div>
<div class="enc-gauges-grid" id="gconfigs-${eId}">${gaugeConfigsHtml}</div>
</div>
<!-- Bandeau session terminée -->
<div class="enc-done-banner" id="done-banner-${eId}" style="display:none">
<span class="material-symbols-outlined mso-fill" style="color:#22c55e;font-size:22px">check_circle</span>
<div class="enc-done-texts">
<div class="enc-done-title">Session terminée !</div>
<div class="enc-done-sub">Toutes les cibles ont é atteintes</div>
</div>
<button class="enc-done-reset-btn" id="done-reset-${eId}" style="display:none">
<span class="material-symbols-outlined" style="font-size:16px">refresh</span>
Nouvelle fournée
</button>
</div>
<!-- Section Dragodindes -->
<div class="enc-dd-section">
<div class="enc-dd-head">
<h3 class="enc-dd-title">
Dragodindes <span class="enc-dd-count" id="ddcount-${eId}">${ddCount}/${ddMax}</span>
</h3>
<button class="enc-add-dd-btn" id="adddd-${eId}" ${ddCount >= ddMax ? 'disabled' : ''}>
<span class="material-symbols-outlined" style="font-size:16px">add</span>
Ajouter une Dragodinde
</button>
</div>
<div class="dd-grid" id="dd-grid-${eId}"></div>
</div>
</div>
`;
this.bindEvents(enc);
this.renderDdCards(enc);
}
private bindEvents(enc: Enclos): void {
if (!this.el) return;
const eId = this.enclosId;
/* Enclos name input */
const nameInput = this.el.querySelector<HTMLInputElement>(`#ename-${eId}`);
if (nameInput) {
nameInput.addEventListener('focus', () => {
nameInput.dataset.prev = nameInput.value;
nameInput.value = '';
});
nameInput.addEventListener('blur', () => {
const v = nameInput.value.trim();
if (!v) nameInput.value = nameInput.dataset.prev || enc.name;
else this.commandBus.execute({ type: 'rename-enclos', enclosId: eId, name: v });
});
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { nameInput.value = nameInput.dataset.prev || enc.name; nameInput.blur(); }
else if (e.key === 'Enter') nameInput.blur();
});
}
/* Clear enclos */
this.el.querySelector(`#eclear-${eId}`)?.addEventListener('click', async () => {
const ok = await ConfirmModal.show('Vider l\'enclos', 'Toutes les dragodindes seront supprimées. Continuer ?');
if (!ok) return;
const hasSnap = await UndoManager.snapshotCurrent('Enclos vidé');
this.commandBus.execute({ type: 'clear-enclos', enclosId: eId });
Toast.show('success', 'Enclos vidé.', hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined);
this.renderInner();
});
/* Gauge toggles */
this.el.querySelectorAll<HTMLButtonElement>('.gauge-btn').forEach(btn => {
btn.addEventListener('click', () => {
const gid = btn.dataset.gid as GaugeType;
this.commandBus.execute({ type: 'toggle-gauge', enclosId: eId, gaugeId: gid });
this.renderInner();
});
});
/* Gauge level inputs */
this.el.querySelectorAll<HTMLInputElement>('.gauge-inp').forEach(inp => {
inp.addEventListener('focus', () => {
inp.dataset.prev = inp.value;
inp.value = '';
});
inp.addEventListener('blur', () => {
if (inp.value === '') { inp.value = inp.dataset.prev || '0'; return; }
const v = Math.min(100000, Math.max(0, Number(inp.value)));
if (isNaN(v)) { inp.value = inp.dataset.prev || '0'; return; }
const gid = inp.dataset.gid as GaugeType;
if (inp.dataset.running === '1') {
this.commandBus.execute({ type: 'recharge-gauge', enclosId: eId, gaugeId: gid, level: v });
} else {
this.commandBus.execute({ type: 'update-gauge-level', enclosId: eId, gaugeId: gid, level: v });
}
inp.value = String(v);
});
inp.addEventListener('input', () => {
if (!inp.value) return;
const v = Math.min(100000, Math.max(0, Number(inp.value)));
if (isNaN(v)) return;
const gid = inp.dataset.gid as GaugeType;
if (inp.dataset.running === '1') {
// Recharge en temps réel pendant la session (consolidé côté command)
this.commandBus.execute({ type: 'recharge-gauge', enclosId: eId, gaugeId: gid, level: v });
} else {
this.commandBus.execute({ type: 'update-gauge-level', enclosId: eId, gaugeId: gid, level: v });
}
});
inp.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { inp.value = inp.dataset.prev || '0'; inp.blur(); }
else if (e.key === 'Enter') inp.blur();
});
});
/* Timer start/pause */
this.el.querySelector(`#tbtn-${eId}`)?.addEventListener('click', () => {
const freshEnc = this.getEnc();
if (freshEnc.timer.running) {
this.commandBus.execute({ type: 'stop-timer', enclosId: eId });
} else {
this.commandBus.execute({ type: 'start-timer', enclosId: eId });
}
this.renderInner();
});
/* Reset timer */
this.el.querySelector(`#treset-${eId}`)?.addEventListener('click', () => {
this.commandBus.execute({ type: 'reset-timer', enclosId: eId });
this.renderInner();
});
/* Done banner — Nouvelle fournée */
this.el.querySelector(`#done-reset-${eId}`)?.addEventListener('click', () => {
this.commandBus.execute({ type: 'nouvelle-fournee', enclosId: eId });
Toast.show('success', 'Nouvelle fournée lancée.');
this.renderInner();
});
/* Add DD */
this.el.querySelector(`#adddd-${eId}`)?.addEventListener('click', () => {
this.commandBus.execute({ type: 'add-dragodinde', enclosId: eId });
this.renderInner();
});
}
private renderDdCards(enc: Enclos): void {
const grid = this.el?.querySelector(`#dd-grid-${this.enclosId}`);
if (!grid) return;
/* Destroy old cards */
this.ddCards.forEach(card => card.destroy());
this.ddCards.clear();
/* Create new cards */
enc.dragodindes.forEach(dd => {
const card = new DragodindeCard(this.commandBus, this.queryBus, () => this.renderInner());
card.render(grid as HTMLElement, this.enclosId, dd.id);
this.ddCards.set(dd.id, card);
});
}
update(): void {
if (!this.el) return;
const enc = this.getEnc();
const eId = this.enclosId;
const { globalMax, allDone, started, el: elSec, ddDone } = enclosGlobalState(enc);
const running = enc.timer.running;
/* Complétion automatique : toutes les cibles atteintes → une seule alarme */
if (allDone && running && enc.dragodindes.length > 0 && enc.activeGauges.length > 0) {
this.commandBus.execute({ type: 'complete-timer', enclosId: eId });
this.renderInner();
return;
}
/* Elapsed clock */
const elapsedEl = this.el.querySelector(`#elapsed-${eId}`);
if (elapsedEl) elapsedEl.textContent = fmtClock(elSec);
/* Global countdown */
const gcdEl = this.el.querySelector(`#gcd-${eId}`);
if (gcdEl) {
if (enc.activeGauges.length > 0 && enc.dragodindes.length > 0) {
if (allDone) {
gcdEl.textContent = '✅';
} else if (!isFinite(globalMax)) {
gcdEl.textContent = '∞';
} else {
gcdEl.textContent = fmtClock(globalMax);
}
} else {
gcdEl.textContent = '--:--:--';
}
}
/* Timer button state */
const tbtn = this.el.querySelector<HTMLButtonElement>(`#tbtn-${eId}`);
if (tbtn) {
const timerIcon = running ? 'pause' : 'play_arrow';
const timerText = running ? 'PAUSE' : (enc.timer.pausedAt && !enc.alerted['__done__'] ? 'REPRENDRE' : 'DÉMARRER');
tbtn.className = running ? 'enc-start-btn enc-btn-pause' : 'enc-start-btn';
tbtn.innerHTML = `<span class="material-symbols-outlined">${timerIcon}</span>${timerText}`;
}
/* Reset button visibility */
const resetBtn = this.el.querySelector<HTMLElement>(`#treset-${eId}`);
if (resetBtn) resetBtn.style.display = started ? '' : 'none';
/* Gauge config live updates (tier badges and bars decay over time) */
enc.activeGauges.forEach(gid => {
const startGl = started ? (enc.timer.snapGauges[gid] ?? enc.gaugeLevels[gid]) : enc.gaugeLevels[gid];
const curGl = started ? enclosGaugeCurGl(enc, gid) : startGl;
const tn = tierNum(curGl);
const tr = curGl > 0 ? tierRate(curGl) : 0;
const pct = Math.min(100, (curGl / 100000) * 100);
const emptyTime = curGl > 0 ? timeToGain(curGl, curGl) : 0;
const emptyStr = emptyTime === Infinity ? '∞' : fmt(emptyTime);
const tierEl = this.el!.querySelector(`#gtier-${eId}-${gid}`);
if (tierEl) {
tierEl.textContent = curGl > 0 ? `Tier ${tn} · ±${tr}/tick` : 'Jauge vide';
}
const barEl = this.el!.querySelector<HTMLElement>(`#gbar-${eId}-${gid}`);
if (barEl) barEl.style.width = `${pct.toFixed(1)}%`;
const emptyEl = this.el!.querySelector(`#gempty-${eId}-${gid}`);
if (emptyEl) {
if (curGl > 0) {
emptyEl.textContent = `Vide en ${emptyStr}`;
emptyEl.classList.remove('enc-gauge-alert');
} else if (started && running) {
emptyEl.textContent = '⚠ Rechargez la jauge';
emptyEl.classList.add('enc-gauge-alert');
} else {
emptyEl.textContent = 'Vide';
emptyEl.classList.remove('enc-gauge-alert');
}
}
/* Mettre à jour la valeur affichée de l'input et son état en temps réel */
const inp = this.el!.querySelector<HTMLInputElement>(`#glvl-${eId}-${gid}`);
if (inp) {
// Synchroniser data-running avec l'état réel du timer
inp.dataset.running = started ? '1' : '0';
// Mettre à jour la valeur affichée (sauf si l'utilisateur est en train de taper)
if (document.activeElement !== inp) {
inp.value = String(Math.round(started ? curGl : (enc.gaugeLevels[gid] ?? 0)));
}
}
});
/* Done banner */
const doneBanner = this.el.querySelector<HTMLElement>(`#done-banner-${eId}`);
if (doneBanner) {
doneBanner.style.display = (allDone && started && enc.dragodindes.length > 0 && enc.activeGauges.length > 0) ? '' : 'none';
}
/* Bouton "Nouvelle fournée" — visible uniquement si toutes les DD ont 20000 en maturité, endurance et amour */
const doneResetBtn = this.el.querySelector<HTMLElement>(`#done-reset-${eId}`);
if (doneResetBtn) {
const allMaxed = enc.dragodindes.length > 0 && enc.dragodindes.every(dd =>
dd.stats.maturite >= 20000 && dd.stats.endurance >= 20000 && dd.stats.amour >= 20000
);
doneResetBtn.style.display = allMaxed ? '' : 'none';
}
/* DD count */
const ddCountEl = this.el.querySelector(`#ddcount-${eId}`);
if (ddCountEl) ddCountEl.textContent = `${enc.dragodindes.length}/10`;
/* Add button state */
const addBtn = this.el.querySelector<HTMLButtonElement>(`#adddd-${eId}`);
if (addBtn) addBtn.disabled = enc.dragodindes.length >= 10;
/* Update each DD card */
enc.dragodindes.forEach(dd => {
const card = this.ddCards.get(dd.id);
if (card) card.update(enc, dd, elSec, started);
});
/* Clean up cards for removed DDs */
const currentDdIds = new Set(enc.dragodindes.map(d => d.id));
this.ddCards.forEach((card, id) => {
if (!currentDdIds.has(id)) {
card.destroy();
this.ddCards.delete(id);
}
});
}
destroy(): void {
this.ddCards.forEach(card => card.destroy());
this.ddCards.clear();
this.el?.remove();
this.el = null;
}
}

View File

@ -0,0 +1,48 @@
/**
* GaugePill minimal wrapper for gauge configuration display in EnclosView.
* Renders a gauge level bar with tier badge and "vide en" info.
*/
import type { GaugeType } from '@domain/value-objects/GaugeType';
import { GAUGE_DEFS } from '@domain/value-objects/GaugeType';
import { tierNum, tierRate } from '@domain/value-objects/Tier';
import { timeToGain } from '@domain/services/GaugeCalculator';
import { fmt } from '@presentation/helpers/format';
export class GaugePill {
private el: HTMLElement | null = null;
render(container: HTMLElement, gaugeId: string, value: number, max: number): void {
this.el = document.createElement('div');
this.el.className = 'gauge-pill';
container.appendChild(this.el);
this.update(value, max);
}
update(value: number, max: number): void {
if (!this.el) return;
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
const gid = this.el.dataset.gaugeId as GaugeType | undefined;
const def = gid ? GAUGE_DEFS[gid] : null;
const tn = tierNum(value);
const tr = tierRate(value);
const empty = timeToGain(value, value);
const emptyStr = empty === Infinity ? '∞' : fmt(empty);
this.el.innerHTML = `
<div class="gauge-pill-header">
${def ? `<span>${def.icon} ${def.label}</span>` : ''}
<span class="tier-badge">Tier ${tn} · ±${tr}/tick</span>
</div>
<div class="gauge-pill-bar">
<div class="gauge-pill-fill" style="width:${pct.toFixed(1)}%"></div>
</div>
<div class="gauge-pill-info">Vide en ${emptyStr}</div>
`;
}
destroy(): void {
this.el?.remove();
this.el = null;
}
}

View File

@ -0,0 +1,449 @@
import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import { GEN_COLORS, RACE_GEN, RACES_DATA } from '@domain/value-objects/Race';
import { simulateStock, type SimulationCrossing } from '@domain/services/StockSimulator';
import { getDDImage } from '@presentation/helpers/dd-image';
import { esc } from '@presentation/helpers/format';
interface StockEntry {
m: number;
f: number;
}
export class InventaireView {
private el: HTMLElement | null = null;
private genFilter = 0;
private search = '';
private inventaire: Record<string, StockEntry> = {};
private calcResults: SimulationCrossing[] | null = null;
private unusedStock: { race: string; m: number; f: number }[] | null = null;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
) {}
render(container: HTMLElement): void {
this.el = document.createElement('div');
this.el.className = 'inv-view-new';
container.appendChild(this.el);
const stored = this.queryBus.execute<Record<string, StockEntry>>({ type: 'get-inventaire' });
if (stored && typeof stored === 'object') {
this.inventaire = { ...stored };
}
this.updateDOM();
}
update(): void {}
destroy(): void {
this.saveInventaire();
this.el?.remove();
this.el = null;
}
private getStock(race: string): StockEntry {
if (!this.inventaire[race]) this.inventaire[race] = { m: 0, f: 0 };
return this.inventaire[race];
}
private totalStock(): { total: number; races: number } {
let total = 0, races = 0;
for (const v of Object.values(this.inventaire)) {
const sum = (v.m || 0) + (v.f || 0);
if (sum > 0) { total += sum; races++; }
}
return { total, races };
}
private saveInventaire(): void {
this.commandBus.execute({ type: 'update-settings', inventaire: { ...this.inventaire } });
}
private updateDOM(): void {
if (!this.el) return;
if (this.calcResults !== null) {
this.renderResultsView();
} else {
this.renderInventoryView();
}
}
private renderInventoryView(): void {
if (!this.el) return;
const { genFilter, search } = this;
const allRaces: { name: string; gen: number }[] = [];
for (const base of ['Rousse', 'Amande', 'Dorée']) allRaces.push({ name: base, gen: 1 });
for (const [g, rs] of Object.entries(RACES_DATA)) {
for (const r of rs) allRaces.push({ name: r.name, gen: parseInt(g) });
}
const q = search.trim().toLowerCase();
const filtered = allRaces.filter(r =>
(genFilter > 0 ? r.gen === genFilter : true) &&
(q ? r.name.toLowerCase().includes(q) : true)
);
const { total } = this.totalStock();
let html = '';
/* ── Header ── */
html += `<div class="inv-header">
<div>
<h2 class="inv-title">Inventaire Actuel</h2>
<div class="reappro-title-bar"></div>
</div>
<span class="inv-total" id="inv-summary">${total} Dragons au total</span>
</div>`;
/* ── Search + Gen chips + Action buttons ── */
html += `<div class="inv-filters">
<div class="inv-filters-row">
<div class="inv-search-col">
<label class="inv-filter-label">Rechercher une Dragodinde</label>
<div class="accoup-search-wrap">
<input class="accoup-search" id="inv-search-input" type="text"
placeholder="Nom du type…" value="${esc(search)}" autocomplete="off">
${search ? `<button class="accoup-search-clear" title="Effacer"><span class="material-symbols-outlined" style="font-size:16px">close</span></button>` : ''}
</div>
</div>
<div class="inv-gen-col">
<label class="inv-filter-label">Filtrer par Génération</label>
<div class="accoup-gen-chips" style="margin-bottom:0;border-bottom:none;padding-bottom:0">
<button class="accoup-gen-chip${genFilter === 0 ? ' active' : ''}" data-gen="0">Toutes</button>`;
for (let g = 1; g <= 10; g++) {
html += `<button class="accoup-gen-chip${genFilter === g ? ' active' : ''}" data-gen="${g}">Gen ${g}</button>`;
}
html += `</div>
</div>
</div>
<div class="inv-actions">
<button class="inv-btn-reset" id="inv-reset">
<span class="material-symbols-outlined" style="font-size:16px">restart_alt</span>
Réinitialiser
</button>
<button class="inv-btn-calc" id="inv-calc">
<span class="material-symbols-outlined" style="font-size:16px">science</span>
Calculer
</button>
</div>
</div>`;
/* ── Race grid ── */
if (filtered.length === 0) {
html += `<div class="accoup-empty">Aucune race trouvée</div>`;
} else {
html += `<div class="inv-grid">`;
for (const race of filtered) {
const stock = this.getStock(race.name);
const genCol = GEN_COLORS[race.gen] ?? '#888';
html += `<div class="inv-card-new">
<span class="inv-gen-badge" style="color:${genCol};border-color:${genCol}30;background:${genCol}15">GEN ${race.gen}</span>
<div class="inv-avatar">
${getDDImage(race.name)}
</div>
<h4 class="inv-card-name-new">${esc(race.name)}</h4>
<div class="inv-gender-row">
<div class="inv-gender-input male">
<span class="inv-gender-symbol"></span>
<input type="number" min="0" value="${stock.m}" data-race="${esc(race.name)}" data-gender="m">
</div>
<div class="inv-gender-input female">
<span class="inv-gender-symbol"></span>
<input type="number" min="0" value="${stock.f}" data-race="${esc(race.name)}" data-gender="f">
</div>
</div>
</div>`;
}
html += `</div>`;
}
this.el.innerHTML = html;
this.bindInventoryEvents();
if (search) {
const inp = this.el.querySelector<HTMLInputElement>('#inv-search-input');
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
}
}
private renderResultsView(): void {
if (!this.el) return;
let html = '';
/* Back button */
html += `<div class="reappro-top-bar">
<button class="reappro-back-btn" id="inv-back">
<span class="material-symbols-outlined" style="font-size:18px">arrow_back</span>
Retour à l'inventaire
</button>
</div>`;
html += this.renderCalcResults();
this.el.innerHTML = html;
this.bindResultsEvents();
}
private renderCalcResults(): string {
const crossings = this.calcResults!;
if (crossings.length === 0) {
return `<div class="inv-calc-empty">
<span class="material-symbols-outlined" style="font-size:48px;color:var(--md-outline-variant)">egg</span>
<p>Aucun croisement possible avec le stock actuel.</p>
</div>`;
}
// Group by gen
const byGen = new Map<number, SimulationCrossing[]>();
for (const c of crossings) {
if (!byGen.has(c.gen)) byGen.set(c.gen, []);
byGen.get(c.gen)!.push(c);
}
const gens = Array.from(byGen.keys()).sort((a, b) => a - b);
const totalBabies = crossings.reduce((s, c) => s + c.count, 0);
let html = '';
/* Glass panel wrapper */
html += `<div class="inv-calc-panel">`;
/* Panel header */
html += `<div class="inv-calc-header">
<div class="inv-calc-header-left">
<span class="material-symbols-outlined inv-calc-icon">science</span>
<h2 class="inv-calc-title">Calculateur de Croisements</h2>
</div>
<div class="inv-calc-stats">
<span class="inv-calc-stat-value">${totalBabies}</span>
<span class="inv-calc-stat-label">bébés possibles sur ${gens.length} génération${gens.length > 1 ? 's' : ''}</span>
</div>
</div>`;
/* Crossings by gen */
for (let gi = 0; gi < gens.length; gi++) {
const g = gens[gi];
const gCrossings = byGen.get(g)!;
const genTotal = gCrossings.reduce((s, c) => s + c.count, 0);
const genCol = GEN_COLORS[g] ?? '#888';
html += `<div class="inv-calc-step">
<div class="inv-calc-step-label">
<div class="inv-calc-step-badge-col">
<span class="inv-calc-step-num" style="background:${genCol}">0${gi + 1}</span>
<span class="inv-calc-step-subtitle">Étape</span>
</div>
<h3 class="inv-calc-step-title">Génération ${g}</h3>
<span class="inv-calc-step-count">${genTotal} bébé${genTotal > 1 ? 's' : ''}</span>
</div>`;
for (const c of gCrossings) {
html += `<div class="inv-calc-crossing">`;
// Parent A
html += this.renderCalcParent(c.parentA, c.pAMale, c.pAFemale);
html += `<span class="material-symbols-outlined reappro-crossing-op">add</span>`;
// Parent B
html += this.renderCalcParent(c.parentB, c.pBMale, c.pBFemale);
html += `<span class="material-symbols-outlined reappro-crossing-op" style="color:var(--md-primary)">arrow_forward</span>`;
// Baby
const babyGen = RACE_GEN[c.baby] ?? 0;
html += `<div class="inv-calc-baby">
<div class="inv-calc-baby-avatar">
${getDDImage(c.baby)}
<span class="reappro-baby-gen-badge" style="background:${genCol}">G${babyGen}</span>
</div>
<span class="inv-calc-baby-name">${esc(c.baby)}</span>
<span class="inv-calc-baby-qty">&times;${c.count}</span>
</div>`;
html += `</div>`; // .inv-calc-crossing
}
html += `</div>`; // .inv-calc-step
}
/* Save workflow button */
html += `<div class="inv-calc-save-row">
<button class="reappro-save-btn" id="inv-save">
<span class="material-symbols-outlined" style="font-size:18px">save</span>
Sauvegarder le workflow
</button>
</div>`;
html += `</div>`; // .inv-calc-panel
/* Unused stock */
if (this.unusedStock && this.unusedStock.length > 0) {
html += `<div class="inv-unused-section">
<div class="inv-unused-header">
<h3 class="inv-unused-title">Dragodindes Restantes</h3>
</div>
<div class="inv-unused-grid">`;
for (const u of this.unusedStock) {
const gen = RACE_GEN[u.race] ?? 1;
html += `<div class="inv-unused-card">
<div class="inv-unused-avatar">
${getDDImage(u.race)}
</div>
<div class="inv-unused-info">
<p class="inv-unused-name">${esc(u.race)}</p>
<p class="inv-unused-qty">
${u.m > 0 ? `<span style="color:#50a0ff;font-weight:800">♂ ${u.m}</span>` : ''}
${u.f > 0 ? `<span style="color:#ff64a0;font-weight:800">♀ ${u.f}</span>` : ''}
</p>
</div>
</div>`;
}
html += `</div></div>`;
}
return html;
}
private renderCalcParent(race: string, maleUsed: number, femaleUsed: number): string {
const parts: string[] = [];
if (maleUsed > 0) parts.push(`<span style="color:#50a0ff">♂ ${maleUsed}</span>`);
if (femaleUsed > 0) parts.push(`<span style="color:#ff64a0">♀ ${femaleUsed}</span>`);
return `<div class="inv-calc-parent">
<div class="inv-calc-parent-avatar">
${getDDImage(race)}
</div>
<span class="inv-calc-parent-name">${esc(race)}</span>
<span class="inv-calc-parent-gender">${parts.join(' ')}</span>
</div>`;
}
private bindInventoryEvents(): void {
if (!this.el) return;
// Search
const searchInput = this.el.querySelector<HTMLInputElement>('#inv-search-input');
if (searchInput) {
searchInput.addEventListener('input', () => { this.search = searchInput.value; this.updateDOM(); });
}
const clearBtn = this.el.querySelector('.accoup-search-clear');
if (clearBtn) {
clearBtn.addEventListener('click', () => { this.search = ''; this.updateDOM(); });
}
// Gen filter chips
this.el.querySelectorAll('.accoup-gen-chip[data-gen]').forEach(btn => {
btn.addEventListener('click', () => {
this.genFilter = parseInt((btn as HTMLElement).dataset.gen ?? '0');
this.updateDOM();
});
});
// Stock inputs — focus/blur
this.el.querySelectorAll('.inv-gender-input input').forEach(inp => {
const el = inp as HTMLInputElement;
let prev = el.value;
el.addEventListener('focus', () => { prev = el.value; el.value = ''; });
el.addEventListener('input', () => {
if (el.value === '') return;
const race = el.dataset.race!;
const gender = el.dataset.gender as 'm' | 'f';
if (!this.inventaire[race]) this.inventaire[race] = { m: 0, f: 0 };
this.inventaire[race][gender] = Math.max(0, parseInt(el.value) || 0);
const { total } = this.totalStock();
const summary = this.el?.querySelector('#inv-summary');
if (summary) summary.textContent = `${total} Dragons au total`;
});
el.addEventListener('blur', () => {
if (el.value === '') el.value = prev;
const race = el.dataset.race!;
const gender = el.dataset.gender as 'm' | 'f';
if (!this.inventaire[race]) this.inventaire[race] = { m: 0, f: 0 };
this.inventaire[race][gender] = Math.max(0, parseInt(el.value) || 0);
el.value = String(this.inventaire[race][gender]);
this.saveInventaire();
const { total } = this.totalStock();
const summary = this.el?.querySelector('#inv-summary');
if (summary) summary.textContent = `${total} Dragons au total`;
});
});
// Reset
const resetBtn = this.el.querySelector('#inv-reset');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
this.inventaire = {};
this.calcResults = null;
this.unusedStock = null;
this.saveInventaire();
this.updateDOM();
});
}
// Calculate
const calcBtn = this.el.querySelector('#inv-calc');
if (calcBtn) {
calcBtn.addEventListener('click', () => {
this.calcInventaire();
this.updateDOM();
});
}
}
private bindResultsEvents(): void {
if (!this.el) return;
// Back button — retour à l'inventaire sans réinitialiser
const backBtn = this.el.querySelector('#inv-back');
if (backBtn) {
backBtn.addEventListener('click', () => {
this.calcResults = null;
this.unusedStock = null;
this.updateDOM();
});
}
// Save workflow
const saveBtn = this.el.querySelector<HTMLButtonElement>('#inv-save');
if (saveBtn && this.calcResults && this.calcResults.length > 0) {
saveBtn.addEventListener('click', () => {
const crossings = this.calcResults!;
const topCrossing = crossings.reduce((best, c) => c.gen > best.gen ? c : best, crossings[0]);
const target = topCrossing.baby;
const qty = topCrossing.count;
const materials = Object.entries(this.inventaire)
.filter(([, e]) => e.m > 0 || e.f > 0)
.map(([race, e]) => ({ race, m: e.m, f: e.f }));
const steps = crossings.map(c => ({
baby: c.baby,
parentA: c.parentA,
parentB: c.parentB,
couples: c.count,
gen: c.gen,
}));
this.commandBus.execute({
type: 'save-workflow',
target,
qty,
materials,
steps,
repro: {},
});
saveBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px">check</span> Sauvegardé !';
setTimeout(() => {
saveBtn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px">save</span> Sauvegarder le workflow';
}, 2000);
});
}
}
/* ── Simulation ── */
private calcInventaire(): void {
const { crossings, unusedStock } = simulateStock(this.inventaire);
this.calcResults = crossings;
this.unusedStock = unusedStock;
}
}

View File

@ -0,0 +1,375 @@
import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import type { SettingsResult } from '@application/queries/GetSettings';
import { esc } from '@presentation/helpers/format';
import { Toast } from './Toast';
import { ConfirmModal } from './ConfirmModal';
const NTFY_BASE = 'https://ntfy.mickael-pol.fr';
const NTFY_REDIRECT = 'https://ntfy-redirect.mickael-pol.fr';
const SOUND_OPTIONS: { value: string; label: string; icon: string }[] = [
{ value: 'arpege', label: 'Arpège', icon: 'music_note' },
{ value: 'pulse', label: 'Pulsation', icon: 'pulse_alert' },
{ value: 'fanfare', label: 'Fanfare', icon: 'celebration' },
{ value: 'cloche', label: 'Cloche', icon: 'notifications_active' },
];
export class ParametresView {
private el: HTMLElement | null = null;
private modal: HTMLElement | null = null;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
private playSound?: (name: string) => void,
) {}
render(container: HTMLElement): void {
this.el = document.createElement('div');
this.el.className = 'param-view';
container.appendChild(this.el);
this.updateDOM();
}
update(): void {}
destroy(): void {
this.modal?.remove();
this.modal = null;
this.el?.remove();
this.el = null;
}
private getSettings(): SettingsResult {
return this.queryBus.execute<SettingsResult>({ type: 'get-settings' });
}
private updateDOM(): void {
if (!this.el) return;
const { alarmSound, notifsEnabled, ntfyTopic } = this.getSettings();
let html = '';
// ── Hero ──────────────────────────────────────────────────────
html += `<div class="param-hero">
<div>
<h2 class="param-hero-title">Param\u00e8tres</h2>
<p class="param-hero-sub">Configuration de l'application et des notifications.</p>
</div>
</div>`;
// ── Son d'alarme ──────────────────────────────────────────────
html += `<div class="param-section">
<div class="param-section-header">
<span class="material-symbols-outlined param-section-icon">volume_up</span>
<h3 class="param-section-title">Son d'alarme</h3>
</div>
<p class="param-section-desc">Choisissez le son joué lorsqu'un enclos termine sa session.</p>
<div class="param-sound-grid">`;
for (const opt of SOUND_OPTIONS) {
const isActive = alarmSound === opt.value ? ' active' : '';
html += `<button class="param-sound-card${isActive}" data-sound="${esc(opt.value)}">
<span class="material-symbols-outlined param-sound-icon">${opt.icon}</span>
<span class="param-sound-label">${esc(opt.label)}</span>
</button>`;
}
html += `</div>
<button class="param-test-btn" id="param-test-sound">
<span class="material-symbols-outlined">play_circle</span>
Tester le son
</button>
</div>`;
// ── Notifications PC ──────────────────────────────────────────
html += `<div class="param-section">
<div class="param-section-header">
<span class="material-symbols-outlined param-section-icon">notifications</span>
<h3 class="param-section-title">Notifications PC</h3>
</div>
<p class="param-section-desc">Recevez une notification Windows quand un enclos est pr\u00eat.</p>
<div class="param-toggle-row">
<span class="param-toggle-label">${notifsEnabled ? 'Notifications activ\u00e9es' : 'Notifications d\u00e9sactiv\u00e9es'}</span>
<button class="param-toggle${notifsEnabled ? ' active' : ''}" id="param-notifs-toggle">
<span class="param-toggle-knob"></span>
</button>
</div>
</div>`;
// ── Notifications Mobile ──────────────────────────────────────
const mobileActive = ntfyTopic ? ' active' : '';
html += `<div class="param-section">
<div class="param-section-header">
<span class="material-symbols-outlined param-section-icon">phone_android</span>
<h3 class="param-section-title">Notifications Mobiles</h3>
</div>
<p class="param-section-desc">Recevez une alerte sur votre t\u00e9l\u00e9phone via ntfy, m\u00eame loin de votre PC.</p>
<div class="param-mobile-status">
<span class="param-mobile-dot${mobileActive}"></span>
<span class="param-mobile-text">${ntfyTopic ? 'Connect\u00e9 — notifications actives' : 'Non configur\u00e9'}</span>
</div>
<button class="param-mobile-btn${mobileActive}" id="param-ntfy-btn">
<span class="material-symbols-outlined">${ntfyTopic ? 'settings' : 'add_circle'}</span>
${ntfyTopic ? 'G\u00e9rer la configuration' : 'Activer les notifications mobiles'}
</button>
</div>`;
// ── Données ──────────────────────────────────────────────────
html += `<div class="param-section">
<div class="param-section-header">
<span class="material-symbols-outlined param-section-icon">database</span>
<h3 class="param-section-title">Donn\u00e9es</h3>
</div>
<p class="param-section-desc">Exportez ou importez toutes vos donn\u00e9es (enclos, dragodindes, statistiques, workflows).</p>
<div class="param-data-btns">
<button class="param-data-btn" id="param-export-data">
<span class="material-symbols-outlined">download</span>
Exporter les donn\u00e9es
</button>
<button class="param-data-btn param-data-btn-import" id="param-import-data">
<span class="material-symbols-outlined">upload</span>
Importer les donn\u00e9es
</button>
</div>
</div>`;
this.el.innerHTML = html;
this.bindEvents();
}
private bindEvents(): void {
if (!this.el) return;
// Sound cards
this.el.querySelectorAll<HTMLElement>('.param-sound-card').forEach(card => {
card.addEventListener('click', () => {
const sound = card.dataset['sound']!;
this.commandBus.execute({ type: 'update-settings', alarmSound: sound });
this.updateDOM();
});
});
// Test sound
this.el.querySelector('#param-test-sound')?.addEventListener('click', () => {
const { alarmSound } = this.getSettings();
this.playSound?.(alarmSound);
});
// PC notifications toggle
this.el.querySelector('#param-notifs-toggle')?.addEventListener('click', () => {
const { notifsEnabled } = this.getSettings();
this.commandBus.execute({ type: 'update-settings', notifsEnabled: !notifsEnabled });
this.updateDOM();
});
// Ntfy modal
this.el.querySelector('#param-ntfy-btn')?.addEventListener('click', () => {
this.openNtfyModal();
});
// Export data
this.el.querySelector('#param-export-data')?.addEventListener('click', () => this.exportData());
// Import data
this.el.querySelector('#param-import-data')?.addEventListener('click', () => this.importData());
}
/* ══ Modal ntfy ══ */
private openNtfyModal(): void {
if (this.modal) { this.modal.classList.remove('hidden'); this.renderNtfyModal(); return; }
this.modal = document.createElement('div');
this.modal.className = 'param-modal-overlay';
this.modal.innerHTML = `
<div class="param-modal-box">
<div class="param-modal-header">
<div class="param-modal-header-left">
<span class="material-symbols-outlined" style="color:var(--md-primary)">phone_android</span>
<h2 class="param-modal-title">Notifications mobiles</h2>
</div>
<button class="param-modal-close" id="ntfy-modal-close">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="param-modal-body" id="ntfy-modal-body"></div>
<div class="param-modal-footer">
<button class="param-modal-btn-ghost" id="ntfy-modal-footer-close">Fermer</button>
</div>
</div>`;
document.body.appendChild(this.modal);
this.modal.querySelector('#ntfy-modal-close')?.addEventListener('click', () => this.closeNtfyModal());
this.modal.querySelector('#ntfy-modal-footer-close')?.addEventListener('click', () => this.closeNtfyModal());
this.modal.addEventListener('click', (e) => { if (e.target === this.modal) this.closeNtfyModal(); });
this.renderNtfyModal();
}
private closeNtfyModal(): void {
this.modal?.classList.add('hidden');
}
private renderNtfyModal(): void {
const body = this.modal?.querySelector('#ntfy-modal-body');
if (!body) return;
const { ntfyTopic } = this.getSettings();
if (!ntfyTopic) {
body.innerHTML = `
<div class="param-ntfy-intro">
<div class="param-ntfy-intro-card">
<span class="material-symbols-outlined" style="font-size:36px;color:var(--md-primary)">notifications_active</span>
<p>Recevez une alerte sur votre t\u00e9l\u00e9phone quand un enclos est pr\u00eat, m\u00eame si votre PC est loin !</p>
</div>
<button class="param-ntfy-activate" id="ntfy-activate">
<span class="material-symbols-outlined">add_circle</span>
Activer les notifications mobiles
</button>
</div>`;
body.querySelector('#ntfy-activate')?.addEventListener('click', () => this.activateNtfy());
return;
}
// QR codes
const ntfyPlayStore = 'https://play.google.com/store/apps/details?id=io.heckel.ntfy';
const ntfyAppStore = 'https://apps.apple.com/app/ntfy/id1625396347';
const qrDownload = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(ntfyPlayStore)}`;
const redirectUrl = `${NTFY_REDIRECT}/?t=${encodeURIComponent(ntfyTopic)}&s=${encodeURIComponent(NTFY_BASE.replace(/^https?:\/\//, ''))}&n=dd-timer`;
const qrSubscribe = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(redirectUrl)}`;
body.innerHTML = `
<div class="param-ntfy-steps">
<div class="param-ntfy-step">
<div class="param-ntfy-step-badge">1</div>
<div class="param-ntfy-step-content">
<h4 class="param-ntfy-step-title">Installer l'app ntfy</h4>
<p class="param-ntfy-step-desc">Scannez ce QR code ou cherchez <strong>ntfy</strong> sur le
<a href="${ntfyPlayStore}" target="_blank">Play Store</a> /
<a href="${ntfyAppStore}" target="_blank">App Store</a></p>
<div class="param-ntfy-qr-wrap">
<img src="${qrDownload}" width="100" height="100" alt="T\u00e9l\u00e9charger ntfy">
</div>
</div>
</div>
<div class="param-ntfy-step">
<div class="param-ntfy-step-badge">2</div>
<div class="param-ntfy-step-content">
<h4 class="param-ntfy-step-title">S'abonner aux notifications</h4>
<p class="param-ntfy-step-desc">Scannez ce QR code avec l'appareil photo de votre t\u00e9l\u00e9phone pour ajouter automatiquement les notifications.</p>
<div class="param-ntfy-qr-wrap param-ntfy-qr-main">
<img src="${qrSubscribe}" width="150" height="150" alt="S'abonner aux notifications">
</div>
</div>
</div>
</div>
<div class="param-ntfy-actions">
<button class="param-ntfy-test-btn" id="ntfy-test">
<span class="material-symbols-outlined">send</span>
Tester
</button>
<button class="param-ntfy-deactivate-btn" id="ntfy-deactivate">
<span class="material-symbols-outlined">link_off</span>
D\u00e9sactiver
</button>
</div>`;
body.querySelector('#ntfy-test')?.addEventListener('click', () => this.testNtfy());
body.querySelector('#ntfy-deactivate')?.addEventListener('click', () => this.deactivateNtfy());
}
private activateNtfy(): void {
const topic = 'dd-' + Math.random().toString(36).slice(2, 8) + '-' + Date.now().toString(36).slice(-4);
this.commandBus.execute({ type: 'update-settings', ntfyTopic: topic });
this.updateDOM();
this.renderNtfyModal();
}
private deactivateNtfy(): void {
this.commandBus.execute({ type: 'update-settings', ntfyTopic: '' });
this.updateDOM();
this.renderNtfyModal();
}
private testNtfy(): void {
const { ntfyTopic } = this.getSettings();
if (!ntfyTopic) return;
const url = `${NTFY_BASE}/${ntfyTopic}`;
(window as any).electronAPI?.sendNtfy?.(url, 'Test alarme', 'Ceci est un test de la notification mobile Minuteur Dragodinde !');
}
/* ══ Backup / Restore ══ */
private async exportData(): Promise<void> {
const api = (window as any).electronAPI;
if (!api?.loadData || !api?.exportFile) return;
const raw = await api.loadData();
if (!raw) {
Toast.show('error', 'Aucune donnée à exporter.');
return;
}
const version = await api.getVersion?.() ?? 'unknown';
const backup = {
app: 'minuteur-dragodinde',
version,
exportedAt: new Date().toISOString(),
data: JSON.parse(raw),
};
const date = new Date().toISOString().slice(0, 10);
const ok = await api.exportFile(JSON.stringify(backup, null, 2), `dd-timer-backup-${date}.json`);
if (ok) {
Toast.show('success', 'Données exportées avec succès.');
}
}
private async importData(): Promise<void> {
const api = (window as any).electronAPI;
if (!api?.importFile || !api?.saveData) return;
const raw = await api.importFile();
if (!raw) return; // dialogue annulé
let parsed: any;
try {
parsed = JSON.parse(raw);
} catch {
Toast.show('error', 'Le fichier sélectionné n\'est pas un JSON valide.');
return;
}
// Validation du format backup
if (parsed.app === 'minuteur-dragodinde' && parsed.data && typeof parsed.data === 'object') {
// Format backup avec métadonnées
const date = parsed.exportedAt ? new Date(parsed.exportedAt).toLocaleDateString('fr-FR') : 'inconnue';
const ok = await ConfirmModal.show(
'Importer les données',
`Ce backup date du ${date} (v${parsed.version ?? '?'}). Toutes vos données actuelles seront remplacées. Continuer ?`,
);
if (!ok) return;
api.saveData(JSON.stringify(parsed.data));
} else if (parsed.enclos && Array.isArray(parsed.enclos)) {
// Format brut (ancien export ou fichier state direct)
const ok = await ConfirmModal.show(
'Importer les données',
'Toutes vos données actuelles seront remplacées. Continuer ?',
);
if (!ok) return;
api.saveData(JSON.stringify(parsed));
} else {
Toast.show('error', 'Format de fichier non reconnu.');
return;
}
Toast.show('success', 'Données importées. Rechargement...');
setTimeout(() => window.location.reload(), 1000);
}
}

View File

@ -0,0 +1,471 @@
import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import { RACES_DATA, GEN_COLORS, RACE_GEN, BREEDING_RECIPES } from '@domain/value-objects/Race';
import { getDDImage } from '@presentation/helpers/dd-image';
import { esc } from '@presentation/helpers/format';
interface ApproNeeds {
total: number;
m: number;
f: number;
}
interface ApproStep {
baby: string;
parentA: string;
parentB: string;
couples: number;
gen: number;
}
interface ApproState {
target: string;
qty: number;
repro: Record<string, number>;
inverted: Record<string, boolean>;
genFilter: number;
search: string;
}
export class ReapproView {
private el: HTMLElement | null = null;
private approState: ApproState = { target: '', qty: 1, repro: {}, inverted: {}, genFilter: 0, search: '' };
private dirty = true;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
) {}
render(container: HTMLElement): void {
this.el = document.createElement('div');
this.el.className = 'reappro-view-new';
container.appendChild(this.el);
this.dirty = true; this.update();
}
update(): void {
if (!this.el || !this.dirty) return;
this.dirty = false;
const { target } = this.approState;
if (target) {
this.renderResults();
} else {
this.renderTargetSelection();
}
}
destroy(): void {
this.el?.remove();
this.el = null;
}
/* ── Target selection ── */
private renderTargetSelection(): void {
if (!this.el) return;
const { genFilter, search } = this.approState;
const allRaces: { name: string; gen: number }[] = [];
for (const [g, rs] of Object.entries(RACES_DATA)) {
const gen = parseInt(g);
if (gen < 2) continue;
for (const r of rs) allRaces.push({ name: r.name, gen });
}
const q = search.trim().toLowerCase();
const filtered = allRaces.filter(r =>
(genFilter > 0 ? r.gen === genFilter : true) &&
(q ? r.name.toLowerCase().includes(q) : true)
);
let html = '';
// Header
html += `<div class="reappro-section-header">
<div>
<h2 class="reappro-section-title">Sélectionne ta cible</h2>
<div class="reappro-title-bar"></div>
</div>
<div class="accoup-gen-chips" style="margin-bottom:0;border-bottom:none;padding-bottom:0">
<button class="accoup-gen-chip${genFilter === 0 ? ' active' : ''}" data-gen="0">Toutes</button>`;
for (let g = 2; g <= 10; g++) {
html += `<button class="accoup-gen-chip${genFilter === g ? ' active' : ''}" data-gen="${g}">Gen ${g}</button>`;
}
html += `</div></div>`;
// Search
html += `<div class="accoup-search-wrap">
<input class="accoup-search" id="appro-search-input" type="text"
placeholder="Rechercher une race…" value="${esc(search)}" autocomplete="off">
${search ? `<button class="accoup-search-clear" title="Effacer"><span class="material-symbols-outlined" style="font-size:16px">close</span></button>` : ''}
</div>`;
// Race grid
if (filtered.length === 0) {
html += `<div class="accoup-empty">Aucune race trouvée</div>`;
} else {
html += `<div class="accoup-race-grid">`;
for (const race of filtered) {
const genCol = GEN_COLORS[race.gen] ?? '#888';
html += `<div class="accoup-race-card" data-race="${esc(race.name)}">
<div class="accoup-race-card-img">
${getDDImage(race.name)}
<div class="accoup-race-card-gen" style="color:${genCol}">GEN ${race.gen}</div>
</div>
<div class="accoup-race-card-name">${esc(race.name)}</div>
</div>`;
}
html += `</div>`;
}
this.el.innerHTML = html;
this.bindTargetSelectionEvents();
if (search) {
const inp = this.el.querySelector<HTMLInputElement>('#appro-search-input');
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
}
}
private bindTargetSelectionEvents(): void {
if (!this.el) return;
const searchInput = this.el.querySelector<HTMLInputElement>('#appro-search-input');
if (searchInput) {
searchInput.addEventListener('input', () => {
this.approState.search = searchInput.value;
this.dirty = true; this.update();
});
}
const clearBtn = this.el.querySelector('.accoup-search-clear');
if (clearBtn) {
clearBtn.addEventListener('click', () => {
this.approState.search = '';
this.dirty = true; this.update();
});
}
this.el.querySelectorAll('.accoup-gen-chip').forEach(btn => {
btn.addEventListener('click', () => {
this.approState.genFilter = parseInt((btn as HTMLElement).dataset.gen ?? '0');
this.dirty = true; this.update();
});
});
this.el.querySelectorAll('.accoup-race-card').forEach(card => {
card.addEventListener('click', () => {
this.approState.target = (card as HTMLElement).dataset.race!;
this.approState.qty = 1;
this.approState.repro = {};
this.approState.inverted = {};
this.approState.search = '';
this.dirty = true; this.update();
});
});
}
/* ── Results view ── */
private renderResults(): void {
if (!this.el) return;
const { target, qty } = this.approState;
const targetGen = RACE_GEN[target] ?? 0;
const { materials, steps } = this.calcAppro(target, qty);
// Group steps by generation
const stepsByGen = new Map<number, ApproStep[]>();
for (const step of steps) {
const list = stepsByGen.get(step.gen) ?? [];
list.push(step);
stepsByGen.set(step.gen, list);
}
const sortedGens = [...stepsByGen.keys()].sort((a, b) => a - b);
let html = '';
// Top bar: back + quantity (top-left)
html += `<div class="reappro-top-bar">
<button class="reappro-back-btn" id="appro-back">
<span class="material-symbols-outlined" style="font-size:18px">arrow_back</span>
Retour
</button>
<div class="reappro-qty-wrap">
<label class="accoup-center-label">Quantité</label>
<input class="accoup-center-input" id="appro-qty" type="number" min="1" value="${qty}" style="width:70px">
</div>
</div>`;
// Materials (Gen 1)
if (materials.length > 0) {
const totalMat = materials.reduce((s, m) => s + m.m + m.f, 0);
html += `<div class="reappro-step-panel reappro-step-primary">`;
html += `<div class="reappro-step-header reappro-step-header-primary">
<h3 class="reappro-step-title">
<span class="reappro-step-badge reappro-badge-primary">1</span>
MATIÈRES PREMIÈRES GÉNÉRATION 1
</h3>
<span class="reappro-step-count">Total : ${totalMat} dragodindes requises</span>
</div>`;
html += `<div class="reappro-materials-grid">`;
for (const mat of materials) {
html += this.renderMaterialCard(mat.race, mat.m, mat.f);
}
html += `</div></div>`;
// Arrow separator
html += `<div class="reappro-arrow-sep">
<span class="material-symbols-outlined reappro-arrow-icon">expand_more</span>
</div>`;
}
// Steps grouped by generation
let stepNum = 2;
for (let gi = 0; gi < sortedGens.length; gi++) {
const gen = sortedGens[gi];
const genSteps = stepsByGen.get(gen)!;
const isFinalGen = gi === sortedGens.length - 1;
const genCol = GEN_COLORS[gen] ?? '#888';
const panelClass = isFinalGen ? 'reappro-step-panel reappro-step-final' : 'reappro-step-panel';
const headerClass = isFinalGen ? 'reappro-step-header reappro-step-header-final' : 'reappro-step-header';
const badgeClass = isFinalGen ? 'reappro-step-badge reappro-badge-primary' : 'reappro-step-badge';
html += `<div class="${panelClass}">`;
html += `<div class="${headerClass}">
<h3 class="reappro-step-title">
<span class="${badgeClass}">${stepNum}</span>
${isFinalGen ? 'ÉTAPE FINALE' : 'CROISEMENTS'} GÉNÉRATION ${gen}
</h3>
<span class="reappro-step-count">${genSteps.length} croisement${genSteps.length > 1 ? 's' : ''}</span>
</div>`;
html += `<div class="reappro-crossings-grid">`;
for (const step of genSteps) {
const invKey = step.baby;
const isInverted = this.approState.inverted[invKey] ?? false;
const reproCount = this.approState.repro[invKey] ?? 0;
const isLast = isFinalGen && genSteps.length === 1;
html += `<div class="reappro-crossing-card${isLast ? ' reappro-crossing-final' : ''}">`;
html += `<div class="reappro-crossing-row">`;
// Parents
const pA = isInverted ? step.parentB : step.parentA;
const pB = isInverted ? step.parentA : step.parentB;
html += this.renderCrossingParent(pA, '♂', step.couples);
html += `<span class="material-symbols-outlined reappro-crossing-op">add</span>`;
html += this.renderCrossingParent(pB, '♀', step.couples);
html += `<span class="material-symbols-outlined reappro-crossing-op" style="color:var(--md-primary)">arrow_forward</span>`;
// Baby result
const babyGen = RACE_GEN[step.baby] ?? 0;
html += `<div class="reappro-crossing-baby${isLast ? ' reappro-crossing-baby-final' : ''}">
<div class="reappro-baby-avatar">
${getDDImage(step.baby)}
<span class="reappro-baby-gen-badge" style="background:${genCol}">G${babyGen}</span>
</div>
<span class="reappro-baby-name">${esc(step.baby)}</span>
<span class="reappro-baby-qty">&times;${step.couples}</span>
</div>`;
html += `</div>`; // .reappro-crossing-row
// Controls row
html += `<div class="reappro-crossing-controls">
<label class="reappro-repro-label">
<span style="font-size:11px;color:var(--md-on-surface-variant)">Reproducteurs</span>
<input type="number" class="reappro-repro-input" data-race="${esc(invKey)}" min="0" value="${reproCount}">
</label>
<button class="reappro-invert-btn" data-race="${esc(invKey)}" title="Inverser ♂/♀">
<span class="material-symbols-outlined" style="font-size:16px">swap_horiz</span>
</button>
<span class="reappro-couples-badge">${step.couples} couple${step.couples > 1 ? 's' : ''}</span>
</div>`;
html += `</div>`; // .reappro-crossing-card
}
html += `</div>`; // .reappro-crossings-grid
html += `</div>`; // .reappro-step-panel
stepNum++;
// Arrow between gen panels (not after last)
if (!isFinalGen) {
html += `<div class="reappro-arrow-sep">
<span class="material-symbols-outlined reappro-arrow-icon">expand_more</span>
</div>`;
}
}
// Sticky target bar at bottom with save button
html += `<div class="reappro-target-bar-sticky">
<div class="reappro-target-info">
<div class="reappro-target-icon">
<span class="material-symbols-outlined mso-fill">auto_awesome</span>
</div>
<div>
<div class="reappro-target-label">Cible de réapprovisionnement</div>
<div class="reappro-target-name">${esc(target)} (Génération ${targetGen})</div>
</div>
</div>
<button class="reappro-save-btn" id="appro-save">
<span class="material-symbols-outlined" style="font-size:18px">save</span>
Sauvegarder ce plan
</button>
</div>`;
this.el.innerHTML = html;
this.bindResultsEvents();
}
private renderMaterialCard(race: string, m: number, f: number): string {
const gen = RACE_GEN[race] ?? 1;
const genCol = GEN_COLORS[gen] ?? '#888';
const parts: string[] = [];
if (m > 0) parts.push(`<span class="reappro-mat-gender" style="color:#50a0ff">♂ ${m}</span>`);
if (f > 0) parts.push(`<span class="reappro-mat-gender" style="color:#ff64a0">♀ ${f}</span>`);
return `<div class="reappro-material-card">
<div class="reappro-mat-avatar">
${getDDImage(race)}
</div>
<span class="reappro-mat-name">${esc(race)}</span>
<div class="reappro-mat-qty">${parts.join(' ')}</div>
</div>`;
}
private renderCrossingParent(race: string, gender: string, qty: number): string {
const genderColor = gender === '♂' ? '#50a0ff' : '#ff64a0';
return `<div class="reappro-crossing-parent">
<div class="reappro-crossing-parent-avatar">
${getDDImage(race)}
</div>
<span class="reappro-crossing-parent-name">${esc(race)}</span>
<span class="reappro-crossing-parent-gender" style="color:${genderColor}">${gender} ${qty}</span>
</div>`;
}
private bindResultsEvents(): void {
if (!this.el) return;
// Back buttons
this.el.querySelectorAll('#appro-back').forEach(btn => {
btn.addEventListener('click', () => {
this.approState.target = '';
this.dirty = true; this.update();
});
});
const qtyInput = this.el.querySelector('#appro-qty') as HTMLInputElement | null;
if (qtyInput) {
let prevQty = qtyInput.value;
qtyInput.addEventListener('focus', () => { prevQty = qtyInput.value; qtyInput.value = ''; });
qtyInput.addEventListener('blur', () => {
if (qtyInput.value === '') qtyInput.value = prevQty;
this.approState.qty = Math.max(1, parseInt(qtyInput.value) || 1);
qtyInput.value = String(this.approState.qty);
this.dirty = true; this.update();
});
}
this.el.querySelectorAll('.reappro-repro-input').forEach(inp => {
const inpEl = inp as HTMLInputElement;
let prev = inpEl.value;
inpEl.addEventListener('focus', () => { prev = inpEl.value; inpEl.value = ''; });
inpEl.addEventListener('blur', () => {
if (inpEl.value === '') inpEl.value = prev;
const race = inpEl.dataset.race!;
this.approState.repro[race] = Math.max(0, parseInt(inpEl.value) || 0);
this.dirty = true; this.update();
});
});
this.el.querySelectorAll('.reappro-invert-btn').forEach(btn => {
btn.addEventListener('click', () => {
const race = (btn as HTMLElement).dataset.race!;
this.approState.inverted[race] = !this.approState.inverted[race];
this.dirty = true; this.update();
});
});
const saveBtn = this.el.querySelector('#appro-save');
if (saveBtn) {
saveBtn.addEventListener('click', () => {
const { target, qty, repro } = this.approState;
const { materials, steps } = this.calcAppro(target, qty);
this.commandBus.execute({
type: 'save-workflow',
target,
qty,
materials,
steps,
repro: { ...repro },
});
(saveBtn as HTMLButtonElement).innerHTML = '<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle;margin-right:6px">check</span> Sauvegardé !';
setTimeout(() => {
(saveBtn as HTMLButtonElement).innerHTML = '<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle;margin-right:6px">save</span> Sauvegarder ce plan';
}, 2000);
});
}
}
/* ── Breeding plan calculation ── */
private calcAppro(target: string, qty: number): { materials: { race: string; m: number; f: number }[]; steps: ApproStep[] } {
const targetGen = RACE_GEN[target] ?? 0;
if (targetGen < 2 || !BREEDING_RECIPES[target]) {
return { materials: [], steps: [] };
}
const needs: Record<string, ApproNeeds> = {};
needs[target] = { total: qty, m: 0, f: 0 };
const steps: ApproStep[] = [];
for (let g = targetGen; g >= 2; g--) {
const racesAtGen: string[] = [];
for (const [rg, rs] of Object.entries(RACES_DATA)) {
if (parseInt(rg) === g) {
for (const r of rs) {
if (needs[r.name]) racesAtGen.push(r.name);
}
}
}
for (const race of racesAtGen) {
const recipe = BREEDING_RECIPES[race];
if (!recipe) continue;
const need = needs[race];
if (!need || need.total <= 0) continue;
const reproCount = this.approState.repro[race] ?? 0;
const couples = Math.max(1, need.total - reproCount);
const isInverted = this.approState.inverted[race] ?? false;
const [recipeA, recipeB] = recipe;
const parentA = isInverted ? recipeB : recipeA;
const parentB = isInverted ? recipeA : recipeB;
if (!needs[parentA]) needs[parentA] = { total: 0, m: 0, f: 0 };
needs[parentA].total += couples;
needs[parentA].m += couples;
if (!needs[parentB]) needs[parentB] = { total: 0, m: 0, f: 0 };
needs[parentB].total += couples;
needs[parentB].f += couples;
steps.push({ baby: race, parentA, parentB, couples, gen: g });
}
}
const materials: { race: string; m: number; f: number }[] = [];
for (const [race, need] of Object.entries(needs)) {
if (!BREEDING_RECIPES[race] && need.total > 0) {
materials.push({ race, m: need.m, f: need.f });
}
}
steps.reverse();
return { materials, steps };
}
}

View File

@ -0,0 +1,126 @@
import type { UIState } from '@presentation/state/UIState';
import type { QueryBus } from '@application/handlers/QueryBus';
import type { DashboardResult } from '@application/queries/GetDashboard';
import { esc } from '@presentation/helpers/format';
export class Sidebar {
private el: HTMLElement | null = null;
constructor(
private uiState: UIState,
private queryBus: QueryBus,
) {}
render(container: HTMLElement): void {
this.el = document.createElement('aside');
this.el.className = 'sidebar-new';
container.appendChild(this.el);
this.update();
this.fetchAndInjectVersion();
}
update(): void {
if (!this.el) return;
const data = this.queryBus.execute<DashboardResult>({ type: 'get-dashboard' });
const enclosList = data.enclosSummaries;
const activeView = this.uiState.activeView;
let html = '';
// ── Header ──
html += `
<div class="sb-header">
<div class="sb-logo-wrap">
<img src="/icone_sidebar.png" alt="logo" class="sb-logo-img" />
</div>
<div class="sb-brand">
<span class="sb-brand-name">Obsidienne</span>
<span class="sb-brand-sub">Gestion d'&eacute;levage</span>
</div>
</div>
`;
// ── Nav ──
html += `<div class="sb-nav">`;
// Section Principal
html += `<div class="sb-section">`;
html += `<div class="sb-section-head"><span class="sb-section-label">Principal</span></div>`;
html += this.item('dashboard', 'dashboard', 'Tableau de bord', activeView === 'dashboard');
html += this.item('statistiques', 'bar_chart', 'Statistiques', activeView === 'statistiques');
html += `</div>`;
// Section Enclos
html += `<div class="sb-section">`;
html += `<div class="sb-section-head"><span class="sb-section-label">Enclos</span></div>`;
enclosList.forEach(enc => {
const isActive = activeView === enc.id;
const dotClass = enc.running ? 'running' : 'idle';
html += `<button class="sb-item${isActive ? ' active' : ''}" data-view="${enc.id}">`;
html += `<span class="sb-item-icon material-symbols-outlined">pentagon</span>`;
html += `<span class="sb-item-text">${esc(enc.name)}</span>`;
html += `<span class="sb-dot ${dotClass}"></span>`;
html += `</button>`;
});
html += `</div>`;
// Section Outils
html += `<div class="sb-section">`;
html += `<div class="sb-section-head"><span class="sb-section-label">Outils</span></div>`;
html += this.item('accouplement', 'favorite', 'Accouplement', activeView === 'accouplement');
html += this.item('appro', 'science', 'R\u00e9appro', activeView === 'appro');
html += this.item('inventaire', 'inventory_2', 'Inventaire', activeView === 'inventaire');
html += this.item('workflows', 'account_tree', 'Workflows', activeView === 'workflows');
html += `</div>`;
html += `</div>`; // end sb-nav
// ── Footer ──
html += `
<div class="sb-footer">
${this.item('parametres', 'settings', 'Param\u00e8tres', activeView === 'parametres')}
<div class="sb-version">
<span class="material-symbols-outlined">info</span>
<span id="sb-ver">v\u2014</span>
</div>
</div>
`;
this.el.innerHTML = html;
this.bindEvents();
this.fetchAndInjectVersion();
}
private item(viewId: string, icon: string, label: string, active: boolean): string {
return `<button class="sb-item${active ? ' active' : ''}" data-view="${viewId}">` +
`<span class="sb-item-icon material-symbols-outlined">${icon}</span>` +
`<span class="sb-item-text">${esc(label)}</span>` +
`</button>`;
}
private bindEvents(): void {
if (!this.el) return;
this.el.querySelectorAll<HTMLElement>('.sb-item[data-view]').forEach(btn => {
btn.addEventListener('click', () => {
const view = btn.dataset['view']!;
const viewValue: string | number = /^\d+$/.test(view) ? Number(view) : view;
this.uiState.setActiveView(viewValue);
});
});
}
private fetchAndInjectVersion(): void {
const api = (window as any).electronAPI;
if (!api?.getVersion) return;
api.getVersion().then((v: string) => {
const verEl = this.el?.querySelector('#sb-ver');
if (verEl) verEl.textContent = `v${v}`;
});
}
destroy(): void {
this.el?.remove();
this.el = null;
}
}

View File

@ -0,0 +1,490 @@
import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import type { StatisticsResult, KpiDelta } from '@application/queries/GetStatistics';
import { TOTAL_RACES } from '@application/queries/GetStatistics';
import { GEN_COLORS, raceColor } from '@domain/value-objects/Race';
import { getDDImage } from '@presentation/helpers/dd-image';
import { esc } from '@presentation/helpers/format';
const PERIOD_OPTIONS = [
{ days: 7, label: '7 jours' },
{ days: 14, label: '14 jours' },
{ days: 30, label: '30 jours' },
{ days: 90, label: '3 mois' },
{ days: 0, label: 'Tout' },
];
export class StatistiquesView {
private el: HTMLElement | null = null;
private selectedDays = 30;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
) {}
render(container: HTMLElement): void {
this.el = document.createElement('div');
this.el.className = 'stats-view';
container.appendChild(this.el);
this.renderAll();
}
update(): void {}
destroy(): void {
this.el?.remove();
this.el = null;
}
private getData(): StatisticsResult {
return this.queryBus.execute<StatisticsResult>({ type: 'get-statistics', days: this.selectedDays });
}
private renderAll(): void {
if (!this.el) return;
const data = this.getData();
let html = '';
// ── Hero + Filtres ──────────────────────────────────────────
html += `<div class="stats-hero">
<div>
<h2 class="stats-hero-title">Statistiques d'\u00c9levage</h2>
<p class="stats-hero-sub">Aperçu analytique des performances de votre archive.</p>
</div>
<div class="stats-period-chips">`;
for (const opt of PERIOD_OPTIONS) {
const active = this.selectedDays === opt.days ? ' active' : '';
html += `<button class="stats-period-chip${active}" data-days="${opt.days}">${esc(opt.label)}</button>`;
}
html += `</div></div>`;
// ── KPIs ────────────────────────────────────────────────────
html += `<div class="stats-kpi-grid">`;
html += this.renderKpi('Total Naissances', data.totalBabies, 'egg_alt');
html += this.renderKpi('Taux de R\u00e9ussite', data.successRate, 'trending_up', '%');
html += this.renderKpi('Couples Form\u00e9s', data.totalCouples, 'favorite');
html += this.renderKpiRaces(data.racesCount);
html += `</div>`;
// ── Graphique naissances + Donut ─────────────────────────────
html += `<div class="stats-two-col">`;
html += this.renderBarChart(data);
html += this.renderRaceDistribution(data);
html += `</div>`;
// ── Répartition par génération + Activité semaine ────────────
html += `<div class="stats-two-col">`;
html += this.renderGenBreakdown(data);
html += this.renderWeekdayActivity(data);
html += `</div>`;
// ── Détail par race ──────────────────────────────────────────
html += this.renderRaceDetail(data);
// ── Taux de réussite par race ────────────────────────────────
html += this.renderRaceSuccessRates(data);
// ── Meilleurs couples ────────────────────────────────────────
html += this.renderBestCouples(data);
// ── Races manquantes (toujours en dernier) ───────────────────
html += this.renderMissingRaces(data);
this.el.innerHTML = html;
this.bindEvents();
}
// ── KPIs ──────────────────────────────────────────────────────
private renderKpi(label: string, kpi: KpiDelta, icon: string, suffix = ''): string {
return `<div class="stats-kpi-card">
<div class="stats-kpi-icon-wrap">
<span class="material-symbols-outlined stats-kpi-icon">${icon}</span>
</div>
<p class="stats-kpi-label">${label}</p>
<div class="stats-kpi-row">
<span class="stats-kpi-value">${kpi.value}${suffix}</span>
${this.deltaTag(kpi.delta, suffix)}
</div>
</div>`;
}
private renderKpiRaces(kpi: KpiDelta): string {
return `<div class="stats-kpi-card">
<div class="stats-kpi-icon-wrap">
<span class="material-symbols-outlined stats-kpi-icon">pets</span>
</div>
<p class="stats-kpi-label">Races Obtenues</p>
<div class="stats-kpi-row">
<span class="stats-kpi-value">${kpi.value}<span class="stats-kpi-total">/${TOTAL_RACES}</span></span>
${this.deltaTag(kpi.delta)}
</div>
</div>`;
}
private deltaTag(delta: number | null, suffix = ''): string {
if (delta === null) return '';
if (delta === 0) return `<span class="stats-delta stats-delta-neutral">= 0${suffix}</span>`;
const sign = delta > 0 ? '+' : '';
const cls = delta > 0 ? 'stats-delta-up' : 'stats-delta-down';
return `<span class="stats-delta ${cls}">${sign}${delta}${suffix}</span>`;
}
// ── Graphique barres ──────────────────────────────────────────
private renderBarChart(data: StatisticsResult): string {
const maxCount = Math.max(...data.dailyBirths.map(d => d.count), 1);
const hasData = data.dailyBirths.some(d => d.count > 0);
const periodLabel = this.selectedDays === 0 ? '30 DERNIERS JOURS' : `${this.selectedDays} DERNIERS JOURS`;
let html = `<div class="stats-chart-panel">
<div class="stats-chart-header">
<h3 class="stats-chart-title">\u00c9volution des Naissances</h3>
<span class="stats-chart-badge">${periodLabel}</span>
</div>`;
if (!hasData) {
html += `<div class="stats-chart-empty">
<span class="material-symbols-outlined" style="font-size:40px;color:var(--md-outline-variant)">bar_chart</span>
<p>Aucune naissance sur cette p\u00e9riode</p>
</div>`;
} else {
html += `<div class="stats-chart-area">`;
html += `<div class="stats-chart-y-axis">
<span>${maxCount}</span>
<span>${Math.round(maxCount / 2)}</span>
<span>0</span>
</div>`;
html += `<div class="stats-chart-bars">`;
for (const day of data.dailyBirths) {
const pct = (day.count / maxCount) * 100;
const isToday = day === data.dailyBirths[data.dailyBirths.length - 1];
html += `<div class="stats-bar-col">
<div class="stats-tooltip">${day.label}<br><strong>${day.count}</strong> naissance${day.count !== 1 ? 's' : ''}</div>
<div class="stats-bar${isToday ? ' stats-bar-today' : ''}" style="height:${Math.max(pct, day.count > 0 ? 4 : 0)}%"></div>
</div>`;
}
html += `</div>`;
const step = Math.max(1, Math.floor(data.dailyBirths.length / 6));
html += `<div class="stats-chart-x-axis">`;
for (let i = 0; i < data.dailyBirths.length; i++) {
if (i % step === 0 || i === data.dailyBirths.length - 1) {
html += `<span>${data.dailyBirths[i].label}</span>`;
}
}
html += `</div></div>`;
}
html += `</div>`;
return html;
}
// ── Donut races ───────────────────────────────────────────────
private renderRaceDistribution(data: StatisticsResult): string {
const top = data.raceShares.slice(0, 8);
let html = `<div class="stats-race-panel">
<h3 class="stats-chart-title">R\u00e9partition des Races</h3>`;
if (top.length === 0) {
html += `<div class="stats-chart-empty">
<span class="material-symbols-outlined" style="font-size:40px;color:var(--md-outline-variant)">donut_large</span>
<p>Aucune race enregistr\u00e9e</p>
</div>`;
} else {
html += `<div class="stats-donut-wrap">
<div class="stats-donut-center">
<span class="stats-donut-num">${data.racesCount.value}</span>
<span class="stats-donut-label">RACES</span>
</div>
${this.renderDonutSVG(top)}
</div>`;
html += `<div class="stats-race-legend">`;
for (const r of top) {
const col = raceColor(r.race);
html += `<div class="stats-race-legend-row">
<div class="stats-race-legend-left">
<span class="stats-race-dot" style="background:${esc(col)}"></span>
<span class="stats-race-name">${esc(r.race)}</span>
</div>
<div class="stats-race-legend-right">
<span class="stats-race-count">${r.count}</span>
<span class="stats-race-pct">${r.pct}%</span>
</div>
</div>`;
}
if (data.raceShares.length > 8) {
const otherCount = data.raceShares.slice(8).reduce((s, r) => s + r.count, 0);
const otherPct = data.totalBabies.value > 0 ? Math.round((otherCount / data.totalBabies.value) * 100) : 0;
html += `<div class="stats-race-legend-row">
<div class="stats-race-legend-left">
<span class="stats-race-dot" style="background:var(--md-outline-variant)"></span>
<span class="stats-race-name">Autres (${data.raceShares.length - 8})</span>
</div>
<div class="stats-race-legend-right">
<span class="stats-race-count">${otherCount}</span>
<span class="stats-race-pct">${otherPct}%</span>
</div>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
return html;
}
private renderDonutSVG(shares: { race: string; pct: number; count: number }[]): string {
const r = 60, circ = 2 * Math.PI * r, gap = 2;
let offset = 0;
let arcs = '';
for (const s of shares) {
const col = raceColor(s.race);
const len = Math.max((s.pct / 100) * circ - gap, 0);
arcs += `<circle class="stats-donut-arc" cx="75" cy="75" r="${r}" fill="none"
stroke="${col}" stroke-width="14"
stroke-dasharray="${len} ${circ - len}" stroke-dashoffset="${-offset}"
stroke-linecap="round" transform="rotate(-90 75 75)">
<title>${esc(s.race)} ${s.count} (${s.pct}%)</title>
</circle>`;
offset += len + gap;
}
return `<svg class="stats-donut-svg" viewBox="0 0 150 150" width="150" height="150">${arcs}</svg>`;
}
// ── Répartition par génération ─────────────────────────────────
private renderGenBreakdown(data: StatisticsResult): string {
let html = `<div class="stats-chart-panel">
<div class="stats-chart-header">
<h3 class="stats-chart-title">Naissances par G\u00e9n\u00e9ration</h3>
</div>`;
if (data.genBreakdown.length === 0) {
html += `<div class="stats-chart-empty">
<span class="material-symbols-outlined" style="font-size:40px;color:var(--md-outline-variant)">genetics</span>
<p>Aucune donn\u00e9e</p>
</div>`;
} else {
const maxBabies = Math.max(...data.genBreakdown.map(g => g.babies), 1);
html += `<div class="stats-gen-bars">`;
for (const g of data.genBreakdown) {
const col = GEN_COLORS[g.gen] ?? '#888';
const pct = (g.babies / maxBabies) * 100;
const rate = g.couples > 0 ? Math.round((g.babies / g.couples) * 100) : 0;
html += `<div class="stats-gen-row">
<span class="stats-gen-label" style="color:${esc(col)}">Gen ${g.gen}</span>
<div class="stats-gen-bar-wrap">
<div class="stats-gen-bar" style="width:${pct}%;background:${esc(col)}"></div>
</div>
<div class="stats-gen-info">
<span class="stats-gen-count">${g.babies}</span>
<span class="stats-gen-rate">${rate}%</span>
<span class="stats-gen-races">${g.races} race${g.races > 1 ? 's' : ''}</span>
</div>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
return html;
}
// ── Activité par jour de la semaine ────────────────────────────
private renderWeekdayActivity(data: StatisticsResult): string {
const maxCount = Math.max(...data.weekdayActivity.map(d => d.count), 1);
const hasData = data.weekdayActivity.some(d => d.count > 0);
let html = `<div class="stats-race-panel">
<h3 class="stats-chart-title">Activit\u00e9 par Jour</h3>`;
if (!hasData) {
html += `<div class="stats-chart-empty">
<span class="material-symbols-outlined" style="font-size:40px;color:var(--md-outline-variant)">calendar_month</span>
<p>Aucune activit\u00e9</p>
</div>`;
} else {
html += `<div class="stats-weekday-grid">`;
for (const w of data.weekdayActivity) {
const pct = (w.count / maxCount) * 100;
const intensity = Math.min(pct / 100, 1);
const bg = `rgba(157, 120, 255, ${0.1 + intensity * 0.6})`;
html += `<div class="stats-weekday-item">
<span class="stats-weekday-name">${w.day.slice(0, 3)}</span>
<div class="stats-weekday-block" style="background:${bg}" title="${w.day} : ${w.count} naissance${w.count !== 1 ? 's' : ''}">
<span class="stats-weekday-count">${w.count}</span>
</div>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
return html;
}
// ── Taux de réussite par race ──────────────────────────────────
private renderRaceSuccessRates(data: StatisticsResult): string {
if (data.raceSuccessRates.length === 0) return '';
let html = `<div class="stats-history-panel">
<div class="stats-chart-header">
<h3 class="stats-chart-title">Taux de R\u00e9ussite par Race</h3>
<span class="stats-chart-badge">${data.raceSuccessRates.length} RACES</span>
</div>
<div class="stats-success-grid">`;
for (const r of data.raceSuccessRates) {
const col = raceColor(r.race);
const rateClass = r.rate >= 80 ? 'stats-rate-high' : r.rate >= 50 ? 'stats-rate-mid' : 'stats-rate-low';
html += `<div class="stats-success-row">
<div class="stats-success-race">
<div class="stats-success-avatar">${getDDImage(r.race)}</div>
<span class="stats-success-name" style="color:${esc(col)}">${esc(r.race)}</span>
</div>
<div class="stats-success-bar-wrap">
<div class="stats-success-bar" style="width:${r.rate}%;background:${esc(col)}"></div>
</div>
<span class="stats-success-rate ${rateClass}">${r.rate}%</span>
<span class="stats-success-detail">${r.babies}/${r.couples}</span>
</div>`;
}
html += `</div></div>`;
return html;
}
// ── Meilleurs couples ──────────────────────────────────────────
private renderBestCouples(data: StatisticsResult): string {
if (data.bestCouples.length === 0) return '';
let html = `<div class="stats-history-panel">
<div class="stats-chart-header">
<h3 class="stats-chart-title">Meilleurs Couples</h3>
<span class="stats-chart-badge">TOP ${data.bestCouples.length}</span>
</div>
<div class="stats-couples-grid">`;
for (let i = 0; i < data.bestCouples.length; i++) {
const c = data.bestCouples[i];
const babyCol = raceColor(c.baby);
const rateClass = c.rate >= 80 ? 'stats-rate-high' : c.rate >= 50 ? 'stats-rate-mid' : 'stats-rate-low';
html += `<div class="stats-couple-row">
<span class="stats-couple-rank">#${i + 1}</span>
<div class="stats-couple-parents">
<div class="stats-couple-av">${getDDImage(c.parentA)}</div>
<span class="material-symbols-outlined" style="font-size:14px;color:var(--md-outline-variant)">add</span>
<div class="stats-couple-av">${getDDImage(c.parentB)}</div>
</div>
<span class="material-symbols-outlined" style="font-size:20px;color:var(--md-primary)">arrow_forward</span>
<div class="stats-couple-baby">
<div class="stats-couple-baby-av">${getDDImage(c.baby)}</div>
<span style="color:${esc(babyCol)};font-weight:600;font-size:13px">${esc(c.baby)}</span>
</div>
<span class="stats-couple-rate ${rateClass}">${c.rate}%</span>
<span class="stats-couple-detail">${c.babies}/${c.couples}</span>
</div>`;
}
html += `</div></div>`;
return html;
}
// ── Races manquantes ───────────────────────────────────────────
private renderMissingRaces(data: StatisticsResult): string {
if (data.missingRaces.length === 0) {
return `<div class="stats-history-panel">
<div class="stats-chart-header">
<h3 class="stats-chart-title">Races Manquantes</h3>
<span class="stats-chart-badge stats-badge-success">COMPLET !</span>
</div>
<div class="stats-chart-empty">
<span class="material-symbols-outlined" style="font-size:48px;color:#22c55e">check_circle</span>
<p style="color:#22c55e;font-weight:600">Toutes les ${TOTAL_RACES} races ont \u00e9t\u00e9 obtenues !</p>
</div>
</div>`;
}
// Grouper par génération
const byGen = new Map<number, typeof data.missingRaces>();
for (const r of data.missingRaces) {
if (!byGen.has(r.gen)) byGen.set(r.gen, []);
byGen.get(r.gen)!.push(r);
}
let html = `<div class="stats-history-panel">
<div class="stats-chart-header">
<h3 class="stats-chart-title">Races Manquantes</h3>
<span class="stats-chart-badge">${data.missingRaces.length} / ${TOTAL_RACES} RESTANTES</span>
</div>
<div class="stats-missing-list">`;
for (const [gen, races] of Array.from(byGen.entries()).sort((a, b) => a[0] - b[0])) {
const genCol = GEN_COLORS[gen] ?? '#888';
html += `<div class="stats-missing-gen">
<span class="stats-missing-gen-badge" style="background:${esc(genCol)}">Gen ${gen}</span>
<div class="stats-missing-races">`;
for (const r of races) {
const col = raceColor(r.name);
html += `<div class="stats-missing-card">
<div class="stats-missing-av">${getDDImage(r.name)}</div>
<span class="stats-missing-name" style="color:${esc(col)}">${esc(r.name)}</span>
</div>`;
}
html += `</div></div>`;
}
html += `</div></div>`;
return html;
}
// ── Détail par race (barres horizontales) ──────────────────────
private renderRaceDetail(data: StatisticsResult): string {
if (data.raceShares.length === 0) return '';
const maxCount = data.raceShares[0].count;
let html = `<div class="stats-history-panel">
<div class="stats-chart-header">
<h3 class="stats-chart-title">D\u00e9tail par Race</h3>
<span class="stats-chart-badge">${data.raceShares.length} / ${TOTAL_RACES} RACES</span>
</div>
<div class="stats-race-bars">`;
for (const r of data.raceShares) {
const col = raceColor(r.race);
const pct = maxCount > 0 ? (r.count / maxCount) * 100 : 0;
html += `<div class="stats-race-bar-row">
<span class="stats-race-bar-name" style="color:${esc(col)}">${esc(r.race)}</span>
<div class="stats-race-bar-track">
<div class="stats-race-bar-fill" style="width:${pct}%;background:${esc(col)}"></div>
</div>
<span class="stats-race-bar-count">${r.count}</span>
</div>`;
}
html += `</div></div>`;
return html;
}
// ── Events ────────────────────────────────────────────────────
private bindEvents(): void {
if (!this.el) return;
this.el.querySelectorAll<HTMLElement>('.stats-period-chip').forEach(chip => {
chip.addEventListener('click', () => {
this.selectedDays = Number(chip.dataset['days']);
this.renderAll();
});
});
}
}

View File

@ -0,0 +1,97 @@
export type ToastType = 'success' | 'error';
interface ToastAction {
label: string;
callback: () => void;
}
interface ToastItem {
id: number;
type: ToastType;
message: string;
}
const ICON: Record<ToastType, string> = {
success: 'check_circle',
error: 'error',
};
const DURATION: Record<ToastType, number> = {
success: 3000,
error: 5000,
};
const DURATION_WITH_ACTION = 10_000;
const MAX_VISIBLE = 3;
let nextId = 0;
let container: HTMLElement | null = null;
const items: ToastItem[] = [];
export const Toast = {
mount(parent: HTMLElement): void {
container = document.createElement('div');
container.id = 'toast-container';
parent.appendChild(container);
},
show(type: ToastType, message: string, action?: ToastAction): void {
if (!container) return;
const id = nextId++;
items.push({ id, type, message });
// Evincer les plus anciens si > MAX_VISIBLE
while (items.length > MAX_VISIBLE) {
const old = items.shift()!;
const oldEl = container.querySelector(`[data-toast-id="${old.id}"]`);
if (oldEl) oldEl.remove();
}
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.dataset['toastId'] = String(id);
const iconSpan = document.createElement('span');
iconSpan.className = 'toast-icon material-symbols-outlined';
iconSpan.textContent = ICON[type];
const msgSpan = document.createElement('span');
msgSpan.className = 'toast-msg';
msgSpan.textContent = message;
el.appendChild(iconSpan);
el.appendChild(msgSpan);
if (action) {
const btn = document.createElement('button');
btn.className = 'toast-action';
btn.textContent = action.label;
btn.addEventListener('click', () => {
action.callback();
cleanup();
}, { once: true });
el.appendChild(btn);
}
container.appendChild(el);
// Trigger animation
requestAnimationFrame(() => el.classList.add('toast-visible'));
const cleanup = () => {
if (!el.parentNode) return;
el.remove();
const idx = items.findIndex(i => i.id === id);
if (idx !== -1) items.splice(idx, 1);
};
const duration = action ? DURATION_WITH_ACTION : DURATION[type];
setTimeout(() => {
el.classList.remove('toast-visible');
el.classList.add('toast-exit');
el.addEventListener('animationend', cleanup, { once: true });
// Fallback si animationend ne se declenche pas
setTimeout(cleanup, 500);
}, duration);
},
};

View File

@ -0,0 +1,118 @@
declare const window: Window & {
electronAPI?: {
onUpdateAvailable?: (cb: (info: { version: string }) => void) => void;
onUpdateDownloading?: (cb: (info: any) => void) => void;
onUpdateProgress?: (cb: (progress: { percent: number }) => void) => void;
onUpdateReady?: (cb: () => void) => void;
onUpdateError?: (cb: (err: { message: string }) => void) => void;
installUpdate?: () => void;
};
};
export class UpdateBanner {
private el: HTMLElement | null = null;
private state: 'hidden' | 'available' | 'downloading' | 'ready' | 'error' = 'hidden';
private progressPercent = 0;
private version = '';
private errorMsg = '';
render(container: HTMLElement): void {
this.el = document.createElement('div');
this.el.className = 'update-banner-new';
container.appendChild(this.el);
this.bindElectronEvents();
}
private bindElectronEvents(): void {
const api = window.electronAPI;
if (!api) return;
api.onUpdateAvailable?.((info) => {
this.state = 'available';
this.version = info.version;
this.updateDisplay();
});
api.onUpdateDownloading?.(() => {
this.state = 'downloading';
this.progressPercent = 0;
this.updateDisplay();
});
api.onUpdateProgress?.((progress) => {
this.state = 'downloading';
this.progressPercent = Math.round(progress.percent);
this.updateDisplay();
});
api.onUpdateReady?.(() => {
this.state = 'ready';
// version was set in onUpdateAvailable
this.updateDisplay();
});
api.onUpdateError?.((err) => {
this.state = 'error';
this.errorMsg = err.message;
this.updateDisplay();
});
}
private updateDisplay(): void {
if (!this.el) return;
this.el.className = `update-banner-new ${this.state}`;
switch (this.state) {
case 'hidden':
this.el.innerHTML = '';
return;
case 'available':
this.el.innerHTML =
`<span class="upd-dot"></span>` +
`<span class="upd-text">Mise \u00e0 jour disponible\u00a0: <strong>v${this.escHtml(this.version)}</strong> — T\u00e9l\u00e9chargement en cours…</span>`;
break;
case 'downloading':
this.el.innerHTML =
`<span class="upd-dot"></span>` +
`<span class="upd-text">T\u00e9l\u00e9chargement de la mise \u00e0 jour…</span>` +
`<div class="upd-bar-wrap"><div class="upd-bar-fill" style="width:${this.progressPercent}%"></div></div>` +
`<span class="upd-percent">${this.progressPercent}\u00a0%</span>`;
break;
case 'ready': {
this.el.innerHTML =
`<span class="upd-dot"></span>` +
`<span class="upd-text">Mise \u00e0 jour <strong>v${this.escHtml(this.version)}</strong> pr\u00eate !</span>` +
`<button class="upd-install-btn" id="upd-install-btn">Installer et red\u00e9marrer</button>`;
const btn = this.el.querySelector('#upd-install-btn') as HTMLElement | null;
btn?.addEventListener('click', () => window.electronAPI?.installUpdate?.());
break;
}
case 'error':
this.el.innerHTML =
`<span class="upd-dot"></span>` +
`<span class="upd-text">Erreur de mise \u00e0 jour\u00a0: ${this.escHtml(this.errorMsg)}</span>` +
`<button class="upd-dismiss-btn" id="upd-dismiss-btn">\u2715</button>`;
const dismissBtn = this.el.querySelector('#upd-dismiss-btn') as HTMLElement | null;
dismissBtn?.addEventListener('click', () => {
this.state = 'hidden';
this.updateDisplay();
});
break;
}
}
private escHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
destroy(): void {
this.el?.remove();
this.el = null;
}
}

View File

@ -0,0 +1,693 @@
import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import type { UIState } from '@presentation/state/UIState';
import type { WorkflowItem } from '@application/queries/GetWorkflows';
import type { ImportWorkflowsCommand } from '@application/commands/ImportWorkflows';
import { GEN_COLORS, RACE_GEN, raceColor } from '@domain/value-objects/Race';
import { getDDImage } from '@presentation/helpers/dd-image';
import { esc } from '@presentation/helpers/format';
import { Toast } from './Toast';
import { ConfirmModal } from './ConfirmModal';
import { UndoManager } from '@presentation/services/UndoManager';
interface WorkflowProgress {
done: number;
total: number;
pct: number;
}
function getProgress(wf: WorkflowItem): WorkflowProgress {
let done = 0;
let total = 0;
wf.materials.forEach(m => {
total += m.needed;
done += Math.min(m.done, m.needed);
});
wf.steps.forEach(st =>
st.crossings.forEach(cr => {
total += cr.needed;
done += Math.min(cr.done, cr.needed);
}),
);
return total > 0 ? { done, total, pct: Math.round((done / total) * 100) } : { done: 0, total: 0, pct: 0 };
}
function formatDate(ts: number): string {
const d = new Date(ts);
const now = new Date();
const diff = now.getTime() - d.getTime();
const days = Math.floor(diff / 86400000);
if (days === 0) return "Aujourd'hui";
if (days === 1) return 'Hier';
if (days < 7) return `Il y a ${days} jours`;
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' });
}
export class WorkflowsView {
private el: HTMLElement | null = null;
private detailId: number | null = null;
private genFilter = 0;
private search = '';
private dirty = true;
private exportMode = false;
private selectedIds = new Set<number>();
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
private uiState: UIState,
) {}
render(container: HTMLElement): void {
this.el = document.createElement('div');
this.el.className = 'wf-view-new';
container.appendChild(this.el);
this.updateDOM();
}
update(): void {
if (!this.dirty) return;
this.dirty = false;
this.updateDOM();
}
destroy(): void {
this.el?.remove();
this.el = null;
}
private getWorkflows(): WorkflowItem[] {
return this.queryBus.execute<WorkflowItem[]>({ type: 'get-workflows' });
}
private updateDOM(): void {
if (!this.el) return;
if (this.detailId !== null) {
this.renderDetail(this.detailId);
} else {
this.renderList();
}
}
// ── List view ──────────────────────────────────────────────────
private renderList(): void {
if (!this.el) return;
const allWorkflows = this.getWorkflows();
const q = this.search.trim().toLowerCase();
const filtered = allWorkflows.filter(wf => {
const gen = RACE_GEN[wf.target] ?? 0;
const matchGen = this.genFilter === 0 || gen === this.genFilter;
const matchSearch = !q || wf.target.toLowerCase().includes(q) || wf.name.toLowerCase().includes(q);
return matchGen && matchSearch;
});
let html = '';
/* Header */
if (this.exportMode) {
const allSelected = filtered.length > 0 && filtered.every(wf => this.selectedIds.has(wf.id));
html += `<div class="wf-export-bar">
<div class="wf-export-bar-left">
<button class="wf-io-btn" id="wf-export-cancel">
<span class="material-symbols-outlined" style="font-size:18px">close</span>
Annuler
</button>
<label class="wf-select-all-label">
<input type="checkbox" id="wf-select-all" ${allSelected ? 'checked' : ''}>
<span>Tout sélectionner</span>
</label>
<span class="wf-export-count">${this.selectedIds.size} sélectionné${this.selectedIds.size > 1 ? 's' : ''}</span>
</div>
<button class="wf-io-btn wf-export-confirm" id="wf-export-confirm"${this.selectedIds.size === 0 ? ' disabled' : ''}>
<span class="material-symbols-outlined" style="font-size:18px">download</span>
Exporter (${this.selectedIds.size})
</button>
</div>`;
} else {
html += `<div class="wf-list-header">
<div>
<h2 class="wf-list-title">Sommaire des Plans</h2>
<p class="wf-list-subtitle">Gérez et suivez l'évolution de vos plans de reproduction.</p>
</div>
<div class="wf-header-actions">
<button class="wf-io-btn" id="wf-import-btn" title="Importer des plans">
<span class="material-symbols-outlined" style="font-size:18px">upload</span>
Importer
</button>
<button class="wf-io-btn" id="wf-export-btn" title="Exporter les plans"${allWorkflows.length === 0 ? ' disabled' : ''}>
<span class="material-symbols-outlined" style="font-size:18px">download</span>
Exporter
</button>
<span class="wf-list-count">${allWorkflows.length} plan${allWorkflows.length > 1 ? 's' : ''} actif${allWorkflows.length > 1 ? 's' : ''}</span>
</div>
</div>`;
}
/* Search + Gen chips */
html += `<div class="inv-filters-row">
<div class="inv-search-col">
<div class="accoup-search-wrap">
<input class="accoup-search" id="wf-search-input" type="text"
placeholder="Rechercher un plan…" value="${esc(this.search)}" autocomplete="off">
${this.search ? `<button class="accoup-search-clear" title="Effacer"><span class="material-symbols-outlined" style="font-size:16px">close</span></button>` : ''}
</div>
</div>
<div class="inv-gen-col">
<div class="accoup-gen-chips" style="margin-bottom:0;border-bottom:none;padding-bottom:0">
<button class="accoup-gen-chip${this.genFilter === 0 ? ' active' : ''}" data-gen="0">Toutes</button>`;
for (let g = 1; g <= 10; g++) {
const hasWf = allWorkflows.some(wf => (RACE_GEN[wf.target] ?? 0) === g);
if (!hasWf) continue;
html += `<button class="accoup-gen-chip${this.genFilter === g ? ' active' : ''}" data-gen="${g}">Gen ${g}</button>`;
}
html += `</div>
</div>
</div>`;
/* Cards grid */
if (allWorkflows.length === 0) {
html += `<div class="wf-empty">
<span class="material-symbols-outlined" style="font-size:48px;color:var(--md-outline-variant)">assignment</span>
<p>Aucun plan sauvegardé</p>
<p style="font-size:12px;color:var(--md-on-surface-variant)">Créez un plan depuis l'onglet Appro ou Inventaire.</p>
</div>`;
} else if (filtered.length === 0) {
html += `<div class="accoup-empty">Aucun plan trouvé</div>`;
} else {
html += `<div class="wf-cards-grid">`;
for (const wf of filtered) {
html += this.renderWorkflowCard(wf);
}
html += `</div>`;
}
this.el.innerHTML = html;
this.bindListEvents();
if (this.search) {
const inp = this.el.querySelector<HTMLInputElement>('#wf-search-input');
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
}
}
private renderWorkflowCard(wf: WorkflowItem): string {
const prog = getProgress(wf);
const gen = RACE_GEN[wf.target] ?? 0;
const genColor = GEN_COLORS[gen] ?? '#888';
const col = raceColor(wf.target);
const isSelected = this.selectedIds.has(wf.id);
return `<div class="wf-card${this.exportMode ? ' wf-card-selectable' : ''}${isSelected ? ' wf-card-selected' : ''}" data-wf-id="${wf.id}">
${this.exportMode ? `<div class="wf-card-checkbox"><input type="checkbox" class="wf-card-check" data-wf-id="${wf.id}" ${isSelected ? 'checked' : ''}></div>` : ''}
<div class="wf-card-top">
<div class="wf-card-meta">
<span class="wf-card-gen" style="color:${esc(genColor)}">Génération ${gen}</span>
<h4 class="wf-card-name">${esc(wf.name)}</h4>
</div>
<div class="wf-card-avatar">
${getDDImage(wf.target)}
</div>
</div>
<div class="wf-card-progress">
<div class="wf-card-progress-row">
<span class="wf-card-progress-label">Progression Actuelle</span>
<span class="wf-card-progress-pct" style="color:${esc(col)}">${prog.pct}%</span>
</div>
<div class="wf-card-bar">
<div class="wf-card-bar-fill" style="width:${prog.pct}%;background:${esc(col)}"></div>
</div>
</div>
<div class="wf-card-footer">
<div class="wf-card-date">
<span class="material-symbols-outlined" style="font-size:14px">history</span>
Dernière modif : ${formatDate(wf.updatedAt ?? wf.createdAt)}
</div>
<div class="wf-card-actions">
<button class="wf-delete-btn" data-wf-id="${wf.id}" title="Supprimer">
<span class="material-symbols-outlined" style="font-size:16px">delete</span>
</button>
<div class="wf-card-arrow">
<span class="material-symbols-outlined" style="font-size:18px">arrow_forward</span>
</div>
</div>
</div>
</div>`;
}
private bindListEvents(): void {
if (!this.el) return;
// Search
const searchInput = this.el.querySelector<HTMLInputElement>('#wf-search-input');
if (searchInput) {
searchInput.addEventListener('input', () => { this.search = searchInput.value; this.dirty = true; this.update(); });
}
const clearBtn = this.el.querySelector('.accoup-search-clear');
if (clearBtn) {
clearBtn.addEventListener('click', () => { this.search = ''; this.dirty = true; this.update(); });
}
// Gen filter chips
this.el.querySelectorAll('.accoup-gen-chip[data-gen]').forEach(btn => {
btn.addEventListener('click', () => {
this.genFilter = parseInt((btn as HTMLElement).dataset.gen ?? '0');
this.dirty = true; this.update();
});
});
if (this.exportMode) {
// Export mode events
// Cancel export mode
const cancelBtn = this.el.querySelector('#wf-export-cancel');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
this.exportMode = false;
this.selectedIds.clear();
this.dirty = true; this.update();
});
}
// Select all
const selectAll = this.el.querySelector<HTMLInputElement>('#wf-select-all');
if (selectAll) {
selectAll.addEventListener('change', () => {
const allWorkflows = this.getWorkflows();
// Apply to filtered workflows only
const q = this.search.trim().toLowerCase();
const filtered = allWorkflows.filter(wf => {
const gen = RACE_GEN[wf.target] ?? 0;
const matchGen = this.genFilter === 0 || gen === this.genFilter;
const matchSearch = !q || wf.target.toLowerCase().includes(q) || wf.name.toLowerCase().includes(q);
return matchGen && matchSearch;
});
if (selectAll.checked) {
filtered.forEach(wf => this.selectedIds.add(wf.id));
} else {
filtered.forEach(wf => this.selectedIds.delete(wf.id));
}
this.dirty = true; this.update();
});
}
// Individual checkboxes (click anywhere on card toggles)
this.el.querySelectorAll('.wf-card').forEach(card => {
card.addEventListener('click', (e) => {
const id = Number((card as HTMLElement).dataset['wfId']);
if (this.selectedIds.has(id)) {
this.selectedIds.delete(id);
} else {
this.selectedIds.add(id);
}
this.dirty = true; this.update();
});
});
// Confirm export
const confirmBtn = this.el.querySelector('#wf-export-confirm');
if (confirmBtn) {
confirmBtn.addEventListener('click', () => this.exportWorkflows());
}
} else {
// Normal mode events
// Card click → open detail
this.el.querySelectorAll('.wf-card').forEach(card => {
card.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.closest('.wf-delete-btn')) return;
const id = Number((card as HTMLElement).dataset['wfId']);
this.detailId = id;
this.dirty = true; this.update();
});
});
// Export button → enter export mode
const exportBtn = this.el.querySelector('#wf-export-btn');
if (exportBtn) {
exportBtn.addEventListener('click', () => {
this.exportMode = true;
this.selectedIds.clear();
this.dirty = true; this.update();
});
}
// Import button
const importBtn = this.el.querySelector('#wf-import-btn');
if (importBtn) {
importBtn.addEventListener('click', () => this.importWorkflows());
}
// Delete buttons
this.el.querySelectorAll('.wf-delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const id = Number((btn as HTMLElement).dataset['wfId']);
const workflows = this.getWorkflows();
const wf = workflows.find(w => w.id === id);
const name = wf ? wf.name : String(id);
const ok = await ConfirmModal.show('Supprimer le plan', `Supprimer "${name}" ? Cette action est irréversible.`);
if (!ok) return;
const hasSnap = await UndoManager.snapshotCurrent('Suppression plan');
this.commandBus.execute({ type: 'delete-workflow', workflowId: id });
Toast.show('success', `Plan "${name}" supprimé.`, hasSnap ? { label: 'Annuler', callback: () => UndoManager.undo() } : undefined);
this.dirty = true; this.update();
});
});
}
}
// ── Detail view ───────────────────────────────────────────────
private renderDetail(id: number): void {
if (!this.el) return;
const workflows = this.getWorkflows();
const wf = workflows.find(w => w.id === id);
if (!wf) {
this.detailId = null;
this.dirty = true; this.update();
return;
}
const prog = getProgress(wf);
const gen = RACE_GEN[wf.target] ?? 0;
const genColor = GEN_COLORS[gen] ?? '#888';
const col = raceColor(wf.target);
let html = '';
/* Top bar: back + objective + progress */
html += `<div class="wf-detail-top">
<button class="reappro-back-btn" id="wf-back-btn">
<span class="material-symbols-outlined" style="font-size:18px">arrow_back</span>
Retour au planneur
</button>
<div class="wf-detail-objective">
<span class="material-symbols-outlined" style="font-size:18px;color:var(--md-primary)">timeline</span>
<span class="wf-detail-obj-text">Objectif : ${esc(wf.target)}</span>
</div>
<div class="wf-detail-progress-wrap">
<div class="wf-detail-progress-info">
<span class="wf-detail-progress-label">Progression Globale</span>
<span class="wf-detail-progress-pct" id="wf-global-pct">${prog.pct}%</span>
</div>
<div class="wf-detail-progress-bar">
<div class="wf-detail-progress-fill" id="wf-global-bar" style="width:${prog.pct}%"></div>
</div>
</div>
</div>`;
/* Overview card */
html += `<div class="wf-overview-card">
<div class="wf-overview-left">
<div class="wf-overview-avatar">
${getDDImage(wf.target)}
<span class="wf-overview-gen-badge" style="background:${esc(genColor)}">GEN ${gen}</span>
</div>
<div>
<h3 class="wf-overview-name">${wf.qty}x ${esc(wf.target)}</h3>
<p class="wf-overview-meta">Créé le ${new Date(wf.createdAt).toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })}</p>
</div>
</div>
<div class="wf-overview-stats">
<div class="wf-overview-stat">
<span class="wf-overview-stat-label">Naissances</span>
<span class="wf-overview-stat-value" id="wf-stat-births">${prog.done} / ${prog.total}</span>
</div>
</div>
</div>`;
/* Materials */
if (wf.materials.length > 0) {
const totalSteps = wf.steps.length + 1;
html += `<div class="wf-detail-step-header">
<span class="wf-detail-step-badge" style="background:var(--md-primary)">Étape 1/${totalSteps}</span>
<span class="wf-detail-step-label">Géniteurs de Base (Matières Premières)</span>
</div>`;
html += `<div class="wf-materials-grid">`;
wf.materials.forEach((mat, mIdx) => {
const matDone = Math.min(mat.done, mat.needed);
const matPct = mat.needed > 0 ? Math.round((matDone / mat.needed) * 100) : 0;
const matComplete = matDone >= mat.needed;
html += `<div class="wf-material-card${matComplete ? ' wf-material-complete' : ''}">
<div class="wf-material-avatar">
${getDDImage(mat.name)}
</div>
<div class="wf-material-info">
<div class="wf-material-name-row">
<p class="wf-material-name">${esc(mat.name)}</p>
${matComplete ? '<span class="material-symbols-outlined" style="font-size:16px;color:#22c55e">check_circle</span>' : ''}
</div>
<div class="wf-material-progress-row">
<span class="wf-material-need">Besoin: ${mat.needed}</span>
<div class="wf-material-bar-wrap">
<span class="wf-material-count" id="wf-mat-count-${mIdx}">${matDone} / ${mat.needed}</span>
<div class="wf-material-bar">
<div class="wf-material-bar-fill" id="wf-mat-bar-${mIdx}" style="width:${matPct}%"></div>
</div>
</div>
</div>
<input type="number" class="wf-mat-input" data-mat-idx="${mIdx}" data-max="${mat.needed}" min="0" max="${mat.needed}" value="${matDone}">
</div>
</div>`;
});
html += `</div>`;
/* Connector */
html += `<div class="wf-step-connector"></div>`;
}
/* Step cards (crossings by gen) */
const totalSteps = wf.steps.length + (wf.materials.length > 0 ? 1 : 0);
wf.steps.forEach((step, sIdx) => {
const stepNum = sIdx + (wf.materials.length > 0 ? 2 : 1);
const stepGenColor = GEN_COLORS[step.gen] ?? '#888';
html += `<div class="wf-detail-step-header">
<span class="wf-detail-step-badge" style="background:${esc(stepGenColor)}">Étape ${stepNum}/${totalSteps}</span>
<span class="wf-detail-step-label">Croisements Génération ${step.gen}</span>
</div>`;
html += `<div class="wf-crossings-list">`;
step.crossings.forEach((cr, cIdx) => {
const crDone = Math.min(cr.done, cr.needed);
const crPct = cr.needed > 0 ? Math.round((crDone / cr.needed) * 100) : 0;
const crCol = raceColor(cr.race);
const babyGen = RACE_GEN[cr.race] ?? 0;
const babyGenCol = GEN_COLORS[babyGen] ?? '#888';
html += `<div class="wf-crossing-card">
<div class="wf-crossing-row">
<div class="wf-crossing-parents">
<div class="wf-crossing-parent-av">
${getDDImage(cr.parentA)}
</div>
<span class="material-symbols-outlined" style="font-size:16px;color:var(--md-outline-variant)">add</span>
<div class="wf-crossing-parent-av">
${getDDImage(cr.parentB)}
</div>
</div>
<span class="material-symbols-outlined" style="font-size:28px;color:var(--md-primary)">double_arrow</span>
<div class="wf-crossing-result">
<div class="wf-crossing-baby-av">
${getDDImage(cr.race)}
<span class="reappro-baby-gen-badge" style="background:${babyGenCol}">G${babyGen}</span>
</div>
<div class="wf-crossing-result-info">
<p class="wf-crossing-baby-name">${esc(cr.race)}</p>
<p class="wf-crossing-baby-obj">Objectif : ${cr.needed} réussi${cr.needed > 1 ? 's' : ''}</p>
<div class="wf-crossing-dots">
${Array.from({ length: cr.needed }, (_, i) =>
`<span class="wf-dot${i < crDone ? ' wf-dot-done' : ''}" style="${i < crDone ? `background:${esc(crCol)};box-shadow:0 0 8px ${esc(crCol)}60` : ''}"></span>`
).join('')}
<span class="wf-crossing-count" id="wf-cr-count-${sIdx}-${cIdx}" style="color:${esc(crCol)}">${crDone} / ${cr.needed}</span>
</div>
</div>
</div>
</div>
<input type="number" class="wf-crossing-input" data-step-idx="${sIdx}" data-crossing-idx="${cIdx}" data-max="${cr.needed}" min="0" max="${cr.needed}" value="${crDone}">
</div>`;
});
html += `</div>`;
/* Connector between steps */
if (sIdx < wf.steps.length - 1) {
html += `<div class="wf-step-connector"></div>`;
}
});
this.el.innerHTML = html;
this.bindDetailEvents(id);
}
private bindDetailEvents(wfId: number): void {
if (!this.el) return;
// Back button
const backBtn = this.el.querySelector('#wf-back-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
this.detailId = null;
this.dirty = true; this.update();
});
}
// Material inputs
this.el.querySelectorAll<HTMLInputElement>('.wf-mat-input').forEach(input => {
let prev = input.value;
input.addEventListener('focus', () => { prev = input.value; input.value = ''; });
input.addEventListener('input', () => {
if (input.value === '') return;
const mIdx = Number(input.dataset['matIdx']);
const max = Number(input.dataset['max']) || 0;
const val = Math.min(Math.max(0, Number(input.value) || 0), max);
const pct = max > 0 ? Math.round((val / max) * 100) : 0;
const bar = this.el?.querySelector<HTMLElement>(`#wf-mat-bar-${mIdx}`);
const cnt = this.el?.querySelector<HTMLElement>(`#wf-mat-count-${mIdx}`);
if (bar) bar.style.width = `${pct}%`;
if (cnt) cnt.textContent = `${val} / ${max}`;
this.refreshGlobalBar();
});
input.addEventListener('blur', () => {
if (input.value === '') { input.value = prev; this.refreshGlobalBar(); return; }
const mIdx = Number(input.dataset['matIdx']);
const done = Math.max(0, Number(input.value) || 0);
this.commandBus.execute({ type: 'update-workflow', workflowId: wfId, materialIdx: mIdx, done });
this.dirty = true; this.update();
});
});
// Crossing inputs
this.el.querySelectorAll<HTMLInputElement>('.wf-crossing-input').forEach(input => {
let prev = input.value;
input.addEventListener('focus', () => { prev = input.value; input.value = ''; });
input.addEventListener('input', () => {
if (input.value === '') return;
const sIdx = Number(input.dataset['stepIdx']);
const cIdx = Number(input.dataset['crossingIdx']);
const max = Number(input.dataset['max']) || 0;
const val = Math.min(Math.max(0, Number(input.value) || 0), max);
const cnt = this.el?.querySelector<HTMLElement>(`#wf-cr-count-${sIdx}-${cIdx}`);
if (cnt) cnt.textContent = `${val} / ${max}`;
this.refreshGlobalBar();
});
input.addEventListener('blur', () => {
if (input.value === '') { input.value = prev; this.refreshGlobalBar(); return; }
const sIdx = Number(input.dataset['stepIdx']);
const cIdx = Number(input.dataset['crossingIdx']);
const done = Math.max(0, Number(input.value) || 0);
this.commandBus.execute({ type: 'update-workflow', workflowId: wfId, stepIdx: sIdx, crossingIdx: cIdx, done });
this.dirty = true; this.update();
});
});
}
// ── Export / Import ─────────────────────────────────────────
private async exportWorkflows(): Promise<void> {
const allWorkflows = this.getWorkflows();
const toExport = allWorkflows.filter(wf => this.selectedIds.has(wf.id));
if (toExport.length === 0) return;
const data = JSON.stringify(toExport, null, 2);
const suffix = toExport.length === 1 ? toExport[0].target.toLowerCase().replace(/\s+/g, '-') : 'tous';
const defaultName = `plans-dragodinde-${suffix}-${new Date().toISOString().slice(0, 10)}.json`;
const api = (window as any).electronAPI;
if (api?.exportFile) {
const ok = await api.exportFile(data, defaultName);
if (ok) { Toast.show('success', 'Plans exportés avec succès.'); this.exportMode = false; this.selectedIds.clear(); this.dirty = true; this.update(); }
} else {
// Fallback navigateur : téléchargement via Blob
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = defaultName;
a.click();
URL.revokeObjectURL(url);
Toast.show('success', 'Plans exportés avec succès.');
this.exportMode = false; this.selectedIds.clear(); this.dirty = true; this.update();
}
}
private async importWorkflows(): Promise<void> {
const api = (window as any).electronAPI;
let raw: string | null = null;
if (api?.importFile) {
raw = await api.importFile();
} else {
// Fallback navigateur : input file
raw = await new Promise<string | null>(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.addEventListener('change', () => {
const file = input.files?.[0];
if (!file) { resolve(null); return; }
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => resolve(null);
reader.readAsText(file);
});
input.click();
});
}
if (!raw) return;
try {
const parsed = JSON.parse(raw);
const arr = Array.isArray(parsed) ? parsed : [parsed];
// Validation basique de la structure
const valid = arr.filter((item: any) =>
item && typeof item === 'object' &&
typeof item.target === 'string' &&
typeof item.qty === 'number' &&
Array.isArray(item.materials) &&
Array.isArray(item.steps),
) as WorkflowItem[];
if (valid.length === 0) {
Toast.show('error', 'Aucun plan valide trouvé dans le fichier.');
return;
}
this.commandBus.execute({
type: 'import-workflows',
workflows: valid,
} as ImportWorkflowsCommand);
Toast.show('success', `${valid.length} plan(s) importé(s) avec succès.`);
this.dirty = true;
this.update();
} catch {
Toast.show('error', 'Le fichier sélectionné n\'est pas un JSON valide.');
}
}
private refreshGlobalBar(): void {
if (!this.el) return;
let done = 0, total = 0;
this.el.querySelectorAll<HTMLInputElement>('.wf-mat-input, .wf-crossing-input').forEach(inp => {
const max = Number(inp.dataset['max']) || 0;
const val = Math.min(Math.max(0, Number(inp.value) || 0), max);
total += max;
done += val;
});
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
const bar = this.el.querySelector<HTMLElement>('#wf-global-bar');
const pctEl = this.el.querySelector<HTMLElement>('#wf-global-pct');
const births = this.el.querySelector<HTMLElement>('#wf-stat-births');
if (bar) bar.style.width = `${pct}%`;
if (pctEl) pctEl.textContent = `${pct}%`;
if (births) births.textContent = `${done} / ${total}`;
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,21 @@
/** Pad number to 2 digits */
export const pad = (n: number): string => String(Math.max(0, n)).padStart(2, '0');
/** Format seconds as human-readable duration: "1h 23m 45s" */
export const fmt = (s: number): string => {
if (!isFinite(s) || s < 0) return '—';
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = Math.floor(s % 60);
return h > 0 ? `${h}h ${pad(m)}m ${pad(ss)}s` : m > 0 ? `${m}m ${pad(ss)}s` : `${ss}s`;
};
/** Format seconds as clock: "01:23:45" */
export const fmtClock = (s: number): string => {
if (!isFinite(s) || isNaN(s) || s < 0) return '--:--:--';
return `${pad(Math.floor(s / 3600))}:${pad(Math.floor((s % 3600) / 60))}:${pad(Math.floor(s % 60))}`;
};
/** Escape HTML special characters */
export const esc = (s: string | number): string =>
String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

View File

@ -0,0 +1,272 @@
import type { Enclos } from '@domain/entities/Enclos';
import type { Dragodinde } from '@domain/entities/Dragodinde';
import type { GaugeType, GaugeDef } from '@domain/value-objects/GaugeType';
import { GAUGE_DEFS, STAT_DEFS } from '@domain/value-objects/GaugeType';
import { tierRate } from '@domain/value-objects/Tier';
import { elapsed, timeToGain, computeGaugeState } from '@domain/services/GaugeCalculator';
import type { GaugeRecharge } from '@domain/services/GaugeCalculator';
import { xpForLevel, levelFromXp } from '@domain/value-objects/XpTable';
export interface GaugeLiveResult {
estStat: number;
done: boolean;
progPct: number;
totalSec: number;
cntDown: number;
startGl: number;
curGl: number;
liveText: string;
deltaText: string;
}
/** Points jusqu'au cap absolu de la stat (gel de la jauge).
* Mangeoire : gel uniquement à niveau 200. La cible XP (< 200) ne gèle PAS la jauge. */
function ptsToAbsCap(startSt: number, def: GaugeDef): number {
if (def.isXp) return Math.max(0, xpForLevel(200) - xpForLevel(startSt));
const sd = STAT_DEFS[def.stat as keyof typeof STAT_DEFS];
return def.dir > 0 ? Math.max(0, sd.max - startSt) : Math.max(0, startSt - sd.min);
}
function rechargesFor(enc: Enclos, gid: GaugeType): GaugeRecharge[] {
return enc.timer.gaugeRecharges?.[gid] ?? [];
}
/**
* Temps écoulé "vivant" : utilisé pour TOUTES les jauges.
* Quand la session s'est terminée automatiquement (alerted.__done__),
* continue en temps réel pour que toutes les jauges se vident en fond.
* Une pause manuelle (running=false sans alerted.__done__) fige normalement.
*/
export function elapsedLive(enc: Enclos): number {
if (!enc.timer.startTime) return 0;
if (enc.alerted['__done__']) {
return Math.floor((Date.now() - enc.timer.startTime - enc.timer.pausedMs) / 1000);
}
return elapsed(enc.timer);
}
export function computeGaugeLive(enc: Enclos, dd: Dragodinde, gid: GaugeType, el: number, started: boolean): GaugeLiveResult {
const def = GAUGE_DEFS[gid];
const startGl = started ? (enc.timer.snapGauges[gid] ?? enc.gaugeLevels[gid]) : enc.gaugeLevels[gid];
const startSt = started
? (enc.timer.snapStats[dd.id]?.[def.stat] ?? dd.stats[def.stat as keyof typeof dd.stats])
: dd.stats[def.stat as keyof typeof dd.stats];
let target = dd.targets[gid];
if (def.isXp) target = dd.levelTarget ?? 200;
if (def.stat === 'serenite' && dd.sereniteTarget != null) target = dd.sereniteTarget;
// Toutes les jauges utilisent le temps réel si la session s'est terminée automatiquement
const elLive = started ? elapsedLive(enc) : 0;
const recharges = started ? rechargesFor(enc, gid) : [];
const pts = ptsToAbsCap(startSt as number, def);
const { gained, curGl } = started
? computeGaugeState(startGl, recharges, pts, elLive)
: { gained: 0, curGl: startGl };
// Quand la jauge est vide, le taux est 0 (plus aucun point gagné)
const curRate = curGl > 0 ? tierRate(curGl) : 0;
if (def.isXp) {
const startXP = xpForLevel(startSt as number);
const targetXP = xpForLevel(target);
const xpNeeded = Math.max(0, targetXP - startXP);
const xpGained = gained;
const xpRestante = Math.max(0, xpNeeded - xpGained);
const estLevel = levelFromXp(startXP + xpGained);
const done = estLevel >= target;
const progPct = xpNeeded > 0 ? Math.min(100, (xpGained / xpNeeded) * 100) : (done ? 100 : 0);
const totalSec = xpNeeded > 0 ? timeToGain(startGl, Math.min(xpNeeded, startGl)) : 0;
const cntDown = done ? 0 : (curGl > 0 ? Math.max(0, timeToGain(curGl, Math.min(xpRestante, curGl)) - (elLive % 10)) : Infinity);
const deltaText = curGl > 0 ? `+${curRate} xp` : 'Jauge vide';
return { estStat: estLevel, done, progPct, totalSec, cntDown, startGl, curGl,
liveText: `NIV. ${estLevel}`, deltaText };
} else {
const sd = STAT_DEFS[def.stat as keyof typeof STAT_DEFS];
const rawStat = (startSt as number) + def.dir * gained;
const estStat = Math.min(sd.max, Math.max(sd.min, rawStat));
const ptsNeeded = def.dir > 0 ? Math.max(0, target - (startSt as number)) : Math.max(0, (startSt as number) - target);
const totalSec = timeToGain(startGl, ptsNeeded);
const done = def.dir > 0 ? estStat >= target : estStat <= target;
// Countdown basé sur les points restants et le niveau de jauge actuel
const ptsRemaining = Math.max(0, ptsNeeded - gained);
const cntDown = done ? 0 : (curGl > 0 ? timeToGain(curGl, Math.min(ptsRemaining, curGl)) : Infinity);
const progPct = ptsNeeded > 0 ? Math.min(100, (Math.abs(estStat - (startSt as number)) / ptsNeeded) * 100) : (done ? 100 : 0);
const deltaText = curGl > 0 ? (def.dir > 0 ? `+${curRate}` : `-${curRate}`) : 'Jauge vide';
return { estStat, done, progPct, totalSec, cntDown, startGl, curGl,
liveText: Math.round(estStat).toLocaleString('fr'), deltaText };
}
}
/**
* Niveau actuel de la jauge pour l'affichage global de l'enclos.
* Utilise le temps réel si la session s'est terminée automatiquement.
*/
export function enclosGaugeCurGl(enc: Enclos, gid: GaugeType): number {
const startGl = enc.timer.snapGauges[gid] ?? enc.gaugeLevels[gid];
if (!enc.timer.startTime) return enc.gaugeLevels[gid];
const def = GAUGE_DEFS[gid];
const recharges = rechargesFor(enc, gid);
const elLive = elapsedLive(enc);
if (!enc.dragodindes.length) return computeGaugeState(startGl, recharges, Infinity, elLive).curGl;
let maxEffectiveEl = -1;
let resultCurGl = startGl;
for (const dd of enc.dragodindes) {
const startSt = (enc.timer.snapStats[dd.id]?.[def.stat] ?? dd.stats[def.stat as keyof typeof dd.stats]) as number;
const pts = ptsToAbsCap(startSt, def);
if (!isFinite(pts)) return computeGaugeState(startGl, recharges, Infinity, elLive).curGl;
const { curGl, effectiveEl } = computeGaugeState(startGl, recharges, pts, elLive);
if (effectiveEl > maxEffectiveEl) { maxEffectiveEl = effectiveEl; resultCurGl = curGl; }
}
return resultCurGl;
}
export interface EnclosGlobalStateResult {
globalMax: number;
allDone: boolean;
started: boolean;
el: number;
ddDone: number;
}
export function enclosGlobalState(enc: Enclos): EnclosGlobalStateResult {
const el = elapsed(enc.timer);
const started = !!enc.timer.startTime;
if (!enc.activeGauges.length || !enc.dragodindes.length)
return { globalMax: 0, allDone: false, started, el, ddDone: 0 };
let globalMax = 0, allEnclosDone = true, ddDone = 0;
enc.dragodindes.forEach(dd => {
let ddAllDone = true;
enc.activeGauges.forEach(gid => {
const r = computeGaugeLive(enc, dd, gid, el, started);
if (!r.done) {
ddAllDone = false;
allEnclosDone = false;
}
if (r.cntDown > globalMax) globalMax = r.cntDown;
});
if (ddAllDone) ddDone++;
});
return { globalMax, allDone: allEnclosDone, started, el, ddDone };
}
export function calcSerenEta(enc: Enclos, dd: Dragodinde): string {
const target = dd.sereniteTarget;
if (target === null || target === undefined) return '—';
const cur = dd.stats.serenite;
const diff = target - cur;
if (diff === 0) return '✅';
const needUp = diff > 0;
const gid: GaugeType = needUp ? 'caresseur' : 'baffeur';
if (!enc.activeGauges.includes(gid))
return needUp ? '<span class="eta-need" title="Activer le Caresseur"></span>' : '<span class="eta-need" title="Activer le Baffeur"></span>';
const gl = enc.gaugeLevels[gid] || 0;
const sec = timeToGain(gl, Math.abs(diff));
return sec === Infinity ? '∞' : '~' + fmtShort(sec);
}
export function calcSerenEtaLive(enc: Enclos, dd: Dragodinde, el: number, started: boolean): string {
const target = dd.sereniteTarget;
if (target === null || target === undefined) return '—';
const cur = dd.stats.serenite;
const diff = target - cur;
if (diff === 0) return '✅';
const needUp = diff > 0;
const gid: GaugeType = needUp ? 'caresseur' : 'baffeur';
if (!enc.activeGauges.includes(gid))
return needUp ? '<span class="eta-need" title="Activer le Caresseur"></span>' : '<span class="eta-need" title="Activer le Baffeur"></span>';
const startSt = started ? (enc.timer.snapStats[dd.id]?.['serenite'] ?? dd.stats.serenite) : dd.stats.serenite;
const startGl = started ? (enc.timer.snapGauges[gid] ?? enc.gaugeLevels[gid]) : enc.gaugeLevels[gid];
const def = GAUGE_DEFS[gid];
const recharges = started ? rechargesFor(enc, gid) : [];
const pts = ptsToAbsCap(startSt, def);
const elLive = started ? elapsedLive(enc) : 0;
const { gained, curGl } = started ? computeGaugeState(startGl, recharges, pts, elLive) : { gained: 0, curGl: startGl };
const curStat = Math.min(5000, Math.max(-5000, startSt + def.dir * gained));
if ((needUp && curStat >= target) || (!needUp && curStat <= target)) return '✅';
const remaining = Math.abs(target - curStat);
const sec = timeToGain(curGl, remaining);
return sec === Infinity ? '∞' : '~' + fmtShort(sec);
}
export function calcLevelEta(enc: Enclos, dd: Dragodinde): string {
const target = dd.levelTarget ?? 200;
if (dd.stats.xp >= target) return '✅';
if (!enc.activeGauges.includes('mangeoire')) return '<span class="eta-need" title="Activer la Mangeoire">🍖</span>';
const gl = enc.gaugeLevels.mangeoire || 0;
const xpNeeded = Math.max(0, xpForLevel(target) - xpForLevel(dd.stats.xp));
const sec = timeToGain(gl, xpNeeded);
return sec === Infinity ? '∞' : '~' + fmtShort(sec);
}
export function calcLevelEtaLive(enc: Enclos, dd: Dragodinde, el: number, started: boolean): string {
const target = dd.levelTarget ?? 200;
if (!enc.activeGauges.includes('mangeoire')) return '<span class="eta-need" title="Activer la Mangeoire">🍖</span>';
const startXp = started ? (enc.timer.snapStats[dd.id]?.['xp'] ?? dd.stats.xp) : dd.stats.xp;
const startGl = started ? (enc.timer.snapGauges['mangeoire'] ?? enc.gaugeLevels.mangeoire) : enc.gaugeLevels.mangeoire;
const def = GAUGE_DEFS['mangeoire'];
const recharges = started ? rechargesFor(enc, 'mangeoire') : [];
const pts = ptsToAbsCap(startXp, def);
const elLive = started ? elapsedLive(enc) : 0;
const { gained, curGl } = started ? computeGaugeState(startGl, recharges, pts, elLive) : { gained: 0, curGl: startGl };
const estLevel = levelFromXp(xpForLevel(startXp) + gained);
if (estLevel >= target) return '✅';
const xpRemaining = Math.max(0, xpForLevel(target) - xpForLevel(startXp) - gained);
const sec = timeToGain(curGl, Math.min(xpRemaining, curGl));
return sec === Infinity ? '∞' : '~' + fmtShort(sec);
}
/**
* Estimation du temps pour passer du niveau actuel au niveau 200,
* en supposant que la jauge reste au tier courant en continu.
* Retourne une chaîne formatée (~3j 8h, ~12h 30m, ~45m, , ).
*/
export function calcLevel200EtaLive(enc: Enclos, dd: Dragodinde, el: number, started: boolean): string {
if (!enc.activeGauges.includes('mangeoire')) return '';
const startXp = started ? (enc.timer.snapStats[dd.id]?.['xp'] ?? dd.stats.xp) : dd.stats.xp;
const startGl = started ? (enc.timer.snapGauges['mangeoire'] ?? enc.gaugeLevels.mangeoire) : enc.gaugeLevels.mangeoire;
const def = GAUGE_DEFS['mangeoire'];
const recharges = started ? rechargesFor(enc, 'mangeoire') : [];
const pts = ptsToAbsCap(startXp, def);
const elLive = started ? elapsedLive(enc) : 0;
const { gained, curGl } = started
? computeGaugeState(startGl, recharges, pts, elLive)
: { gained: 0, curGl: startGl };
const currentLevel = levelFromXp(xpForLevel(startXp) + gained);
if (currentLevel >= 200) return '✅';
const xpNeeded = Math.max(0, xpForLevel(200) - (xpForLevel(startXp) + gained));
const rate = tierRate(curGl); // pts par tick (10s), basé sur le tier courant
if (rate <= 0 || curGl <= 0) return '∞';
const seconds = Math.ceil(xpNeeded / rate) * 10;
return fmtEta200(seconds);
}
function fmtEta200(s: number): string {
if (!isFinite(s) || s <= 0) return '∞';
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d > 0) return `~${d}j ${h}h`;
if (h > 0) return `~${h}h ${String(m).padStart(2, '0')}m`;
return `~${m}m`;
}
function fmtShort(s: number): string {
if (!isFinite(s) || s < 0) return '—';
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = Math.floor(s % 60);
return h > 0 ? `${h}h ${String(m).padStart(2, '0')}m ${String(ss).padStart(2, '0')}s`
: m > 0 ? `${m}m ${String(ss).padStart(2, '0')}s` : `${ss}s`;
}

124
src/presentation/index.ts Normal file
View File

@ -0,0 +1,124 @@
import { CommandBus } from '@application/handlers/CommandBus';
import { QueryBus } from '@application/handlers/QueryBus';
import { EventBus } from '@domain/events/EventBus';
import { LocalStorageRepository } from '@infrastructure/persistence/LocalStorageRepository';
import { ElectronNotification } from '@infrastructure/notifications/ElectronNotification';
import { WebAudioAlarm } from '@infrastructure/alarm/WebAudioAlarm';
import { UIState } from '@presentation/state/UIState';
import { App } from '@presentation/components/App';
import type { AppState } from '@domain/ports/StateRepository';
// Command handler factories
import { createStartTimerHandler } from '@application/commands/StartTimer';
import { createStopTimerHandler } from '@application/commands/StopTimer';
import { createCreateEnclosHandler } from '@application/commands/CreateEnclos';
import { createDeleteEnclosHandler } from '@application/commands/DeleteEnclos';
import { createAddDragodindeHandler } from '@application/commands/AddDragodinde';
import { createRemoveDragodindeHandler } from '@application/commands/RemoveDragodinde';
import { createToggleGaugeHandler, createUpdateGaugeLevelHandler } from '@application/commands/UpdateGauge';
import { createRegisterAccouplementHandler } from '@application/commands/RegisterAccouplement';
import { createUpdateSettingsHandler } from '@application/commands/UpdateSettings';
import { createResetStatsHandler } from '@application/commands/ResetStats';
import { createReorderEnclosHandler } from '@application/commands/ReorderEnclos';
import { createUpdateWorkflowHandler } from '@application/commands/UpdateWorkflow';
import { createDeleteWorkflowHandler } from '@application/commands/DeleteWorkflow';
import { createSaveWorkflowHandler } from '@application/commands/SaveWorkflow';
import { createImportWorkflowsHandler } from '@application/commands/ImportWorkflows';
import { createClearEnclosHandler, createRenameEnclosHandler, createResetTimerHandler, createNouvelleFourneeHandler } from '@application/commands/EnclosActions';
import { createRechargeGaugeHandler } from '@application/commands/RechargeGauge';
import { createCompleteTimerHandler } from '@application/commands/CompleteTimer';
import { createRenameDragodindeHandler, createUpdateDdStatHandler, createUpdateDdSerenTargetHandler, createUpdateDdLevelTargetHandler, createReorderDragodindeHandler } from '@application/commands/DragodindeActions';
// Query handler factories
import { createGetDashboardHandler } from '@application/queries/GetDashboard';
import { createGetEnclosDetailHandler } from '@application/queries/GetEnclosDetail';
import { createGetTimerStateHandler } from '@application/queries/GetTimerState';
import { createGetBreedingOptionsHandler } from '@application/queries/GetBreedingOptions';
import { createGetReapproTreeHandler } from '@application/queries/GetReapproTree';
import { createGetInventaireHandler } from '@application/queries/GetInventaire';
import { createGetSettingsHandler } from '@application/queries/GetSettings';
import { createGetWorkflowsHandler } from '@application/queries/GetWorkflows';
import { createGetStatisticsHandler } from '@application/queries/GetStatistics';
async function bootstrap(): Promise<void> {
// Infrastructure
const repo = new LocalStorageRepository();
const notifications = new ElectronNotification();
const alarm = new WebAudioAlarm();
const events = new EventBus();
// Load state
const defaultState: AppState = {
enclos: [], activeId: 'dashboard', nextEnclosId: 1,
alarmSound: 'arpege', notifsEnabled: true, ntfyTopic: '',
archivedStats: [], inventaire: {}, workflows: [], accouplements: [],
};
const state: AppState = (await repo.load()) ?? defaultState;
// Command Bus
const commandBus = new CommandBus();
commandBus.register('start-timer', createStartTimerHandler(state, repo));
commandBus.register('stop-timer', createStopTimerHandler(state, repo));
commandBus.register('create-enclos', createCreateEnclosHandler(state, repo));
commandBus.register('delete-enclos', createDeleteEnclosHandler(state, repo, events));
commandBus.register('add-dragodinde', createAddDragodindeHandler(state, repo));
commandBus.register('remove-dragodinde', createRemoveDragodindeHandler(state, repo));
commandBus.register('toggle-gauge', createToggleGaugeHandler(state, repo));
commandBus.register('update-gauge-level', createUpdateGaugeLevelHandler(state, repo));
commandBus.register('register-accouplement', createRegisterAccouplementHandler(state, repo, events));
commandBus.register('update-settings', createUpdateSettingsHandler(state, repo));
commandBus.register('reset-stats', createResetStatsHandler(state, repo));
commandBus.register('reorder-enclos', createReorderEnclosHandler(state, repo));
commandBus.register('update-workflow', createUpdateWorkflowHandler(state, repo));
commandBus.register('delete-workflow', createDeleteWorkflowHandler(state, repo));
commandBus.register('save-workflow', createSaveWorkflowHandler(state, repo));
commandBus.register('import-workflows', createImportWorkflowsHandler(state, repo));
commandBus.register('complete-timer', createCompleteTimerHandler(state, repo, events));
commandBus.register('recharge-gauge', createRechargeGaugeHandler(state, repo));
commandBus.register('clear-enclos', createClearEnclosHandler(state, repo));
commandBus.register('rename-enclos', createRenameEnclosHandler(state, repo));
commandBus.register('reset-timer', createResetTimerHandler(state, repo));
commandBus.register('nouvelle-fournee', createNouvelleFourneeHandler(state, repo));
commandBus.register('rename-dragodinde', createRenameDragodindeHandler(state, repo));
commandBus.register('update-dd-stat', createUpdateDdStatHandler(state, repo));
commandBus.register('update-dd-seren-target', createUpdateDdSerenTargetHandler(state, repo));
commandBus.register('update-dd-level-target', createUpdateDdLevelTargetHandler(state, repo));
commandBus.register('reorder-dragodinde', createReorderDragodindeHandler(state, repo));
// Query Bus
const queryBus = new QueryBus();
queryBus.register('get-dashboard', createGetDashboardHandler(state));
queryBus.register('get-enclos-detail', createGetEnclosDetailHandler(state));
queryBus.register('get-timer-state', createGetTimerStateHandler(state));
queryBus.register('get-breeding-options', createGetBreedingOptionsHandler());
queryBus.register('get-reappro-tree', createGetReapproTreeHandler());
queryBus.register('get-inventaire', createGetInventaireHandler(state));
queryBus.register('get-settings', createGetSettingsHandler(state));
queryBus.register('get-workflows', createGetWorkflowsHandler(state));
queryBus.register('get-statistics', createGetStatisticsHandler(state));
// Event handlers
events.on('timer-completed', (event) => {
const enclosName = (event as any).enclosName ?? 'Enclos';
if (state.notifsEnabled) {
notifications.showNotification('Dragodindes prêtes !', `${enclosName} — Toutes les cibles atteintes !`);
}
alarm.play(state.alarmSound);
if (state.ntfyTopic) {
const url = `https://ntfy.mickael-pol.fr/${state.ntfyTopic}`;
notifications.sendMobileNotification(url, 'Dragodindes prêtes !', `${enclosName} — Toutes les cibles atteintes !`);
}
});
// Presentation
const uiState = new UIState();
uiState.activeView = state.activeId ?? 'dashboard';
const rootEl = document.getElementById('app');
if (!rootEl) throw new Error('Root element #app not found');
const app = new App(commandBus, queryBus, uiState, rootEl, (name: string) => alarm.play(name));
app.render();
}
bootstrap().catch(console.error);

View File

@ -0,0 +1,79 @@
/**
* UndoManager snapshot/restore pour les actions destructives.
*
* Stocke un seul niveau d'undo (le dernier snapshot).
* Le snapshot expire après EXPIRY_MS.
*/
const EXPIRY_MS = 10_000;
interface Snapshot {
stateJson: string;
label: string;
timestamp: number;
}
let current: Snapshot | null = null;
let onUndoCallback: (() => void) | null = null;
export const UndoManager = {
/**
* Sauvegarde un snapshot du state avant une action destructive.
*/
snapshot(stateJson: string, label: string): void {
current = { stateJson, label, timestamp: Date.now() };
},
/**
* Restaure le dernier snapshot si disponible et non expiré.
* Retourne true si l'undo a é effectué.
*/
undo(): boolean {
if (!this.canUndo()) return false;
const api = (window as any).electronAPI;
if (!api?.saveData) return false;
api.saveData(current!.stateJson);
current = null;
if (onUndoCallback) onUndoCallback();
else window.location.reload();
return true;
},
canUndo(): boolean {
if (!current) return false;
if (Date.now() - current.timestamp > EXPIRY_MS) {
current = null;
return false;
}
return true;
},
label(): string {
return current?.label ?? '';
},
clear(): void {
current = null;
},
/** Callback custom après undo (par défaut: reload) */
onUndo(cb: () => void): void {
onUndoCallback = cb;
},
/**
* Prend un snapshot du state actuel via electronAPI.
* Retourne true si le snapshot a é pris.
*/
async snapshotCurrent(label: string): Promise<boolean> {
const api = (window as any).electronAPI;
if (!api?.loadData) return false;
const raw = await api.loadData();
if (!raw) return false;
this.snapshot(raw, label);
return true;
},
};

View File

@ -0,0 +1,36 @@
type Listener = () => void;
export class UIState {
private listeners: Listener[] = [];
activeView: string | number = 'dashboard';
sidebarOpen = true;
subscribe(fn: Listener): () => void {
this.listeners.push(fn);
return () => {
this.listeners = this.listeners.filter(l => l !== fn);
};
}
notify(): void {
for (const fn of this.listeners) fn();
}
setActiveView(view: string | number): void {
this.activeView = view;
this.notify();
}
toggleSidebar(): void {
this.sidebarOpen = !this.sidebarOpen;
this.notify();
}
closeSidebar(): void {
if (this.sidebarOpen) {
this.sidebarOpen = false;
this.notify();
}
}
}

View File

@ -0,0 +1,34 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 18px; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Nunito', sans-serif;
font-size: 1rem;
min-height: 100vh;
padding: 22px 28px 52px;
}
.wrap {
max-width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 18px;
}
header { text-align: center; padding: 10px 0 6px; }
header h1 {
font-family: 'Cinzel', serif;
font-size: 2.4rem;
font-weight: 700;
letter-spacing: 5px;
background: linear-gradient(130deg, var(--ser), var(--end));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
header p { color: var(--muted); font-size: 0.85rem; margin-top: 5px; }
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
input[type=number] { -moz-appearance: textfield; }
input::-webkit-input-placeholder { color: var(--muted); }

View File

@ -0,0 +1,466 @@
/* TABS */
.tabs-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; }
.tab { padding: 12px 22px; border-radius: 10px 10px 0 0; border: 1px solid var(--border); border-bottom: none; background: var(--bg2); color: var(--muted); cursor: pointer; font: 700 1.05rem 'Nunito', sans-serif; display: flex; align-items: center; gap: 9px; transition: .15s; user-select: none; }
.tab[draggable=true]:hover { cursor: grab; }
.tab:hover { color: var(--text); background: var(--bg3); }
.tab.active { background: var(--bg3); color: var(--text); border-color: var(--ser); }
.tab-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--muted); flex-shrink: 0; transition: background .3s; }
.tab.running .tab-dot { background: var(--ok); box-shadow: 0 0 7px var(--ok); animation: pulse 1.2s infinite; }
.tab.done-tab .tab-dot { background: var(--ok); }
.tab-del { background: none; border: none; color: var(--muted); cursor: pointer; padding: 0 0 0 5px; font-size: 1rem; line-height: 1; }
.tab-del:hover { color: var(--amour); }
.add-tab { padding: 10px 18px; border-radius: 9px; border: 1px dashed var(--border); background: transparent; color: var(--muted); cursor: pointer; font: 700 1rem 'Nunito', sans-serif; transition: .15s; align-self: center; }
.add-tab:hover { border-color: var(--ser); color: var(--ser); }
.add-tab:disabled { opacity: .3; cursor: not-allowed; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
/* CONTENT */
.enclos-content { background: var(--bg3); border: 1px solid var(--ser); border-radius: 0 12px 12px 12px; padding: 14px 18px; display: flex; flex-direction: column; gap: 12px; }
/* CARD */
.card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r); padding: 14px 18px; }
.card-title { font-family: 'Cinzel', serif; font-size: 0.75rem; letter-spacing: 2.5px; text-transform: uppercase; color: var(--muted); margin-bottom: 10px; }
/* GAUGE BUTTONS */
.gauge-btns { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.gauge-btn { padding: 7px 14px; border-radius: 8px; border: 2px solid transparent; background: var(--bg3); color: var(--muted); cursor: pointer; font: 700 0.92rem 'Nunito', sans-serif; transition: .15s; display: flex; align-items: center; gap: 6px; }
.gauge-btn:hover { background: var(--bg4); color: var(--text); }
.gauge-btn.on { color: #fff; }
.gauge-btn.locked { opacity: .4; cursor: not-allowed; }
/* GAUGE CFG */
.gauges-active { display: flex; gap: 12px; flex-wrap: wrap; }
.gauge-cfg { flex: 1; min-width: 240px; background: var(--bg3); border: 1px solid var(--border); border-radius: 9px; padding: 12px 14px; }
.gauge-cfg-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.gauge-cfg-name { font-weight: 800; font-size: 1rem; display: flex; align-items: center; gap: 7px; }
.tier-badge { font-size: 0.72rem; font-weight: 800; padding: 3px 10px; border-radius: 20px; letter-spacing: .5px; transition: background .3s; }
.gauge-inp-row { display: flex; align-items: center; gap: 8px; }
.gauge-inp { flex: 1; background: var(--bg2); border: 1px solid var(--border); border-radius: 7px; color: var(--text); padding: 6px 10px; font: 700 0.95rem 'Nunito', sans-serif; }
.gauge-inp:focus { outline: none; }
.gauge-inp-recharge { border-color: var(--ok); cursor: text; }
.gauge-inp-recharge:hover { border-color: var(--ok); box-shadow: 0 0 6px color-mix(in srgb, var(--ok) 30%, transparent); }
.gauge-inp-max { font-size: 0.78rem; color: var(--muted); white-space: nowrap; flex-shrink: 0; }
.gauge-bar-bg { height: 6px; background: var(--bg4); border-radius: 4px; margin-top: 8px; }
.gauge-bar-fill { height: 100%; border-radius: 4px; transition: width .5s; }
.gauge-info { font-size: 0.8rem; color: var(--muted); margin-top: 5px; }
.no-gauge-msg { color: var(--muted); text-align: center; padding: 16px; font-style: italic; font-size: 0.95rem; }
/* TIMER */
.timer-bar { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r); padding: 12px 18px; }
.btn { padding: 9px 18px; border-radius: 9px; border: none; font: 800 0.95rem 'Nunito', sans-serif; cursor: pointer; transition: .15s; display: flex; align-items: center; gap: 7px; }
.btn * { pointer-events: none; }
.btn-start { background: linear-gradient(135deg, #5020b0, var(--ser)); color: #fff; }
.btn-start:hover { opacity: .85; }
.btn-pause { background: linear-gradient(135deg, #b06000, #f5a623); color: #fff; }
.btn-pause:hover { opacity: .85; }
.btn-ghost { background: var(--bg3); color: var(--text); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--bg4); }
.clock-wrap .lbl { font-size: 0.75rem; color: var(--muted); margin-bottom: 2px; }
.clock-wrap .val { font-family: 'Cinzel', serif; font-size: 2rem; letter-spacing: 3px; }
.clock-wrap .val.clock-elapsed { color: var(--ok); }
/* DD GRID */
.dd-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
.dd-card { background: var(--bg3); border: 1px solid var(--border); border-radius: 11px; padding: 12px; position: relative; transition: border-color .3s, box-shadow .3s; }
.dd-card[draggable=true] { cursor: default; }
.dd-card.dragging { opacity: 0.35; transform: scale(0.97); box-shadow: none; transition: none; }
.dd-card.drag-over { border-color: var(--ser); box-shadow: 0 0 0 2px var(--ser), 0 6px 20px rgba(192,96,255,0.35); transform: scale(1.02); }
.dd-card.done { border-color: var(--ok); box-shadow: 0 0 18px rgba(40, 232, 136, .18); }
.done-badge { display: none; position: absolute; top: -10px; right: 10px; background: var(--ok); color: #000; font-size: 0.68rem; font-weight: 800; padding: 2px 10px; border-radius: 20px; letter-spacing: .5px; }
.dd-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; gap: 6px; }
.dd-drag-handle { color: var(--muted); font-size: 1.1rem; cursor: grab; opacity: 0; transition: opacity .15s; flex-shrink: 0; line-height: 1; padding: 2px 3px; }
.dd-drag-handle:active { cursor: grabbing; }
.dd-card:hover .dd-drag-handle { opacity: 1; }
.dd-name { flex: 1; background: transparent; border: none; border-bottom: 2px solid var(--border); color: var(--text); font-family: 'Cinzel', serif; font-size: 0.95rem; font-weight: 700; padding: 3px 4px; min-width: 0; }
.dd-name:focus { outline: none; border-bottom-color: var(--ser); }
.dd-del { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 0.95rem; padding: 3px 6px; border-radius: 5px; }
.dd-del:hover { color: var(--amour); background: rgba(255, 80, 112, .1); }
/* DD STAT PILLS */
.dd-stats-bar { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-bottom: 9px; }
.dd-stat-pill { display: flex; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: 7px; padding: 4px 8px; background: var(--bg2); }
.dd-stat-pill.at-max { }
.pill-icon { font-size: 0.9rem; flex-shrink: 0; line-height: 1; }
.pill-input { flex: 1; background: transparent; border: none; color: var(--text); font: 700 0.85rem 'Nunito', sans-serif; min-width: 0; width: 0; text-align: right; }
.pill-input:focus { outline: none; }
.pill-delta { font-size: 0.72rem; font-weight: 700; color: var(--ok); flex-shrink: 0; }
/* DD CIBLE ROWS */
.seren-row, .level-row { display: flex; align-items: center; gap: 6px; padding: 4px 0; border-bottom: 1px solid var(--border); margin-bottom: 6px; }
.target-icon { font-size: 0.85rem; flex-shrink: 0; line-height: 1; }
.target-label { font-size: 0.75rem; color: var(--muted); flex-shrink: 0; }
.target-input { width: 58px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 3px 6px; font: 700 0.8rem 'Nunito', sans-serif; text-align: center; }
.target-input:focus { outline: none; border-color: var(--ser); }
.target-eta { flex: 1; text-align: right; font-size: 0.78rem; font-weight: 700; color: var(--muted); }
.target-clear { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 0.65rem; padding: 0 2px; line-height: 1; opacity: .5; flex-shrink: 0; }
.target-clear:hover { color: var(--amour); opacity: 1; }
/* DD GAUGE BLOCKS */
.dd-gauge-blocks { display: flex; flex-direction: column; gap: 8px; }
/* STAT BLOCK */
.stat-blk { margin-bottom: 16px; }
.stat-lbl { font-size: 0.78rem; font-weight: 800; text-transform: uppercase; letter-spacing: 1.2px; margin-bottom: 7px; }
.stat-row { display: flex; align-items: center; gap: 6px; }
.stat-inp { flex: 1; background: var(--bg2); border: 1px solid var(--border); border-radius: 7px; color: var(--text); padding: 7px 10px; font: 700 0.95rem 'Nunito', sans-serif; min-width: 0; }
.stat-inp:focus { outline: none; border-color: currentColor; }
.stat-inp:disabled { opacity: .5; }
.arrow { color: var(--muted); font-size: 0.9rem; flex-shrink: 0; }
.suggest-btn { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 0.85rem; padding: 3px 6px; border-radius: 5px; }
.suggest-btn:hover { color: var(--ser); }
.stat-live-row { display: flex; align-items: center; justify-content: space-between; margin-top: 9px; gap: 6px; }
.live-val { font-family: 'Cinzel', serif; font-size: 1.35rem; font-weight: 700; min-width: 100px; }
.live-val.tick { animation: tickflash .4s ease-out; }
@keyframes tickflash { 0% { transform: scale(1.18); filter: brightness(1.7); } 100% { transform: scale(1); filter: brightness(1); } }
.live-delta { font-size: 0.9rem; font-weight: 800; opacity: 0; min-width: 46px; text-align: center; }
@keyframes liveDeltaPop { 0% { opacity: 1; transform: translateY(0) scale(1.15); } 70% { opacity: 1; } 100% { opacity: 0; transform: translateY(-10px) scale(1); } }
.live-delta.show { animation: liveDeltaPop 1.2s ease-out forwards; }
.live-cd { font-size: 0.9rem; font-weight: 800; text-align: right; }
.stat-bar-bg { height: 6px; background: var(--bg4); border-radius: 3px; margin-top: 7px; }
.stat-bar-fill { height: 100%; border-radius: 3px; transition: width .5s; }
.eta200-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 3px; }
.eta200-eta { font-size: 0.72rem; color: var(--muted); }
.eta200-progress { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.eta200-bar-bg { flex: 1; height: 4px; background: var(--bg4); border-radius: 3px; overflow: hidden; }
.eta200-bar-fill { height: 100%; border-radius: 3px; transition: width .5s; }
.eta200-pct { font-size: 0.68rem; color: var(--muted); min-width: 28px; text-align: right; }
/* DD SECTION HEAD */
.dd-section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.dd-section-head .card-title { margin-bottom: 0; }
/* ADD */
.add-dd-btn { display: flex; align-items: center; gap: 6px; padding: 6px 14px; background: var(--bg3); border: 1px dashed var(--border); border-radius: 8px; color: var(--muted); cursor: pointer; font: 700 0.88rem 'Nunito', sans-serif; transition: .15s; }
.add-dd-btn:hover { border-color: var(--ser); color: var(--ser); background: rgba(192, 96, 255, .05); }
.add-dd-btn:disabled { opacity: .3; cursor: not-allowed; }
/* DONE BANNER */
.done-banner { display: flex; align-items: center; gap: 16px; padding: 16px 20px; background: rgba(40, 232, 136, .08); border: 1px solid var(--ok); border-radius: 11px; }
.done-banner-text { font-family: 'Cinzel', serif; font-size: 1.1rem; font-weight: 700; color: var(--ok); }
.done-banner-sub { font-size: 0.85rem; color: var(--muted); margin-top: 3px; }
.btn-reset-now { background: linear-gradient(135deg, #005040, var(--ok)); color: #000; font-weight: 800; }
.btn-reset-now:hover { opacity: .85; }
/* DASHBOARD */
.dash-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.dash-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; padding: 18px; cursor: pointer; transition: border-color .2s, box-shadow .2s; }
.dash-card:hover { border-color: var(--ser); box-shadow: 0 0 16px rgba(192, 96, 255, .15); }
.dash-card.running { border-color: rgba(40, 232, 136, .4); }
.dash-card.done-enc { border-color: var(--ok); box-shadow: 0 0 18px rgba(40, 232, 136, .2); }
.dash-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.dash-name { font-family: 'Cinzel', serif; font-size: 1.25rem; font-weight: 700; }
.dash-status { display: flex; align-items: center; gap: 6px; font-size: 0.92rem; }
.dash-dot { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; }
.dash-sep { height: 1px; background: var(--border); margin: 13px 0; }
.dash-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.dash-lbl { font-size: 0.82rem; color: var(--muted); margin-bottom: 3px; }
.dash-val { font-family: 'Cinzel', serif; font-size: 1.65rem; font-weight: 700; }
.dash-go { display: block; width: 100%; margin-top: 14px; padding: 11px; background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font: 700 1rem 'Nunito', sans-serif; cursor: pointer; text-align: center; transition: .15s; }
.dash-go:hover { background: var(--bg4); border-color: var(--ser); }
/* NOTIF + SON */
.notif-btn { padding: 9px 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg3); color: var(--muted); cursor: pointer; font: 700 0.9rem 'Nunito', sans-serif; transition: .15s; }
.notif-btn.granted { color: var(--ok); border-color: var(--ok); }
.notif-btn.denied { color: var(--amour); border-color: var(--amour); opacity: .6; cursor: not-allowed; }
.notif-btn:hover:not(.denied) { background: var(--bg4); }
.sound-select { padding: 8px 13px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg3); color: var(--text); font: 700 0.9rem 'Nunito', sans-serif; cursor: pointer; outline: none; }
.sound-select:focus { border-color: var(--ser); }
.sound-preview { padding: 8px 13px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg3); color: var(--muted); cursor: pointer; font: 700 0.9rem 'Nunito', sans-serif; transition: .15s; }
.sound-preview:hover { background: var(--bg4); color: var(--text); }
/* BANNIERE MISE A JOUR */
.update-banner { display: none; align-items: center; gap: 14px; padding: 13px 18px; border-radius: 10px; border: 1px solid; margin-bottom: 2px; }
.update-banner.available { background: rgba(255, 160, 64, .08); border-color: var(--xp); }
.update-banner.downloading { background: rgba(40, 232, 136, .06); border-color: var(--ok); }
.update-banner.error { background: rgba(255, 80, 112, .07); border-color: var(--amour); }
.update-banner-icon { font-size: 1.4rem; flex-shrink: 0; }
.update-banner-text { flex: 1; }
.update-banner-title { font-family: 'Cinzel', serif; font-size: 1rem; font-weight: 700; }
.update-banner-sub { font-size: 0.8rem; color: var(--muted); margin-top: 2px; }
.update-progress-bar { height: 5px; background: var(--bg4); border-radius: 3px; margin-top: 7px; overflow: hidden; }
.update-progress-fill { height: 100%; background: var(--ok); border-radius: 3px; transition: width .3s; width: 0%; }
.btn-update { padding: 9px 18px; border-radius: 8px; border: none; font: 800 0.9rem 'Nunito', sans-serif; cursor: pointer; background: linear-gradient(135deg, #a06000, var(--xp)); color: #000; white-space: nowrap; }
.btn-update:hover { opacity: .85; }
.btn-update:disabled { opacity: .5; cursor: not-allowed; }
/* MODAL OVERLAY */
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, .75); z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 20px; }
.modal-overlay.hidden { display: none; }
.modal-box { background: var(--bg2); border: 1px solid var(--border); border-radius: 14px; width: 100%; max-width: 820px; max-height: 90vh; display: flex; flex-direction: column; overflow: hidden; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--border); }
.modal-header h2 { font-family: 'Cinzel', serif; font-size: 1.1rem; font-weight: 700; color: var(--text); }
.modal-close { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1.4rem; padding: 2px 8px; border-radius: 5px; }
.modal-close:hover { color: var(--amour); background: rgba(255, 80, 112, .1); }
.modal-body { overflow-y: auto; padding: 20px 22px; flex: 1; }
.modal-footer { padding: 16px 22px; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
/* RACE CARDS (style jeu) */
.race-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
.race-card { background: #f5f0e8; border-radius: 12px; padding: 14px; cursor: pointer; border: 3px solid transparent; transition: .15s; position: relative; color: #2a2a2a; }
.race-card:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0, 0, 0, .25); }
.race-card.selected { border-color: #c060ff !important; box-shadow: 0 0 0 3px rgba(192, 96, 255, .6), 0 8px 24px rgba(0, 0, 0, .35) !important; transform: translateY(-3px); }
.race-card.selected::before { content: '\2713'; position: absolute; top: 8px; left: 10px; background: #c060ff; color: #fff; width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.85rem; font-weight: 800; z-index: 2; }
.race-card-badge { position: absolute; top: 8px; right: 8px; padding: 2px 8px; border-radius: 10px; font-size: 0.68rem; font-weight: 800; color: #fff; letter-spacing: .3px; z-index: 2; line-height: 1.4; }
.race-card-avatar { width: 76px; height: 76px; border-radius: 50%; overflow: hidden; display: flex; align-items: center; justify-content: center; margin: 0 auto; border: 3px solid rgba(255, 255, 255, .85); box-shadow: 0 2px 10px rgba(0, 0, 0, .18); background: transparent; }
.race-card-avatar img { width: 100%; height: 100%; object-fit: contain; }
.race-card-icon { font-size: 2rem; margin-bottom: 6px; display: block; text-align: center; }
.race-card-name { font-family: 'Georgia', serif; font-size: 0.95rem; font-weight: 700; margin-bottom: 8px; line-height: 1.2; color: #1a1a1a; text-align: center; }
.race-card-stats { list-style: none; padding: 0; margin: 0 0 10px; }
.race-card-stats li { font-size: 0.78rem; color: #444; padding: 1px 0; }
.race-card-stats li::before { content: '\2022 '; color: #888; }
.race-card-sep { height: 1px; background: #ddd; margin: 8px 0; }
.race-card-parents { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.race-card-heart { font-size: 1.2rem; }
.race-card-parents-txt { font-size: 0.72rem; color: #666; text-align: center; line-height: 1.3; }
/* STATS TAB */
.stats-wrap { display: flex; flex-direction: column; gap: 18px; }
.stats-section { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r); padding: 18px; }
.stats-section-title { font-family: 'Cinzel', serif; font-size: 0.75rem; letter-spacing: 2px; text-transform: uppercase; color: var(--muted); margin-bottom: 14px; }
.stats-kpi-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
.stats-kpi { background: var(--bg3); border-radius: 9px; padding: 14px; text-align: center; }
.stats-kpi-val { font-family: 'Cinzel', serif; font-size: 2rem; font-weight: 700; color: var(--ser); }
.stats-kpi-lbl { font-size: 0.8rem; color: var(--muted); margin-top: 4px; }
.stats-race-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); }
.stats-race-row:last-child { border-bottom: none; }
.stats-race-chip { padding: 3px 10px; border-radius: 12px; font-size: 0.78rem; font-weight: 800; color: #fff; white-space: nowrap; }
.stats-race-name { flex: 1; font-weight: 700; font-size: 0.9rem; }
.stats-race-count { font-family: 'Cinzel', serif; font-size: 1.1rem; font-weight: 700; }
.stats-bar-wrap { flex: 2; min-width: 80px; }
.stats-bar-bg { height: 8px; background: var(--bg4); border-radius: 4px; overflow: hidden; }
.stats-bar-fill { height: 100%; border-radius: 4px; transition: width .5s; }
.stats-enclos-row { background: var(--bg3); border-radius: 9px; padding: 12px 14px; margin-bottom: 8px; }
.stats-enclos-name { font-family: 'Cinzel', serif; font-size: 0.95rem; font-weight: 700; margin-bottom: 6px; }
.stats-enclos-meta { display: flex; gap: 16px; flex-wrap: wrap; font-size: 0.85rem; color: var(--muted); }
.stats-pct { font-weight: 800; color: var(--ok); }
.stats-pct.low { color: var(--warn); }
.stats-pct.bad { color: var(--amour); }
.stats-empty { text-align: center; padding: 30px; color: var(--muted); font-style: italic; }
/* APPRO / INVENTAIRE */
/* .appro-step et .appro-repro migrés vers obsidienne.css */
.inv-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 8px; }
.inv-item { background: var(--bg3); border-radius: 8px; padding: 10px; display: flex; align-items: center; gap: 8px; }
.inv-item label { flex: 1; font-size: 0.85rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inv-item input { width: 55px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 6px 8px; font: 700 0.9rem 'Nunito', sans-serif; text-align: center; }
.inv-item input:focus { outline: none; border-color: var(--ser); }
.inv-card { display: flex; flex-direction: column; align-items: center; background: var(--bg3); border: 2px solid transparent; border-radius: 12px; padding: 12px 14px 16px; text-align: center; transition: .15s; position: relative; }
.inv-card-name { font-size: 0.78rem; font-weight: 700; line-height: 1.15; color: var(--text); height: 2.3em; display: flex; align-items: center; justify-content: center; margin-top: 5px; }
.inv-gender { display: flex; gap: 6px; margin-top: auto; padding-top: 4px; justify-content: center; }
.inv-gender-box { display: flex; align-items: center; gap: 2px; padding: 4px 8px; border-radius: 8px; font-size: 0.8rem; font-weight: 800; justify-content: center; cursor: text; }
.inv-gender-box.male { background: rgba(80, 160, 255, .1); border: 1.5px solid rgba(80, 160, 255, .3); color: #50a0ff; }
.inv-gender-box.female { background: rgba(255, 100, 160, .1); border: 1.5px solid rgba(255, 100, 160, .3); color: #ff64a0; }
.inv-gender-box input { width: 34px; background: transparent; border: none; color: inherit; font: 800 0.85rem 'Nunito', sans-serif; text-align: center; padding: 4px 2px; cursor: text; }
.inv-gender-box input:focus { outline: none; background: rgba(255, 255, 255, .08); border-radius: 4px; }
/* .appro-card migrés vers obsidienne.css (réutilise .accoup-race-card) */
/* SOUS-ONGLETS */
.subtab { padding: 11px 16px; border: none; background: transparent; color: var(--muted); cursor: pointer; font: 700 0.9rem 'Nunito', sans-serif; transition: .15s; border-bottom: 2px solid transparent; }
.subtab:hover { color: var(--text); background: var(--bg3); }
.subtab.active { color: var(--ser); border-bottom-color: var(--ser); background: rgba(192, 96, 255, .06); }
/* BARRE STATS PERMANENTE DD */
.dd-stats-bar { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0 10px; border-bottom: 1px solid var(--border); margin-bottom: 10px; }
.dd-stat-pill { display: flex; align-items: center; gap: 4px; padding: 3px 9px; border-radius: 12px; font-size: 0.8rem; font-weight: 800; background: transparent; border: 2px solid; cursor: pointer; transition: background .3s, box-shadow .3s; position: relative; overflow: visible; }
.dd-stat-pill.at-max { box-shadow: 0 0 8px rgba(0, 0, 0, .3); }
.dd-stat-pill:hover { filter: brightness(1.15); }
.dd-stat-pill input { background: transparent; border: none; color: inherit; font: 800 0.8rem 'Nunito', sans-serif; width: 52px; text-align: center; padding: 0; }
.dd-stat-pill input:focus { outline: none; }
.dd-stat-pill .pill-icon { font-size: 0.9rem; flex-shrink: 0; }
.pill-delta { position: absolute; top: -20px; left: 50%; transform: translateX(-50%); font-size: 0.72rem; font-weight: 800; pointer-events: none; opacity: 0; white-space: nowrap; z-index: 10; }
@keyframes pillDeltaUp { 0% { opacity: 1; transform: translateX(-50%) translateY(0); } 100% { opacity: 0; transform: translateX(-50%) translateY(-16px); } }
.pill-delta.show { animation: pillDeltaUp .9s ease-out forwards; }
/* SERENITE TARGET ROW */
.seren-row { display: flex; align-items: center; gap: 6px; padding: 6px 10px; margin: -4px 0 8px; border-radius: 10px; background: rgba(244, 114, 182, .06); border: 1px solid rgba(244, 114, 182, .18); }
.seren-row .seren-icon { font-size: 1rem; flex-shrink: 0; }
.seren-row .seren-lbl { font-size: 0.75rem; color: rgba(244, 114, 182, .7); font-weight: 700; white-space: nowrap; }
.seren-row input { background: rgba(244, 114, 182, .08); border: 1.5px solid rgba(244, 114, 182, .3); color: rgb(244, 114, 182); font: 800 0.82rem 'Nunito', sans-serif; width: 58px; text-align: center; padding: 3px 4px; border-radius: 8px; transition: border-color .2s; }
.seren-row input:focus { outline: none; border-color: rgb(244, 114, 182); background: rgba(244, 114, 182, .14); }
.seren-row .seren-arrow { color: rgba(244, 114, 182, .5); font-size: 0.9rem; font-weight: 800; }
.seren-row .seren-eta { font-size: 0.78rem; font-weight: 800; color: rgb(244, 114, 182); white-space: nowrap; margin-left: auto; }
.seren-row .seren-eta .eta-need, .level-row .level-eta .eta-need { display: inline-flex; align-items: center; gap: 3px; padding: 2px 7px; border-radius: 8px; font-size: 0.7rem; opacity: .7; }
.seren-row .eta-need { background: rgba(244, 114, 182, .12); border: 1px solid rgba(244, 114, 182, .25); }
.level-row .eta-need { background: rgba(251, 146, 60, .12); border: 1px solid rgba(251, 146, 60, .25); }
.dd-card .seren-row .eta-need, .dd-card .level-row .eta-need { background: none; border: none; padding: 0; }
/* LEVEL TARGET ROW */
.level-row { display: flex; align-items: center; gap: 6px; padding: 6px 10px; margin: -4px 0 8px; border-radius: 10px; background: rgba(251, 146, 60, .06); border: 1px solid rgba(251, 146, 60, .18); }
.level-row .level-icon { font-size: 1rem; flex-shrink: 0; }
.level-row .level-lbl { font-size: 0.75rem; color: rgba(251, 146, 60, .7); font-weight: 700; white-space: nowrap; }
.level-row input { background: rgba(251, 146, 60, .08); border: 1.5px solid rgba(251, 146, 60, .3); color: rgb(251, 146, 60); font: 800 0.82rem 'Nunito', sans-serif; width: 52px; text-align: center; padding: 3px 4px; border-radius: 8px; transition: border-color .2s; }
.level-row input:focus { outline: none; border-color: rgb(251, 146, 60); background: rgba(251, 146, 60, .14); }
.level-row .level-arrow { color: rgba(251, 146, 60, .5); font-size: 0.9rem; font-weight: 800; }
.level-row .level-eta { font-size: 0.78rem; font-weight: 800; color: rgb(251, 146, 60); white-space: nowrap; margin-left: auto; }
/* Sidebar */
.sidebar-toggle { position: fixed; top: 12px; left: 12px; z-index: 1100; background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font-size: 1.3rem; padding: 6px 10px; cursor: pointer; transition: background .2s; line-height: 1; }
.sidebar-toggle:hover { background: var(--bg4); }
.sidebar-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, .5); z-index: 1200; opacity: 0; pointer-events: none; transition: opacity .25s; }
.sidebar-overlay.open { opacity: 1; pointer-events: all; }
.sidebar { position: fixed; top: 0; left: -280px; width: 270px; height: 100%; background: var(--bg2); border-right: 1px solid var(--border); z-index: 1300; transition: left .25s; padding: 60px 0 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
.sidebar.open { left: 0; }
.sidebar-item { display: flex; align-items: center; gap: 10px; padding: 10px 20px; color: var(--muted); font-size: .88rem; font-weight: 600; cursor: pointer; transition: background .15s, color .15s; border-left: 3px solid transparent; background: transparent; border-top: none; border-right: none; border-bottom: none; width: 100%; text-align: left; }
.sidebar-item:hover { background: var(--bg3); color: var(--text); }
.sidebar-item.active { color: var(--text); border-left-color: var(--ok); background: var(--bg3); }
.sidebar-sep { height: 1px; background: var(--border); margin: 8px 16px; }
.sidebar-label { padding: 8px 20px; font-size: .7rem; font-weight: 800; text-transform: uppercase; letter-spacing: 1.5px; color: var(--muted); opacity: .6; }
/* Accouplement — styles migrés vers obsidienne.css */
/* DASHBOARD COMPONENT */
.dash-section-title { font-family: 'Cinzel', serif; font-size: 0.76rem; letter-spacing: 2.5px; text-transform: uppercase; color: var(--muted); margin-bottom: 14px; }
.dash-gauges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; min-height: 26px; }
.dash-gauge-tag { font-size: 0.83rem; font-weight: 700; padding: 2px 10px; border-radius: 12px; border: 1px solid; }
.dash-val-sm { font-family: 'Cinzel', serif; font-size: 1.1rem; font-weight: 700; margin-top: 2px; }
.dash-elapsed { font-family: 'Cinzel', serif; font-size: 1.05rem; color: var(--ok); margin-top: 2px; margin-bottom: 12px; }
.dash-stats-section { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--r); padding: 20px; margin-top: 4px; }
.dash-stats-title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.dash-stats-title { font-family: 'Cinzel', serif; font-size: 0.75rem; letter-spacing: 2.5px; text-transform: uppercase; color: var(--muted); }
.dash-reset-btn { padding: 8px 16px; border-radius: 8px; border: 1px solid rgba(255,80,112,.4); background: rgba(255,80,112,.08); color: var(--amour); cursor: pointer; font: 700 0.88rem 'Nunito', sans-serif; transition: .18s; }
.dash-reset-btn * { pointer-events: none; }
.dash-reset-btn:hover { background: rgba(255,80,112,.18); border-color: var(--amour); color: #fff; }
.dash-kpi-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; }
.dash-kpi { background: var(--bg3); border-radius: 9px; padding: 14px; text-align: center; }
.dash-kpi-value { font-family: 'Cinzel', serif; font-size: 2rem; font-weight: 700; color: var(--ser); }
.dash-kpi-label { font-size: 0.8rem; color: var(--muted); margin-top: 4px; }
.dash-histogram { display: flex; flex-direction: column; gap: 8px; }
.dash-hist-row { display: flex; align-items: center; gap: 10px; }
.dash-hist-label { min-width: 120px; font-size: 0.85rem; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dash-hist-bar-wrap { flex: 1; height: 14px; background: var(--bg4); border-radius: 7px; overflow: hidden; }
.dash-hist-bar { height: 100%; border-radius: 7px; transition: width .5s; min-width: 4px; }
.dash-hist-count { min-width: 28px; text-align: right; font-size: 0.85rem; font-weight: 700; color: var(--text); }
/* ENCLOS VIEW COMPONENT */
.enclos-view { display: flex; flex-direction: column; gap: 18px; }
.enclos-header { display: flex; align-items: center; gap: 16px; }
.enclos-name { background: transparent; border: none; border-bottom: 2px solid var(--border); color: var(--text); font-family: 'Cinzel', serif; font-size: 1.5rem; font-weight: 700; padding: 4px 6px; width: 260px; min-width: 120px; }
.enclos-name:focus { outline: none; border-bottom-color: var(--ser); }
.clear-enclos-btn { padding: 7px 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg3); color: var(--muted); cursor: pointer; font: 700 0.88rem 'Nunito', sans-serif; transition: .15s; white-space: nowrap; }
.clear-enclos-btn * { pointer-events: none; }
.clear-enclos-btn:hover { border-color: var(--amour); color: var(--amour); }
.gauge-toggles { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; }
.gauge-configs { display: flex; gap: 14px; flex-wrap: wrap; }
.gauge-config { flex: 1; min-width: 240px; background: var(--bg3); border: 1px solid var(--border); border-radius: 9px; padding: 16px; }
.gauge-config-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-weight: 800; font-size: 1.05rem; }
.gauge-level-input { width: 100%; background: var(--bg2); border: 1px solid var(--border); border-radius: 7px; color: var(--text); padding: 8px 12px; font: 700 1rem 'Nunito', sans-serif; margin-bottom: 10px; }
.gauge-level-input:focus { outline: none; border-color: var(--ser); }
.gauge-bar { height: 7px; background: var(--bg4); border-radius: 4px; margin-top: 6px; overflow: hidden; }
.gauge-fill { height: 100%; border-radius: 4px; transition: width .5s; }
.gauge-empty-info { font-size: 0.82rem; color: var(--muted); margin-top: 6px; }
.timer-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.timer-display { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; }
.timer-elapsed { font-family: 'Cinzel', serif; font-size: 1.8rem; letter-spacing: 2px; }
.timer-countdown { font-family: 'Cinzel', serif; font-size: 1.2rem; color: var(--muted); }
.timer-btn { padding: 11px 22px; border-radius: 9px; border: none; font: 800 1rem 'Nunito', sans-serif; cursor: pointer; transition: .15s; background: linear-gradient(135deg, #5020b0, var(--ser)); color: #fff; }
.timer-btn:hover { opacity: .85; }
.timer-btn.running { background: linear-gradient(135deg, #7a3000, #ff8020); }
.timer-btn.paused { background: var(--bg3); color: var(--text); border: 1px solid var(--border); }
.timer-reset-btn { padding: 11px 18px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg3); color: var(--text); font: 700 0.95rem 'Nunito', sans-serif; cursor: pointer; transition: .15s; }
.timer-reset-btn:hover { background: var(--bg4); }
.done-text { font-family: 'Cinzel', serif; font-size: 1.1rem; font-weight: 700; color: var(--ok); }
.done-reset-btn { padding: 10px 20px; border-radius: 9px; border: none; background: linear-gradient(135deg, #005040, var(--ok)); color: #000; font: 800 0.95rem 'Nunito', sans-serif; cursor: pointer; transition: .15s; }
.done-reset-btn:hover { opacity: .85; }
.dd-section { display: flex; flex-direction: column; gap: 12px; }
.dd-count { font-size: 0.85rem; font-weight: 700; color: var(--muted); }
/* SIDEBAR COMPONENT */
.sidebar-content { display: flex; flex-direction: column; }
.sidebar-separator { height: 1px; background: var(--border); margin: 8px 16px; }
.sidebar-icon { font-size: 1.1rem; flex-shrink: 0; }
.sidebar-text { flex: 1; }
.sidebar-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: transparent; }
.dot-running { background: var(--ok); box-shadow: 0 0 5px var(--ok); animation: pulse 1.2s infinite; }
.dot-idle { background: transparent; }
/* VIEW WRAPPERS */
.parametres-view { display: flex; flex-direction: column; gap: 16px; }
/* .reappro-view migré vers obsidienne.css (.reappro-view-new) */
/* .accouplement-view migré vers obsidienne.css (.accoup-view) */
/* .inventaire-view migré vers obsidienne.css (.inv-view-new) */
/* WORKFLOWS VIEW */
/* .workflows-view migré vers obsidienne.css (.wf-view-new) */
.workflow-card:hover { border-color: var(--ser) !important; }
.workflow-list { display: flex; flex-direction: column; gap: 10px; }
.workflow-item { background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; padding: 16px 18px; cursor: pointer; transition: border-color .2s; display: flex; align-items: center; gap: 14px; }
.workflow-item:hover { border-color: var(--ser); }
.workflow-item-info { flex: 1; }
.workflow-item-name { font-family: 'Cinzel', serif; font-size: 1rem; font-weight: 700; margin-bottom: 4px; }
.workflow-item-meta { font-size: 0.82rem; color: var(--muted); }
.workflow-item-progress { text-align: right; }
.workflow-progress-pct { font-family: 'Cinzel', serif; font-size: 1.1rem; font-weight: 700; color: var(--ok); }
.workflow-detail { display: flex; flex-direction: column; gap: 14px; }
.workflow-detail-header { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
.workflow-back-btn { padding: 8px 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg3); color: var(--muted); cursor: pointer; font: 700 0.9rem 'Nunito', sans-serif; transition: .15s; }
.workflow-back-btn:hover { background: var(--bg4); color: var(--text); }
.workflow-detail-title { font-family: 'Cinzel', serif; font-size: 1.3rem; font-weight: 700; flex: 1; }
.workflow-delete-btn { padding: 8px 14px; border-radius: 8px; border: 1px solid var(--amour); background: transparent; color: var(--amour); cursor: pointer; font: 700 0.9rem 'Nunito', sans-serif; transition: .15s; }
.workflow-delete-btn:hover { background: rgba(255,80,112,.1); }
.workflow-steps { display: flex; flex-direction: column; gap: 8px; }
.workflow-step { background: var(--bg2); border: 1px solid var(--border); border-radius: 9px; padding: 14px 16px; display: flex; align-items: flex-start; gap: 12px; }
.workflow-step.done { border-color: var(--ok); background: rgba(40,232,136,.04); }
.workflow-step-icon { font-size: 1.1rem; flex-shrink: 0; margin-top: 2px; }
.workflow-step-info { flex: 1; }
.workflow-step-name { font-weight: 700; font-size: 0.95rem; margin-bottom: 3px; }
.workflow-step-meta { font-size: 0.82rem; color: var(--muted); }
.workflow-step-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
.workflow-step-dec, .workflow-step-inc { width: 28px; height: 28px; border-radius: 7px; border: 1px solid var(--border); background: var(--bg3); color: var(--text); cursor: pointer; font: 700 1rem 'Nunito', sans-serif; display: flex; align-items: center; justify-content: center; transition: .15s; }
.workflow-step-dec:hover, .workflow-step-inc:hover { background: var(--bg4); border-color: var(--ser); }
.workflow-step-count { min-width: 40px; text-align: center; font-size: 0.85rem; font-weight: 700; }
.workflow-add-btn { padding: 11px 18px; border-radius: 9px; border: 1px dashed var(--border); background: transparent; color: var(--muted); cursor: pointer; font: 700 0.95rem 'Nunito', sans-serif; transition: .15s; align-self: flex-start; }
.workflow-add-btn:hover { border-color: var(--ser); color: var(--ser); }
.workflow-empty { text-align: center; padding: 30px; color: var(--muted); font-style: italic; }
/* DRAGODINDE CARD COMPONENT */
.dd-done-badge { display: none; position: absolute; top: -10px; right: 14px; background: var(--ok); color: #000; font-size: 0.72rem; font-weight: 800; padding: 3px 12px; border-radius: 20px; }
.dd-card.done .dd-done-badge { display: block; }
.dd-del-btn { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; padding: 4px 7px; border-radius: 5px; }
.dd-del-btn:hover { color: var(--amour); background: rgba(255,80,112,.1); }
.dd-gauge-blocks { display: flex; flex-direction: column; gap: 10px; margin-top: 6px; }
/* Aliases for stat-blk sub-elements */
.stat-blk-label { font-size: 0.8rem; font-weight: 800; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 5px; color: var(--muted); }
.stat-blk-live { font-family: 'Cinzel', serif; font-size: 1.2rem; font-weight: 700; }
.stat-blk-delta { font-size: 0.88rem; font-weight: 800; color: var(--muted); }
.stat-blk-cd { font-size: 0.9rem; font-weight: 800; text-align: right; }
.stat-blk-bar { height: 6px; background: var(--bg4); border-radius: 3px; margin-top: 6px; overflow: hidden; }
.stat-blk-fill { height: 100%; border-radius: 3px; transition: width .5s; background: var(--ser); }
/* Seren/level target row icon/label/eta */
.target-icon { font-size: 1rem; flex-shrink: 0; }
.target-label { font-size: 0.75rem; font-weight: 700; white-space: nowrap; }
.target-input { width: 62px; text-align: center; padding: 3px 4px; border-radius: 8px; font: 800 0.82rem 'Nunito', sans-serif; }
.target-eta { font-size: 0.78rem; font-weight: 800; white-space: nowrap; margin-left: auto; }
/* HEADER LAYOUT */
header { display: flex; align-items: center; gap: 12px; padding: 10px 0 6px; padding-left: 50px; }
.hamburger { position: fixed; top: 12px; left: 12px; z-index: 1100; background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font-size: 1.3rem; padding: 6px 11px; cursor: pointer; transition: background .2s; flex-shrink: 0; }
.hamburger:hover { background: var(--bg4); }
.header-text { flex: 1; text-align: center; }
.header-text h1 { font-family: 'Cinzel', serif; font-size: 2.4rem; font-weight: 700; color: var(--text); letter-spacing: 0.12em; text-transform: uppercase; margin: 0; line-height: 1.2; }
.header-text h1 .icon { text-transform: none; letter-spacing: 0; margin-right: 6px; }
.header-text p { font-size: 0.78rem; color: var(--muted); margin: 3px 0 0; letter-spacing: 0.03em; }
.header-ver { color: var(--ser); font-weight: 700; }
/* UPDATE BANNER additional states/elements */
.update-banner.ready { display: flex; background: rgba(40, 232, 136, .08); border-color: var(--ok); }
.update-text { flex: 1; font-weight: 700; font-size: 0.9rem; }
.update-hint { font-size: 0.82rem; color: var(--muted); }
.update-percent { font-size: 0.82rem; color: var(--ok); font-weight: 800; white-space: nowrap; }
.update-install-btn { padding: 8px 16px; border-radius: 8px; border: none; font: 800 0.88rem 'Nunito', sans-serif; cursor: pointer; background: linear-gradient(135deg, #005040, var(--ok)); color: #000; white-space: nowrap; }
.update-install-btn:hover { opacity: .85; }
.update-error-msg { font-size: 0.82rem; color: var(--amour); flex: 1; }
.update-dismiss-btn { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1.1rem; padding: 2px 8px; border-radius: 5px; }
.update-dismiss-btn:hover { color: var(--amour); }
/* GAUGE PILL */
.gauge-pill { background: var(--bg3); border: 1px solid var(--border); border-radius: 9px; padding: 12px 14px; }
.gauge-pill-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-weight: 800; font-size: 1rem; }
.gauge-pill-bar { height: 7px; background: var(--bg4); border-radius: 4px; overflow: hidden; margin-bottom: 6px; }
.gauge-pill-fill { height: 100%; border-radius: 4px; background: var(--ser); transition: width .5s; }
.gauge-pill-info { font-size: 0.82rem; color: var(--muted); }
/* REAPPRO extras migrés vers obsidienne.css */
/* BTN ALIASES */
.btn-ok { background: linear-gradient(135deg, #005040, var(--ok)); color: #000; padding: 11px 22px; border-radius: 9px; border: none; font: 800 1rem 'Nunito', sans-serif; cursor: pointer; transition: .15s; }
.btn-ok:hover { opacity: .85; }

View File

@ -0,0 +1,4 @@
@import './variables.css';
@import './base.css';
@import './components.css';
@import './obsidienne.css';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
:root {
--bg: #0b0b14;
--bg2: #111120;
--bg3: #181828;
--bg4: #20203a;
--border: #2a2a45;
--text: #dddaf8;
--muted: #6868a0;
--ser: #f472b6;
--end: #facc15;
--mat: #22d3ee;
--amour: #f87171;
--xp: #fb923c;
--ok: #28e888;
--warn: #ff9820;
--r: 10px;
/* MD3 Obsidienne design tokens */
--md-primary: #cb97ff;
--md-secondary: #f673b7;
--md-tertiary: #ffe083;
--md-on-primary: #46007c;
--md-on-secondary: #4a002f;
--md-primary-container: #c185fd;
--md-secondary-container: #85145a;
--md-background: #100c16;
--md-surface-dim: #100c16;
--md-surface: #100c16;
--md-surface-container-lowest: #000000;
--md-surface-container-low: #16111d;
--md-surface-container: #1c1724;
--md-surface-container-high: #231d2b;
--md-surface-container-highest: #292332;
--md-surface-bright: #302939;
--md-on-surface: #f1e8f7;
--md-on-surface-variant: #b0a8b6;
--md-on-background: #f1e8f7;
--md-outline: #7a7380;
--md-outline-variant: #4b4652;
--md-error: #ff6e84;
--md-primary-dim: #be83fa;
--md-primary-fixed: #c185fd;
--md-inverse-primary: #7c41b5;
}

BIN
src/public/icone_sidebar.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB