feat: HUD bar — stats persistantes sous le header
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
Barre compacte toujours visible : nom+level, HP, endurance+timer regen, XP, or, quêtes actives (avec compteur "prêtes !"). Timer live : "+1 dans X:XX" quand endurance < max. Auto-refresh 30s pour l'endurance, 60s pour les quêtes. Chaque section cliquable vers la page correspondante.
This commit is contained in:
128
frontend/src/components/HudBar.tsx
Normal file
128
frontend/src/components/HudBar.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { characterApi, questApi } from '../api/endpoints';
|
||||||
|
import { Heart, Zap, Star, Coins, Scroll, Clock } from 'lucide-react';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
function RegenTimer({ endurance, enduranceMax, lastEnduranceTs }: { endurance: number; enduranceMax: number; lastEnduranceTs: string }) {
|
||||||
|
const [now, setNow] = useState(Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (endurance >= enduranceMax) return;
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [endurance, enduranceMax]);
|
||||||
|
|
||||||
|
if (endurance >= enduranceMax) return null;
|
||||||
|
|
||||||
|
// Regen = 1pt every 3min = 180s
|
||||||
|
const elapsedMs = now - new Date(lastEnduranceTs).getTime();
|
||||||
|
const elapsedInCycle = elapsedMs % (3 * 60 * 1000);
|
||||||
|
const remainingMs = 3 * 60 * 1000 - elapsedInCycle;
|
||||||
|
const remainingSec = Math.max(0, Math.floor(remainingMs / 1000));
|
||||||
|
const min = Math.floor(remainingSec / 60);
|
||||||
|
const sec = remainingSec % 60;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span style={{ fontSize: 9, color: '#5ba4f5' }}>
|
||||||
|
<Clock size={8} style={{ display: 'inline', marginRight: 2 }} />
|
||||||
|
+1 dans {min}:{sec.toString().padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HudBar() {
|
||||||
|
const { data: char } = useQuery({
|
||||||
|
queryKey: ['character'],
|
||||||
|
queryFn: characterApi.me,
|
||||||
|
refetchInterval: 30_000, // refresh every 30s for endurance updates
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: activeQuests } = useQuery({
|
||||||
|
queryKey: ['questsActive'],
|
||||||
|
queryFn: questApi.active,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!char) return null;
|
||||||
|
|
||||||
|
const endurance = (char as any).enduranceCurrent ?? (char as any).endurance ?? 0;
|
||||||
|
const xpNext = (char as any).xpToNextLevel ?? Math.round(100 * Math.pow(char.level, 1.5));
|
||||||
|
const questCount = activeQuests?.filter((pq: any) => pq.status === 'active').length ?? 0;
|
||||||
|
const questReady = activeQuests?.filter((pq: any) => pq.status === 'completed').length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#111620',
|
||||||
|
borderBottom: '1px solid #1e2535',
|
||||||
|
padding: '4px 1rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#6b7a99',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
{/* Name + Level */}
|
||||||
|
<Link to="/dashboard" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontSize: 14 }}>🐸</span>
|
||||||
|
<span style={{ fontWeight: 700, color: '#dce4f0', fontSize: 12 }}>{char.name}</span>
|
||||||
|
<span style={{ color: '#6b7a99' }}>Niv.{char.level}</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<span style={{ color: '#2a3448' }}>|</span>
|
||||||
|
|
||||||
|
{/* HP */}
|
||||||
|
<Link to="/dashboard" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Heart size={10} color="#e84040" />
|
||||||
|
<span style={{ color: char.hpCurrent < char.hpMax ? '#e84040' : '#6b7a99' }}>
|
||||||
|
{char.hpCurrent}/{char.hpMax}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<span style={{ color: '#2a3448' }}>|</span>
|
||||||
|
|
||||||
|
{/* Endurance + timer */}
|
||||||
|
<Link to="/dashboard" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Zap size={10} color="#5ba4f5" />
|
||||||
|
<span style={{ color: endurance < 5 ? '#e84040' : '#6b7a99' }}>
|
||||||
|
{endurance}/{char.enduranceMax}
|
||||||
|
</span>
|
||||||
|
{(char as any).lastEnduranceTs && (
|
||||||
|
<RegenTimer
|
||||||
|
endurance={endurance}
|
||||||
|
enduranceMax={char.enduranceMax}
|
||||||
|
lastEnduranceTs={(char as any).lastEnduranceTs}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<span style={{ color: '#2a3448' }}>|</span>
|
||||||
|
|
||||||
|
{/* XP */}
|
||||||
|
<Link to="/dashboard" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Star size={10} color="#a78bfa" />
|
||||||
|
<span>{char.xp}/{xpNext}</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<span style={{ color: '#2a3448' }}>|</span>
|
||||||
|
|
||||||
|
{/* Gold */}
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Coins size={10} color="#f4c94e" />
|
||||||
|
<span>{char.gold}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span style={{ color: '#2a3448' }}>|</span>
|
||||||
|
|
||||||
|
{/* Quests */}
|
||||||
|
<Link to="/quests" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Scroll size={10} color={questReady > 0 ? '#f4c94e' : '#6b7a99'} />
|
||||||
|
<span>{questCount} quête{questCount !== 1 ? 's' : ''}</span>
|
||||||
|
{questReady > 0 && (
|
||||||
|
<span style={{ color: '#f4c94e', fontWeight: 700 }}>({questReady} prête{questReady > 1 ? 's' : ''} !)</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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 } from 'lucide-react';
|
import { Swords, Package, Hammer, User, LogOut, Shield, Scroll } from 'lucide-react';
|
||||||
|
import { HudBar } from './HudBar';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/dashboard', icon: User, label: 'Personnage' },
|
{ to: '/dashboard', icon: User, label: 'Personnage' },
|
||||||
@@ -44,6 +45,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<HudBar />
|
||||||
<div style={{ display: 'flex', flex: 1 }}>
|
<div style={{ display: 'flex', flex: 1 }}>
|
||||||
{/* Sidebar nav */}
|
{/* Sidebar nav */}
|
||||||
<nav style={{
|
<nav style={{
|
||||||
|
|||||||
Reference in New Issue
Block a user