feat: HUD bar — stats persistantes sous le header
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:
2026-03-24 17:03:31 +01:00
parent 9fac9e123b
commit cfdc5c9b02
2 changed files with 130 additions and 0 deletions

View 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>
);
}

View File

@@ -1,6 +1,7 @@
import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Swords, Package, Hammer, User, LogOut, Shield, Scroll } from 'lucide-react';
import { HudBar } from './HudBar';
const NAV = [
{ to: '/dashboard', icon: User, label: 'Personnage' },
@@ -44,6 +45,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
)}
</header>
<HudBar />
<div style={{ display: 'flex', flex: 1 }}>
{/* Sidebar nav */}
<nav style={{