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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user