feat: guide drawer inline + hook partagé useGuideData
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
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:
191
frontend/src/components/GuideDrawer.tsx
Normal file
191
frontend/src/components/GuideDrawer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -84,21 +87,25 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
})}
|
||||
<div style={{ flex: 1 }} />
|
||||
<Link to="/guide" title="Guide" style={{
|
||||
<button
|
||||
onClick={() => setGuideOpen(true)}
|
||||
title="Guide rapide"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
color: loc.pathname === '/guide' ? '#f4c94e' : '#6b7a99',
|
||||
background: loc.pathname === '/guide' ? '#1e2535' : 'transparent',
|
||||
border: loc.pathname === '/guide' ? '1px solid #c49c2e' : '1px solid transparent',
|
||||
textDecoration: 'none',
|
||||
color: guideOpen ? '#f4c94e' : '#6b7a99',
|
||||
background: guideOpen ? '#1e2535' : 'transparent',
|
||||
border: guideOpen ? '1px solid #c49c2e' : '1px solid transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<BookOpen size={18} />
|
||||
</Link>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
@@ -106,6 +113,8 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<GuideDrawer open={guideOpen} onClose={() => setGuideOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
43
frontend/src/hooks/useGuideData.ts
Normal file
43
frontend/src/hooks/useGuideData.ts
Normal 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 };
|
||||
}
|
||||
@@ -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<Item[]>('/items'),
|
||||
materials: () => api.get<any[]>('/materials'),
|
||||
recipes: () => api.get<Recipe[]>('/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<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]);
|
||||
const { materials, filteredMonsters, filteredItems, filteredRecipes, q } = useGuideData(search);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900, margin: '0 auto', padding: '2rem 1rem' }}>
|
||||
@@ -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 (
|
||||
<button
|
||||
key={t.id}
|
||||
@@ -500,6 +480,13 @@ export function GuidePage() {
|
||||
>
|
||||
<Icon size={14} />
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user