feat: guide drawer inline + hook partagé useGuideData
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s

- 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)
This commit is contained in:
2026-03-24 21:32:29 +01:00
parent 84104cd96f
commit dbdc02f4ab
4 changed files with 274 additions and 44 deletions

View File

@@ -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<string, string> = {
common: '#9ca3af', rare: '#5ba4f5', epic: '#a78bfa', legendary: '#f4c94e',
};
function RarityDot({ rarity }: { rarity: string }) {
return <span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: RARITY_COLORS[rarity] ?? '#6b7a99', marginRight: 4 }} />;
}
export function GuideDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
const [search, setSearch] = useState('');
const inputRef = useRef<HTMLInputElement>(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 */}
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
zIndex: 90, transition: 'opacity 0.2s',
}}
/>
{/* Drawer */}
<div style={{
position: 'fixed', top: 0, right: 0, bottom: 0, width: 380,
background: '#0d0f14', borderLeft: '1px solid #2a3448',
zIndex: 100, display: 'flex', flexDirection: 'column',
animation: 'slideIn 0.2s ease-out',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 16px', borderBottom: '1px solid #2a3448',
}}>
<span style={{ fontWeight: 700, color: '#f4c94e', fontSize: 14 }}>📖 Guide rapide</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#6b7a99', cursor: 'pointer', padding: 4 }}>
<X size={16} />
</button>
</div>
{/* Search */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid #1e2535' }}>
<div style={{ position: 'relative' }}>
<Search size={13} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: '#6b7a99' }} />
<input
ref={inputRef}
type="text"
value={search}
onChange={e => 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',
}}
/>
</div>
{q && (
<div style={{ fontSize: 10, color: '#6b7a99', marginTop: 4 }}>
{totalResults} résultat{totalResults !== 1 ? 's' : ''}
</div>
)}
</div>
{/* Results */}
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 16px' }}>
{!q && (
<div style={{ textAlign: 'center', padding: '2rem 0', color: '#6b7a99', fontSize: 12 }}>
Tapez pour rechercher dans le guide
</div>
)}
{noResults && (
<div style={{ textAlign: 'center', padding: '2rem 0', color: '#6b7a99', fontSize: 12 }}>
Aucun résultat pour « {search} »
</div>
)}
{/* Monstres */}
{q && filteredMonsters.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', marginBottom: 6 }}>
Monstres ({filteredMonsters.length})
</div>
{filteredMonsters.map(m => (
<div key={m.id} style={{ padding: '6px 0', borderBottom: '1px solid #1e2535', fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#dce4f0', fontWeight: 600 }}>{m.name}</span>
<span style={{ color: '#6b7a99', fontSize: 10 }}>Niv. {m.minLevel}{m.maxLevel}</span>
</div>
<div style={{ color: '#6b7a99', fontSize: 10, marginTop: 2 }}>
{m.hp} {m.attack} 🛡{m.defense} · {m.xpReward}xp · 💰{m.goldMin}{m.goldMax}
{m.dropMaterialId && matMap.get(m.dropMaterialId) && (
<span style={{ color: '#f4c94e' }}> · 🎁 {matMap.get(m.dropMaterialId)!.name}</span>
)}
</div>
</div>
))}
</div>
)}
{/* Items */}
{q && filteredItems.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', marginBottom: 6 }}>
Équipement ({filteredItems.length})
</div>
{filteredItems.map(item => (
<div key={item.id} style={{ padding: '6px 0', borderBottom: '1px solid #1e2535', fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: RARITY_COLORS[item.rarity], fontWeight: 600 }}>
<RarityDot rarity={item.rarity} />
{item.type === 'weapon' ? '⚔️' : item.type === 'armor' ? '🛡️' : '🧪'} {item.name}
</span>
</div>
<div style={{ color: '#6b7a99', fontSize: 10, marginTop: 2 }}>
{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'}
</div>
</div>
))}
</div>
)}
{/* Recettes */}
{q && filteredRecipes.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', marginBottom: 6 }}>
Recettes ({filteredRecipes.length})
</div>
{filteredRecipes.map(r => (
<div key={r.id} style={{ padding: '6px 0', borderBottom: '1px solid #1e2535', fontSize: 12 }}>
<div style={{ color: RARITY_COLORS[r.resultItem?.rarity] ?? '#dce4f0', fontWeight: 600 }}>
<RarityDot rarity={r.resultItem?.rarity ?? 'common'} />
{r.resultItem?.name ?? r.name}
</div>
<div style={{ color: '#6b7a99', fontSize: 10, marginTop: 2 }}>
{r.ingredients.map((ing, i) => (
<span key={i}>{i > 0 ? ' + ' : ''}{ing.quantity}× {matMap.get(ing.materialId)?.name ?? '???'}</span>
))}
<span> · {r.craftDurationSeconds}s · {r.enduranceCost}</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div style={{
padding: '10px 16px', borderTop: '1px solid #2a3448',
textAlign: 'center',
}}>
<a href="/guide" style={{ fontSize: 11, color: '#6b7a99', textDecoration: 'none' }}>
Ouvrir le guide complet
</a>
</div>
</div>
</>
);
}

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Swords, Package, Hammer, User, LogOut, Shield, Scroll, Trophy, ShoppingBag, BookOpen } from 'lucide-react'; import { Swords, Package, Hammer, User, LogOut, Shield, Scroll, Trophy, ShoppingBag, BookOpen } from 'lucide-react';
import { HudBar } from './HudBar'; import { HudBar } from './HudBar';
import { GuideDrawer } from './GuideDrawer';
const NAV = [ const NAV = [
{ to: '/dashboard', icon: User, label: 'Personnage' }, { to: '/dashboard', icon: User, label: 'Personnage' },
@@ -17,6 +19,7 @@ const NAV = [
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const loc = useLocation(); const loc = useLocation();
const [guideOpen, setGuideOpen] = useState(false);
return ( return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}> <div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
@@ -84,21 +87,25 @@ export function Layout({ children }: { children: React.ReactNode }) {
); );
})} })}
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<Link to="/guide" title="Guide" style={{ <button
display: 'flex', onClick={() => setGuideOpen(true)}
alignItems: 'center', title="Guide rapide"
justifyContent: 'center', style={{
width: 40, display: 'flex',
height: 40, alignItems: 'center',
borderRadius: 8, justifyContent: 'center',
color: loc.pathname === '/guide' ? '#f4c94e' : '#6b7a99', width: 40,
background: loc.pathname === '/guide' ? '#1e2535' : 'transparent', height: 40,
border: loc.pathname === '/guide' ? '1px solid #c49c2e' : '1px solid transparent', borderRadius: 8,
textDecoration: 'none', color: guideOpen ? '#f4c94e' : '#6b7a99',
transition: 'all 0.15s', background: guideOpen ? '#1e2535' : 'transparent',
}}> border: guideOpen ? '1px solid #c49c2e' : '1px solid transparent',
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
<BookOpen size={18} /> <BookOpen size={18} />
</Link> </button>
</nav> </nav>
{/* Main content */} {/* Main content */}
@@ -106,6 +113,8 @@ export function Layout({ children }: { children: React.ReactNode }) {
{children} {children}
</main> </main>
</div> </div>
<GuideDrawer open={guideOpen} onClose={() => setGuideOpen(false)} />
</div> </div>
); );
} }

View File

@@ -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<Item[]>('/items'),
materials: () => api.get<any[]>('/materials'),
recipes: () => api.get<Recipe[]>('/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<string, any>(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 };
}

View File

@@ -1,17 +1,7 @@
import { useState, useMemo } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
import type { Monster, Item, Recipe } from '../api/types'; import type { Monster, Item, Recipe } from '../api/types';
import { Swords, Shield, Map as MapIcon, Hammer, ShoppingBag, BookOpen, Sparkles, Search } from 'lucide-react'; import { Swords, Shield, Map as MapIcon, Hammer, ShoppingBag, BookOpen, Sparkles, Search } from 'lucide-react';
import { useGuideData } from '../hooks/useGuideData';
// ── Data fetching (public endpoints, no auth) ──
const guideApi = {
monsters: () => api.get<(Monster & { zone: string })[]>('/monsters/bestiary'),
items: () => api.get<Item[]>('/items'),
materials: () => api.get<any[]>('/materials'),
recipes: () => api.get<Recipe[]>('/craft/recipes'),
};
// ── Constants ── // ── Constants ──
@@ -420,24 +410,7 @@ function ShopTab({ items }: { items: Item[] }) {
export function GuidePage() { export function GuidePage() {
const [tab, setTab] = useState('start'); const [tab, setTab] = useState('start');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const { materials, filteredMonsters, filteredItems, filteredRecipes, q } = useGuideData(search);
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<string, any>(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]);
return ( return (
<div style={{ maxWidth: 900, margin: '0 auto', padding: '2rem 1rem' }}> <div style={{ maxWidth: 900, margin: '0 auto', padding: '2rem 1rem' }}>
@@ -484,6 +457,13 @@ export function GuidePage() {
{TABS.map(t => { {TABS.map(t => {
const Icon = t.icon; const Icon = t.icon;
const active = tab === t.id; 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 ( return (
<button <button
key={t.id} key={t.id}
@@ -500,6 +480,13 @@ export function GuidePage() {
> >
<Icon size={14} /> <Icon size={14} />
{t.label} {t.label}
{count !== null && count > 0 && (
<span style={{
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 8,
background: active ? '#f4c94e33' : '#2a3448', color: active ? '#f4c94e' : '#9ca3af',
minWidth: 16, textAlign: 'center',
}}>{count}</span>
)}
</button> </button>
); );
})} })}