From dbdc02f4ab31df90360847b26fc40f4b9e2b6515 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Tue, 24 Mar 2026 21:32:29 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20guide=20drawer=20inline=20+=20hook=20pa?= =?UTF-8?q?rtag=C3=A9=20useGuideData?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GuideDrawer: panneau coulissant depuis la sidebar, recherche live - useGuideData: hook unique — même cache React Query pour drawer + page - Sidebar: BookOpen toggle le drawer (pas de navigation) - Footer drawer: lien vers /guide complet - GuidePage refactorisée sur useGuideData (zéro duplication) --- frontend/src/components/GuideDrawer.tsx | 191 ++++++++++++++++++++++++ frontend/src/components/Layout.tsx | 37 +++-- frontend/src/hooks/useGuideData.ts | 43 ++++++ frontend/src/pages/GuidePage.tsx | 47 +++--- 4 files changed, 274 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/GuideDrawer.tsx create mode 100644 frontend/src/hooks/useGuideData.ts diff --git a/frontend/src/components/GuideDrawer.tsx b/frontend/src/components/GuideDrawer.tsx new file mode 100644 index 0000000..344bee7 --- /dev/null +++ b/frontend/src/components/GuideDrawer.tsx @@ -0,0 +1,191 @@ +import { useState, useEffect, useRef } from 'react'; +import { Search, X } from 'lucide-react'; +import { useGuideData } from '../hooks/useGuideData'; + +const RARITY_COLORS: Record = { + common: '#9ca3af', rare: '#5ba4f5', epic: '#a78bfa', legendary: '#f4c94e', +}; + +function RarityDot({ rarity }: { rarity: string }) { + return ; +} + +export function GuideDrawer({ open, onClose }: { open: boolean; onClose: () => void }) { + const [search, setSearch] = useState(''); + const inputRef = useRef(null); + const { filteredMonsters, filteredItems, filteredRecipes, matMap, q } = useGuideData(search); + + useEffect(() => { + if (open) { + setSearch(''); + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [open]); + + // Escape to close + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open, onClose]); + + if (!open) return null; + + const hasResults = q && (filteredMonsters.length > 0 || filteredItems.length > 0 || filteredRecipes.length > 0); + const noResults = q && !hasResults; + const totalResults = q ? filteredMonsters.length + filteredItems.length + filteredRecipes.length : 0; + + return ( + <> + {/* Backdrop */} +
+ + {/* Drawer */} +
+ {/* Header */} +
+ 📖 Guide rapide + +
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Monstre, item, matériau, recette…" + style={{ + width: '100%', padding: '8px 10px 8px 30px', fontSize: 12, + background: '#1e2535', border: '1px solid #2a3448', borderRadius: 6, + color: '#dce4f0', outline: 'none', boxSizing: 'border-box', + }} + /> +
+ {q && ( +
+ {totalResults} résultat{totalResults !== 1 ? 's' : ''} +
+ )} +
+ + {/* Results */} +
+ {!q && ( +
+ Tapez pour rechercher dans le guide +
+ )} + + {noResults && ( +
+ Aucun résultat pour « {search} » +
+ )} + + {/* Monstres */} + {q && filteredMonsters.length > 0 && ( +
+
+ Monstres ({filteredMonsters.length}) +
+ {filteredMonsters.map(m => ( +
+
+ {m.name} + Niv. {m.minLevel}–{m.maxLevel} +
+
+ ❤️{m.hp} ⚔️{m.attack} 🛡️{m.defense} · ⭐{m.xpReward}xp · 💰{m.goldMin}–{m.goldMax} + {m.dropMaterialId && matMap.get(m.dropMaterialId) && ( + · 🎁 {matMap.get(m.dropMaterialId)!.name} + )} +
+
+ ))} +
+ )} + + {/* Items */} + {q && filteredItems.length > 0 && ( +
+
+ Équipement ({filteredItems.length}) +
+ {filteredItems.map(item => ( +
+
+ + + {item.type === 'weapon' ? '⚔️' : item.type === 'armor' ? '🛡️' : '🧪'} {item.name} + +
+
+ {item.attackBonus > 0 && `ATK+${item.attackBonus} `} + {item.defenseBonus > 0 && `DEF+${item.defenseBonus} `} + {item.agiliteBonus > 0 && `AGI+${item.agiliteBonus} `} + {item.intelligenceBonus > 0 && `INT+${item.intelligenceBonus} `} + {(item as any).buyPrice > 0 ? `· 💰${(item as any).buyPrice}` : '· 🔨 Craft'} +
+
+ ))} +
+ )} + + {/* Recettes */} + {q && filteredRecipes.length > 0 && ( +
+
+ Recettes ({filteredRecipes.length}) +
+ {filteredRecipes.map(r => ( +
+
+ + {r.resultItem?.name ?? r.name} +
+
+ {r.ingredients.map((ing, i) => ( + {i > 0 ? ' + ' : ''}{ing.quantity}× {matMap.get(ing.materialId)?.name ?? '???'} + ))} + · ⏱️{r.craftDurationSeconds}s · ⚡{r.enduranceCost} +
+
+ ))} +
+ )} +
+ + {/* Footer */} + +
+ + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a4ab9e5..50bc53f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,7 +1,9 @@ +import { useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { Swords, Package, Hammer, User, LogOut, Shield, Scroll, Trophy, ShoppingBag, BookOpen } from 'lucide-react'; import { HudBar } from './HudBar'; +import { GuideDrawer } from './GuideDrawer'; const NAV = [ { to: '/dashboard', icon: User, label: 'Personnage' }, @@ -17,6 +19,7 @@ const NAV = [ export function Layout({ children }: { children: React.ReactNode }) { const { user, logout } = useAuth(); const loc = useLocation(); + const [guideOpen, setGuideOpen] = useState(false); return (
@@ -84,21 +87,25 @@ export function Layout({ children }: { children: React.ReactNode }) { ); })}
- + {/* Main content */} @@ -106,6 +113,8 @@ export function Layout({ children }: { children: React.ReactNode }) { {children}
+ + setGuideOpen(false)} />
); } diff --git a/frontend/src/hooks/useGuideData.ts b/frontend/src/hooks/useGuideData.ts new file mode 100644 index 0000000..2afb095 --- /dev/null +++ b/frontend/src/hooks/useGuideData.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../api/client'; +import type { Monster, Item, Recipe } from '../api/types'; + +const guideApi = { + monsters: () => api.get<(Monster & { zone: string })[]>('/monsters/bestiary'), + items: () => api.get('/items'), + materials: () => api.get('/materials'), + recipes: () => api.get('/craft/recipes'), +}; + +export function useGuideData(search: string) { + const { data: monsters = [] } = useQuery({ queryKey: ['guide-monsters'], queryFn: guideApi.monsters, staleTime: 5 * 60_000 }); + const { data: items = [] } = useQuery({ queryKey: ['guide-items'], queryFn: guideApi.items, staleTime: 5 * 60_000 }); + const { data: materials = [] } = useQuery({ queryKey: ['guide-materials'], queryFn: guideApi.materials, staleTime: 5 * 60_000 }); + const { data: recipes = [] } = useQuery({ queryKey: ['guide-recipes'], queryFn: guideApi.recipes, staleTime: 5 * 60_000 }); + + const q = search.toLowerCase().trim(); + + const filteredMonsters = useMemo( + () => q ? monsters.filter(m => m.name.toLowerCase().includes(q) || (m as any).zone?.toLowerCase().includes(q)) : monsters, + [monsters, q], + ); + + const filteredItems = useMemo( + () => q ? items.filter(i => i.name.toLowerCase().includes(q) || i.rarity.toLowerCase().includes(q) || i.description?.toLowerCase().includes(q)) : items, + [items, q], + ); + + const matMap = useMemo(() => new Map(materials.map(m => [m.id, m])), [materials]); + + const filteredRecipes = useMemo(() => { + if (!q) return recipes; + return recipes.filter(r => + r.name.toLowerCase().includes(q) || + r.resultItem?.name.toLowerCase().includes(q) || + r.ingredients.some(ing => matMap.get(ing.materialId)?.name.toLowerCase().includes(q)) + ); + }, [recipes, matMap, q]); + + return { monsters, items, materials, recipes, filteredMonsters, filteredItems, filteredRecipes, matMap, q }; +} diff --git a/frontend/src/pages/GuidePage.tsx b/frontend/src/pages/GuidePage.tsx index 62442b9..cafd1b4 100644 --- a/frontend/src/pages/GuidePage.tsx +++ b/frontend/src/pages/GuidePage.tsx @@ -1,17 +1,7 @@ -import { useState, useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { api } from '../api/client'; +import { useState } from 'react'; import type { Monster, Item, Recipe } from '../api/types'; import { Swords, Shield, Map as MapIcon, Hammer, ShoppingBag, BookOpen, Sparkles, Search } from 'lucide-react'; - -// ── Data fetching (public endpoints, no auth) ── - -const guideApi = { - monsters: () => api.get<(Monster & { zone: string })[]>('/monsters/bestiary'), - items: () => api.get('/items'), - materials: () => api.get('/materials'), - recipes: () => api.get('/craft/recipes'), -}; +import { useGuideData } from '../hooks/useGuideData'; // ── Constants ── @@ -420,24 +410,7 @@ function ShopTab({ items }: { items: Item[] }) { export function GuidePage() { const [tab, setTab] = useState('start'); const [search, setSearch] = useState(''); - - const { data: monsters = [] } = useQuery({ queryKey: ['guide-monsters'], queryFn: guideApi.monsters }); - const { data: items = [] } = useQuery({ queryKey: ['guide-items'], queryFn: guideApi.items }); - const { data: materials = [] } = useQuery({ queryKey: ['guide-materials'], queryFn: guideApi.materials }); - const { data: recipes = [] } = useQuery({ queryKey: ['guide-recipes'], queryFn: guideApi.recipes }); - - const q = search.toLowerCase().trim(); - const filteredMonsters = useMemo(() => q ? monsters.filter(m => m.name.toLowerCase().includes(q) || m.zone?.toLowerCase().includes(q)) : monsters, [monsters, q]); - const filteredItems = useMemo(() => q ? items.filter(i => i.name.toLowerCase().includes(q) || i.rarity.toLowerCase().includes(q) || i.description?.toLowerCase().includes(q)) : items, [items, q]); - const filteredRecipes = useMemo(() => { - if (!q) return recipes; - const matMap = new Map(materials.map(m => [m.id, m])); - return recipes.filter(r => - r.name.toLowerCase().includes(q) || - r.resultItem?.name.toLowerCase().includes(q) || - r.ingredients.some(ing => matMap.get(ing.materialId)?.name.toLowerCase().includes(q)) - ); - }, [recipes, materials, q]); + const { materials, filteredMonsters, filteredItems, filteredRecipes, q } = useGuideData(search); return (
@@ -484,6 +457,13 @@ export function GuidePage() { {TABS.map(t => { const Icon = t.icon; const active = tab === t.id; + const count = q ? ( + t.id === 'bestiary' ? filteredMonsters.length : + t.id === 'items' ? filteredItems.filter(i => i.type !== 'consumable').length : + t.id === 'craft' ? filteredRecipes.length : + t.id === 'shop' ? filteredItems.filter(i => (i as any).buyPrice > 0).length : + null + ) : null; return ( ); })}