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>
260
refonte_graphique/accouplement.html
Executable 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&family=Manrope:wght@600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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>
|
||||
BIN
refonte_graphique/accouplement.png
Executable file
|
After Width: | Height: | Size: 207 KiB |
361
refonte_graphique/dashboard.html
Executable 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&family=Cinzel:wght@400;700&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
|
After Width: | Height: | Size: 330 KiB |
228
refonte_graphique/enclos.html
Executable 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&family=Outfit:wght@400;600;700&family=Material+Icons+Round&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
|
After Width: | Height: | Size: 260 KiB |
BIN
refonte_graphique/icone_archive_obsidienne.ico
Executable file
|
After Width: | Height: | Size: 207 KiB |
BIN
refonte_graphique/icone_sidebar.png
Executable file
|
After Width: | Height: | Size: 438 KiB |
396
refonte_graphique/inventaire.html
Executable 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 & 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&family=Inter:wght@300;400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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 & 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 & É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
|
After Width: | Height: | Size: 459 KiB |
342
refonte_graphique/palette_couleurs_typo.html
Executable 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&family=Inter:wght@100..900&family=Plus+Jakarta+Sans:wght@200..800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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 & 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 & 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
@ -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&family=Inter:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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 & 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
|
After Width: | Height: | Size: 321 KiB |
157
refonte_graphique/parametres_modal.html
Executable 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&family=Inter:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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>
|
||||
BIN
refonte_graphique/parametres_modal.png
Executable file
|
After Width: | Height: | Size: 325 KiB |
464
refonte_graphique/reapprovisionnement.html
Executable 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&family=Inter:wght@300;400;500;600&family=Material+Icons+Round&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 & 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 & 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>
|
||||
BIN
refonte_graphique/reapprovisionnement.png
Executable file
|
After Width: | Height: | Size: 297 KiB |
203
refonte_graphique/sidebar.html
Executable 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&family=Inter:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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
|
After Width: | Height: | Size: 108 KiB |
225
refonte_graphique/statistiques.html
Executable 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&family=Inter:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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>
|
||||
BIN
refonte_graphique/statistiques.png
Executable file
|
After Width: | Height: | Size: 233 KiB |
305
refonte_graphique/workflows_detail.html
Executable 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&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&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>
|
||||
BIN
refonte_graphique/workflows_detail.png
Executable file
|
After Width: | Height: | Size: 280 KiB |
231
refonte_graphique/workflows_summary.html
Executable 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&family=Inter:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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>
|
||||
BIN
refonte_graphique/workflows_summary.png
Executable file
|
After Width: | Height: | Size: 294 KiB |
458
src/presentation/components/AccouplementView.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
365
src/presentation/components/App.ts
Normal 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">☰</button>
|
||||
<div class="app-header-text">
|
||||
<h1 class="app-title"><span class="app-title-icon">⚔</span> Minuteur Dragodinde</h1>
|
||||
<p class="app-subtitle">Dofus 3 · Gestion multi-enclos en temps ré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">✕</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 = '';
|
||||
}
|
||||
}
|
||||
79
src/presentation/components/ConfirmModal.ts
Normal 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());
|
||||
});
|
||||
},
|
||||
};
|
||||
254
src/presentation/components/Dashboard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
466
src/presentation/components/DragodindeCard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
445
src/presentation/components/EnclosView.ts
Normal 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 été 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;
|
||||
}
|
||||
}
|
||||
48
src/presentation/components/GaugePill.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
449
src/presentation/components/InventaireView.ts
Normal 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">×${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;
|
||||
}
|
||||
}
|
||||
375
src/presentation/components/ParametresView.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
471
src/presentation/components/ReapproView.ts
Normal 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">×${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 };
|
||||
}
|
||||
}
|
||||
126
src/presentation/components/Sidebar.ts
Normal 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'é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;
|
||||
}
|
||||
}
|
||||
490
src/presentation/components/StatistiquesView.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
97
src/presentation/components/Toast.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
118
src/presentation/components/UpdateBanner.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.el?.remove();
|
||||
this.el = null;
|
||||
}
|
||||
}
|
||||
693
src/presentation/components/WorkflowsView.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
79
src/presentation/helpers/dd-image.ts
Normal file
21
src/presentation/helpers/format.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
272
src/presentation/helpers/gauge-live.ts
Normal 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
@ -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);
|
||||
79
src/presentation/services/UndoManager.ts
Normal 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 été 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 été 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;
|
||||
},
|
||||
};
|
||||
36
src/presentation/state/UIState.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/presentation/styles/base.css
Normal 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); }
|
||||
466
src/presentation/styles/components.css
Normal 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; }
|
||||
4
src/presentation/styles/index.css
Normal file
@ -0,0 +1,4 @@
|
||||
@import './variables.css';
|
||||
@import './base.css';
|
||||
@import './components.css';
|
||||
@import './obsidienne.css';
|
||||
5263
src/presentation/styles/obsidienne.css
Normal file
44
src/presentation/styles/variables.css
Normal 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
|
After Width: | Height: | Size: 438 KiB |