feat: PKCE auth + CI/CD deploy
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 1m2s

- Frontend: PKCE flow (oauth.ts, AuthCallback code exchange, 401 interceptor)
- Backend: token introspection via SuperOAuth (no more JWT secret)
- User model: superOauthId (unified) replaces oauthId+provider
- Cookies httpOnly session + refresh token
- POST /auth/refresh endpoint
- Gitea CI workflow (vps-runner pattern)
- DB_SYNC env var for initial schema creation
This commit is contained in:
2026-03-24 13:01:14 +01:00
parent c1bf793234
commit 8c6777c980
61 changed files with 5850 additions and 66 deletions

View File

@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { exchangeCode, loadVerifier, clearVerifier } from '../lib/oauth';
import { authApi } from '../api/endpoints';
import { useAuth } from '../context/AuthContext';
export function AuthCallback() {
const navigate = useNavigate();
const { refresh } = useAuth();
const called = useRef(false);
const [status, setStatus] = useState<'loading' | 'error'>('loading');
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (called.current) return;
called.current = true;
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const error = params.get('error');
if (error) {
setStatus('error');
setErrorMsg(error);
return;
}
if (!code) {
navigate('/login?error=no_code', { replace: true });
return;
}
const verifier = loadVerifier();
if (!verifier) {
navigate('/login?error=no_verifier', { replace: true });
return;
}
const redirectUri = `${window.location.origin}/auth/callback`;
exchangeCode(code, verifier, redirectUri)
.then((tokens) => {
clearVerifier();
return authApi.setSession(tokens.access_token, tokens.refresh_token);
})
.then(() => refresh())
.then(() => navigate('/dashboard', { replace: true }))
.catch(() => {
clearVerifier();
navigate('/login?error=session_failed', { replace: true });
});
}, [navigate, refresh]);
if (status === 'error') {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 40, marginBottom: 16 }}>💀</div>
<p style={{ color: '#ef4444', fontSize: 14, marginBottom: 8 }}>Erreur d'authentification</p>
<p style={{ color: '#6b7a99', fontSize: 12 }}>{errorMsg}</p>
</div>
</div>
);
}
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 40, marginBottom: 16 }}>⚔️</div>
<p style={{ color: '#6b7a99', fontSize: 14 }}>Connexion en cours</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { combatApi } from '../api/endpoints';
import type { Monster, CombatResult } from '../api/types';
import { Swords, Trophy, Skull, Clock } from 'lucide-react';
const ATTACK_TYPES = [
{ id: 'melee', label: 'Mêlée', emoji: '⚔️', stat: 'Force × 1.5' },
{ id: 'ranged', label: 'Distance', emoji: '🏹', stat: 'Agilité × 1.5' },
{ id: 'magic', label: 'Magie', emoji: '✨', stat: 'Intelligence × 1.5' },
];
function MonsterCard({ m, selected, onSelect }: { m: Monster; selected: boolean; onSelect: () => void }) {
return (
<div
className={`card card-hover ${selected ? 'card-gold' : ''}`}
onClick={onSelect}
style={{ cursor: 'pointer', transition: 'all 0.15s' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 6 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: selected ? '#f4c94e' : '#dce4f0' }}>{m.name}</span>
<span className="badge badge-red" style={{ fontSize: 10 }}>Niv. {m.levelMin}{m.levelMax}</span>
</div>
<div style={{ display: 'flex', gap: 12, fontSize: 12, color: '#6b7a99' }}>
<span> {m.hp}</span>
<span> {m.attack}</span>
<span>🛡 {m.defense}</span>
<span> {m.xpReward} XP</span>
<span>💰 {m.goldMin}{m.goldMax}</span>
</div>
</div>
);
}
function CombatLog({ result }: { result: CombatResult }) {
const won = result.winner === 'player';
return (
<div className="card" style={{ marginTop: '1rem' }}>
{/* Résultat */}
<div style={{ textAlign: 'center', padding: '0.75rem 0', marginBottom: '0.75rem', borderBottom: '1px solid #2a3448' }}>
{won
? <div style={{ color: '#3ddc84', fontWeight: 800, fontSize: 18 }}>
<Trophy size={20} style={{ display: 'inline', marginRight: 8 }} />
Victoire ! +{result.xpGained} XP +{result.goldGained} or
</div>
: <div style={{ color: '#e84040', fontWeight: 800, fontSize: 18 }}>
<Skull size={20} style={{ display: 'inline', marginRight: 8 }} />
Défaite 50 endurance
</div>
}
{result.loot && (
<div style={{ fontSize: 13, color: '#f4c94e', marginTop: 4 }}>
🎁 Loot : {result.loot.material.name} ×{result.loot.quantity}
</div>
)}
</div>
{/* Log de combat */}
<p style={{ margin: '0 0 6px', fontSize: 12, fontWeight: 700, color: '#6b7a99' }}>
Log {result.rounds.length} tour{result.rounds.length > 1 ? 's' : ''}
</p>
<div className="combat-log">
{result.rounds.flatMap(r =>
r.log.map((line, i) => {
const cls = line.includes('frappe') && !line.includes('Monstre') ? 'log-player'
: line.includes('Monstre') || line.includes('frappe') ? 'log-monster'
: line.includes('CRITIQUE') ? 'log-crit'
: 'log-system';
return <div key={`${r.round}-${i}`} className={cls}>[T{r.round}] {line}</div>;
})
)}
{won
? <div className="log-system"> Victoire </div>
: <div className="log-monster"> Défaite </div>
}
</div>
</div>
);
}
export function CombatPage() {
const qc = useQueryClient();
const [selectedMonster, setSelectedMonster] = useState<Monster | null>(null);
const [attackType, setAttackType] = useState('melee');
const [lastResult, setLastResult] = useState<CombatResult | null>(null);
const { data: monsters, isLoading } = useQuery({
queryKey: ['monsters'],
queryFn: combatApi.monsters,
});
const { data: history } = useQuery({
queryKey: ['combatHistory'],
queryFn: combatApi.history,
});
const fight = useMutation({
mutationFn: () => combatApi.start(selectedMonster!.id, attackType),
onSuccess: (result) => {
setLastResult(result);
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['combatHistory'] });
},
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement des monstres</div>;
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}> Combat</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
{/* Choix monstre */}
<div>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
Adversaire
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{monsters?.map(m => (
<MonsterCard
key={m.id}
m={m}
selected={selectedMonster?.id === m.id}
onSelect={() => setSelectedMonster(m)}
/>
))}
</div>
</div>
{/* Panneau droite */}
<div>
{/* Type d'attaque */}
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
Type d'attaque
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: '1rem' }}>
{ATTACK_TYPES.map(a => (
<div
key={a.id}
className={`card card-hover ${attackType === a.id ? 'card-gold' : ''}`}
onClick={() => setAttackType(a.id)}
style={{ display: 'flex', alignItems: 'center', gap: 10 }}
>
<span style={{ fontSize: 18 }}>{a.emoji}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 13, color: attackType === a.id ? '#f4c94e' : '#dce4f0' }}>{a.label}</div>
<div style={{ fontSize: 11, color: '#6b7a99' }}>{a.stat}</div>
</div>
</div>
))}
</div>
{/* Bouton combattre */}
<button
className="btn btn-red"
style={{ width: '100%', fontSize: 15, padding: '0.75rem' }}
disabled={!selectedMonster || fight.isPending}
onClick={() => fight.mutate()}
>
{fight.isPending ? (
<span><Swords size={14} style={{ display: 'inline', marginRight: 6 }} />Combat…</span>
) : (
<span>⚔️ Combattre {selectedMonster ? `— ${selectedMonster.name}` : ''}</span>
)}
</button>
{fight.isError && (
<p style={{ color: '#e84040', fontSize: 12, marginTop: 8 }}>{(fight.error as Error).message}</p>
)}
{/* Historique récent */}
{history && history.length > 0 && (
<div style={{ marginTop: '1rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', display:'flex', alignItems:'center', gap:4 }}>
<Clock size={11} /> Historique récent
</p>
<div className="card" style={{ padding: '0.75rem' }}>
{history.slice(0, 5).map(h => (
<div key={h.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '3px 0', borderBottom: '1px solid #1e2535' }}>
<span style={{ color: h.winner === 'player' ? '#3ddc84' : '#e84040' }}>
{h.winner === 'player' ? '' : ''} {h.monsterName ?? 'Monstre'}
</span>
<span style={{ color: '#6b7a99' }}>+{h.xpGained}xp +{h.goldGained}or</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Résultat du dernier combat */}
{lastResult && <CombatLog result={lastResult} />}
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { craftApi, materialApi } from '../api/endpoints';
import type { Recipe, CraftJob } from '../api/types';
import { Hammer, Clock, CheckCircle } from 'lucide-react';
function timeLeft(completedAt: string): string {
const diff = new Date(completedAt).getTime() - Date.now();
if (diff <= 0) return 'Prêt !';
const s = Math.ceil(diff / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
}
function ActiveCraft({ job, onCollect }: { job: CraftJob; onCollect: () => void }) {
const [, tick] = useState(0);
useEffect(() => {
const id = setInterval(() => tick(n => n + 1), 1000);
return () => clearInterval(id);
}, []);
const ready = job.status === 'ready' || new Date(job.completedAt) <= new Date();
return (
<div className={`card ${ready ? 'card-gold' : ''}`} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{ready ? <CheckCircle size={16} color="#3ddc84" /> : <Clock size={16} color="#5ba4f5" />}
<div>
<span style={{ fontWeight: 700, fontSize: 14 }}>{job.recipe.name}</span>
<span style={{ fontSize: 12, color: '#6b7a99', marginLeft: 8 }}>
{job.recipe.resultItem.name}
</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{!ready && <span style={{ fontSize: 13, color: '#5ba4f5', fontFamily: 'monospace' }}>{timeLeft(job.completedAt)}</span>}
<button
className={`btn ${ready ? 'btn-gold' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '0.25rem 0.75rem' }}
disabled={!ready}
onClick={onCollect}
>
{ready ? '⚒️ Collecter' : 'En cours…'}
</button>
</div>
</div>
</div>
);
}
function RecipeCard({ recipe, onCraft, disabled, materials }: {
recipe: Recipe;
onCraft: () => void;
disabled: boolean;
materials: Map<string, number>;
}) {
const canCraft = recipe.ingredients.every(ing => (materials.get(ing.materialId) ?? 0) >= ing.quantity);
return (
<div className={`card ${canCraft ? '' : ''}`} style={{ opacity: canCraft ? 1 : 0.6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 6 }}>
<span style={{ fontWeight: 700, fontSize: 14 }}>{recipe.name}</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{recipe.craftDurationSeconds}s · {recipe.enduranceCost} end.</span>
</div>
<div style={{ fontSize: 12, color: '#6b7a99', marginBottom: 8 }}>
<span style={{ color: '#dce4f0' }}>{recipe.resultItem.name}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 10 }}>
{recipe.ingredients.map(ing => {
const have = materials.get(ing.materialId) ?? 0;
const ok = have >= ing.quantity;
return (
<span key={ing.materialId} className={`badge ${ok ? 'badge-green' : 'badge-red'}`}>
{ing.materialName ?? '?'} {have}/{ing.quantity}
</span>
);
})}
</div>
<button
className="btn btn-gold"
style={{ fontSize: 12, padding: '0.3rem 0.875rem' }}
disabled={!canCraft || disabled}
onClick={onCraft}
>
<Hammer size={12} style={{ display: 'inline', marginRight: 4 }} />
Craft
</button>
</div>
);
}
export function CraftPage() {
const qc = useQueryClient();
const { data: recipes } = useQuery({ queryKey: ['recipes'], queryFn: craftApi.recipes });
const { data: activeCraft, refetch: refetchActive } = useQuery({ queryKey: ['activeCraft'], queryFn: craftApi.active, refetchInterval: 5000 });
const { data: mats } = useQuery({ queryKey: ['materials'], queryFn: materialApi.inventory });
const materialMap = new Map(mats?.map(cm => [cm.material.id, cm.quantity]) ?? []);
const startMut = useMutation({
mutationFn: (recipeId: string) => craftApi.start(recipeId),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['activeCraft'] }); qc.invalidateQueries({ queryKey: ['character'] }); qc.invalidateQueries({ queryKey: ['materials'] }); },
});
const collectMut = useMutation({
mutationFn: (jobId: string) => craftApi.collect(jobId),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['activeCraft'] }); qc.invalidateQueries({ queryKey: ['inventory'] }); refetchActive(); },
});
const hasActive = activeCraft && 'id' in activeCraft;
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>
<Hammer size={18} style={{ display: 'inline', marginRight: 8 }} />Artisanat
</h2>
{hasActive && (
<ActiveCraft
job={activeCraft as CraftJob}
onCollect={() => collectMut.mutate((activeCraft as CraftJob).id)}
/>
)}
{hasActive && (
<div className="card" style={{ marginBottom: '1rem', textAlign: 'center', color: '#6b7a99', fontSize: 13 }}>
Un craft est en cours tu ne peux pas en lancer un autre.
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '0.75rem' }}>
{recipes?.map(r => (
<RecipeCard
key={r.id}
recipe={r}
onCraft={() => startMut.mutate(r.id)}
disabled={hasActive || startMut.isPending}
materials={materialMap}
/>
))}
</div>
{recipes?.length === 0 && (
<div className="card" style={{ textAlign: 'center', padding: '2rem', color: '#6b7a99' }}>
Aucune recette disponible.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,188 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { characterApi } from '../api/endpoints';
import { Bar } from '../components/Bar';
import { Zap, Heart, Star, Coins, Sword, Shield } from 'lucide-react';
const STATS = ['force', 'agilite', 'intelligence', 'chance', 'vitalite'] as const;
const STAT_LABELS: Record<string, string> = {
force: 'Force', agilite: 'Agilité', intelligence: 'Intelligence', chance: 'Chance', vitalite: 'Vitalité',
};
function CreateCharacter() {
const qc = useQueryClient();
const [name, setName] = useState('');
const [pts, setPts] = useState<Record<string, number>>({ force:1, agilite:1, intelligence:1, chance:1, vitalite:1 });
const used = Object.values(pts).reduce((a, b) => a + b, 0) - 5;
const remaining = 5 - used;
const mut = useMutation({
mutationFn: () => characterApi.create(name, pts),
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
});
const adjust = (stat: string, delta: number) => {
const next = (pts[stat] ?? 1) + delta;
if (next < 1 || next > 10) return;
if (delta > 0 && remaining <= 0) return;
setPts(p => ({ ...p, [stat]: next }));
};
return (
<div style={{ maxWidth: 420, margin: '4rem auto' }}>
<div className="card card-gold" style={{ padding: '1.5rem' }}>
<h2 style={{ margin: '0 0 4px', color: '#f4c94e', fontSize: 20 }}>Créer ton personnage</h2>
<p style={{ margin: '0 0 1.25rem', color: '#6b7a99', fontSize: 13 }}>
{remaining > 0 ? `${remaining} point${remaining > 1 ? 's' : ''} à répartir` : 'Tous les points répartis'}
</p>
<input
className="input-rpg"
placeholder="Nom du personnage"
value={name}
onChange={e => setName(e.target.value)}
style={{ marginBottom: '1rem' }}
maxLength={30}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: '1.25rem' }}>
{STATS.map(s => (
<div key={s} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, width: 110, color: '#dce4f0' }}>{STAT_LABELS[s]}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-ghost" style={{ padding: '0.15rem 0.5rem', fontSize: 14 }} onClick={() => adjust(s, -1)}></button>
<span style={{ width: 20, textAlign: 'center', fontWeight: 700, color: '#f4c94e' }}>{pts[s]}</span>
<button className="btn btn-ghost" style={{ padding: '0.15rem 0.5rem', fontSize: 14 }} onClick={() => adjust(s, +1)}>+</button>
</div>
</div>
))}
</div>
<button
className="btn btn-gold"
style={{ width: '100%' }}
disabled={!name.trim() || remaining !== 0 || mut.isPending}
onClick={() => mut.mutate()}
>
{mut.isPending ? 'Création…' : 'Commencer l\'aventure ⚔️'}
</button>
{mut.isError && <p style={{ color: '#e84040', fontSize: 12, marginTop: 8 }}>{(mut.error as Error).message}</p>}
</div>
</div>
);
}
export function DashboardPage() {
const { data: char, isLoading, isError } = useQuery({
queryKey: ['character'],
queryFn: characterApi.me,
retry: 1,
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
if (isError || !char) return <CreateCharacter />;
const xpNext = Math.round(100 * Math.pow(char.level, 1.5));
return (
<div>
{/* Header perso */}
<div className="card card-gold" style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '1rem', padding: '1rem 1.25rem' }}>
<div style={{ fontSize: 48 }}>🐸</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
<h2 style={{ margin: 0, fontSize: 22, color: '#f4c94e' }}>{char.name}</h2>
<span style={{ fontSize: 13, color: '#6b7a99' }}>Niveau {char.level}</span>
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
<Coins size={12} color="#f4c94e" /> {char.gold} or
</span>
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
<Star size={12} color="#a78bfa" /> {char.xp} / {xpNext} XP
</span>
{(char as any).statPoints > 0 && (
<span className="badge badge-gold">+{(char as any).statPoints} pts à répartir</span>
)}
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
{/* Barres vitales */}
<div className="card">
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>État</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#e84040', display: 'flex', alignItems: 'center', gap: 4 }}>
<Heart size={11} /> PV
</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.hpCurrent} / {char.hpMax}</span>
</div>
<Bar value={char.hpCurrent} max={char.hpMax} type="hp" showValues={false} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#5ba4f5', display: 'flex', alignItems: 'center', gap: 4 }}>
<Zap size={11} /> Endurance
</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.endurance} / {char.enduranceMax}</span>
</div>
<Bar value={char.endurance} max={char.enduranceMax} type="end" showValues={false} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#a78bfa', display: 'flex', alignItems: 'center', gap: 4 }}>
<Star size={11} /> XP
</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.xp} / {xpNext}</span>
</div>
<Bar value={char.xp} max={xpNext} type="xp" showValues={false} />
</div>
</div>
</div>
{/* Stats */}
<div className="card">
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Statistiques</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 12px' }}>
{STATS.map(s => (
<div key={s} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, color: '#6b7a99' }}>{STAT_LABELS[s]}</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#dce4f0' }}>{char[s]}</span>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, color: '#e84040', display:'flex', alignItems:'center', gap:3 }}><Heart size={10}/> PV max</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#dce4f0' }}>{char.hpMax}</span>
</div>
</div>
</div>
{/* Équipement résumé */}
<div className="card" style={{ gridColumn: '1 / -1' }}>
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Combat actuel</p>
<div style={{ display: 'flex', gap: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Sword size={14} color="#f4c94e" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Attaque : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{Math.floor(char.force * 1.5)}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Shield size={14} color="#5ba4f5" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Critique : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{(5 + char.chance * 0.2).toFixed(1)}%</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Zap size={14} color="#3ddc84" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Esquive : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{(5 + char.chance * 0.1).toFixed(1)}%</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { itemApi, forgeApi } from '../api/endpoints';
import type { CharacterItem } from '../api/types';
import { Shield, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
const FORGE_RISK = [0, 0, 0, 20, 30, 40];
const FORGE_LABEL = ['—', '—', 'Garanti', '20% échec', '30% échec', '40% échec'];
export function ForgePage() {
const qc = useQueryClient();
const [selected, setSelected] = useState<CharacterItem | null>(null);
const [lastResult, setLastResult] = useState<{ success: boolean; newLevel: number } | null>(null);
const { data: inventory, isLoading } = useQuery({
queryKey: ['inventory'],
queryFn: itemApi.inventory,
});
const forgeMut = useMutation({
mutationFn: () => forgeApi.upgrade(selected!.id),
onSuccess: (res) => {
setLastResult({ success: res.success, newLevel: res.newForgeLevel });
qc.invalidateQueries({ queryKey: ['inventory'] });
// Refresh selected item from updated inventory
setSelected(res.item);
},
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
const forgeable = inventory ?? [];
const nextLevel = (selected?.forgeLevel ?? 0) + 1;
const risk = FORGE_RISK[nextLevel] ?? 40;
const atMax = selected && selected.forgeLevel >= 5;
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>
<Shield size={18} style={{ display: 'inline', marginRight: 8 }} />Forge
</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
{/* Sélection item */}
<div>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
Choisir un équipement
</p>
{forgeable.length === 0 ? (
<div className="card" style={{ color: '#6b7a99', fontSize: 13, textAlign: 'center', padding: '1.5rem' }}>
Aucun item dans l'inventaire
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{forgeable.map(ci => (
<div
key={ci.id}
className={`card card-hover ${selected?.id === ci.id ? 'card-gold' : ''}`}
onClick={() => { setSelected(ci); setLastResult(null); }}
style={{ display: 'flex', alignItems: 'center', gap: 10 }}
>
<span style={{ fontSize: 20 }}>{ci.item.type === 'weapon' ? '' : '🛡'}</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: selected?.id === ci.id ? '#f4c94e' : '#dce4f0' }}>
{ci.item.name}
</div>
<div style={{ fontSize: 11, color: '#6b7a99' }}>Niveau forge : {ci.forgeLevel}/5</div>
</div>
{ci.forgeLevel > 0 && (
<span className="badge badge-blue" style={{ fontSize: 9 }}>+{ci.forgeLevel}</span>
)}
</div>
))}
</div>
)}
</div>
{/* Panneau forge */}
<div>
{selected ? (
<div className="card card-gold" style={{ padding: '1.25rem' }}>
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
<div style={{ fontSize: 40, marginBottom: 4 }}>
{selected.item.type === 'weapon' ? '' : '🛡'}
</div>
<div style={{ fontWeight: 800, fontSize: 16, color: '#f4c94e' }}>{selected.item.name}</div>
<div style={{ fontSize: 12, color: '#6b7a99', marginTop: 2 }}>Forge actuelle : +{selected.forgeLevel}</div>
</div>
{!atMax ? (
<>
<div className="card" style={{ marginBottom: '1rem', textAlign: 'center' }}>
<div style={{ fontSize: 12, color: '#6b7a99', marginBottom: 4 }}>Prochain niveau : +{nextLevel}</div>
<div style={{
fontSize: 14, fontWeight: 700,
color: risk === 0 ? '#3ddc84' : risk <= 20 ? '#f4c94e' : '#e84040',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4
}}>
{risk === 0
? <><CheckCircle size={14} /> Succès garanti</>
: <><AlertTriangle size={14} /> {FORGE_LABEL[nextLevel]}</>
}
</div>
</div>
<button
className="btn btn-gold"
style={{ width: '100%', fontSize: 14, padding: '0.75rem' }}
disabled={forgeMut.isPending}
onClick={() => forgeMut.mutate()}
>
{forgeMut.isPending ? 'Forge en cours' : `🔨 Forger → +${nextLevel}`}
</button>
</>
) : (
<div style={{ textAlign: 'center', color: '#f4c94e', fontSize: 13, padding: '0.5rem' }}>
✨ Niveau maximum atteint (+5)
</div>
)}
{/* Résultat */}
{lastResult && (
<div style={{ marginTop: '0.75rem', textAlign: 'center' }}>
{lastResult.success
? <div style={{ color: '#3ddc84', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<CheckCircle size={16} /> Succès ! Item à +{lastResult.newLevel}
</div>
: <div style={{ color: '#e84040', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<XCircle size={16} /> Échec — l'item est inchangé
</div>
}
</div>
)}
</div>
) : (
<div className="card" style={{ textAlign: 'center', padding: '2rem', color: '#6b7a99', fontSize: 13 }}>
Sélectionne un équipement à améliorer
</div>
)}
{/* Tableau des risques */}
<div className="card" style={{ marginTop: '1rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99' }}>Risques par niveau</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{[1,2,3,4,5].map(n => (
<div key={n} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '2px 0' }}>
<span style={{ color: '#9ca3af' }}>Niv. {n}</span>
<span style={{ color: FORGE_RISK[n] === 0 ? '#3ddc84' : FORGE_RISK[n] <= 20 ? '#f4c94e' : '#e84040', fontWeight: 600 }}>
{FORGE_LABEL[n]}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { itemApi, materialApi } from '../api/endpoints';
import type { CharacterItem } from '../api/types';
import { Package, Sword, Shield } from 'lucide-react';
const RARITY_LABEL: Record<string, string> = {
common: 'Commun', rare: 'Rare', epic: 'Épique', legendary: 'Légendaire',
};
function ItemCard({ ci, onEquip, onUnequip }: { ci: CharacterItem; onEquip: () => void; onUnequip: () => void }) {
const { item } = ci;
const bonuses = [
item.attackBonus && `+${item.attackBonus} ATK`,
item.defenseBonus && `+${item.defenseBonus} DEF`,
item.forceBonus && `+${item.forceBonus} FOR`,
item.agiliteBonus && `+${item.agiliteBonus} AGI`,
item.intelligenceBonus && `+${item.intelligenceBonus} INT`,
item.chanceBonus && `+${item.chanceBonus} CHA`,
item.vitaliteBonus && `+${item.vitaliteBonus} VIT`,
].filter(Boolean).join(' · ');
return (
<div className={`card ${ci.equipped ? 'card-gold' : ''}`} style={{ position: 'relative' }}>
{ci.equipped && (
<span className="badge badge-gold" style={{ position: 'absolute', top: 8, right: 8, fontSize: 9 }}>Équipé</span>
)}
{ci.forgeLevel > 0 && (
<span className="badge badge-blue" style={{ position: 'absolute', top: ci.equipped ? 28 : 8, right: 8, fontSize: 9 }}>
+{ci.forgeLevel}
</span>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 20 }}>{item.type === 'weapon' ? '⚔️' : '🛡️'}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 13 }}>{item.name}</div>
<div className={`rarity-${item.rarity}`} style={{ fontSize: 11 }}>{RARITY_LABEL[item.rarity]}</div>
</div>
</div>
{bonuses && <div style={{ fontSize: 11, color: '#3ddc84', marginBottom: 8 }}>{bonuses}</div>}
<div style={{ display: 'flex', gap: 6 }}>
{!ci.equipped
? <button className="btn btn-ghost" style={{ fontSize: 11, padding: '0.2rem 0.6rem' }} onClick={onEquip}>Équiper</button>
: <button className="btn btn-ghost" style={{ fontSize: 11, padding: '0.2rem 0.6rem' }} onClick={onUnequip}>Déséquiper</button>
}
</div>
</div>
);
}
export function InventoryPage() {
const qc = useQueryClient();
const { data: inventory, isLoading: loadInv } = useQuery({
queryKey: ['inventory'],
queryFn: itemApi.inventory,
});
const { data: materials, isLoading: loadMat } = useQuery({
queryKey: ['materials'],
queryFn: materialApi.inventory,
});
const equipMut = useMutation({
mutationFn: (id: string) => itemApi.equip(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['inventory'] }),
});
const unequipMut = useMutation({
mutationFn: (slot: 'weapon' | 'armor') => itemApi.unequip(slot),
onSuccess: () => qc.invalidateQueries({ queryKey: ['inventory'] }),
});
if (loadInv || loadMat) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
const weapons = inventory?.filter(ci => ci.item.type === 'weapon') ?? [];
const armors = inventory?.filter(ci => ci.item.type === 'armor') ?? [];
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>
<Package size={18} style={{ display: 'inline', marginRight: 8 }} />Inventaire
</h2>
{inventory?.length === 0 && (
<div className="card" style={{ textAlign: 'center', padding: '2rem', color: '#6b7a99' }}>
Inventaire vide gagne des combats pour lootter des matériaux et crafter des équipements !
</div>
)}
{/* Armes */}
{weapons.length > 0 && (
<div style={{ marginBottom: '1.25rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', display:'flex', alignItems:'center', gap:4 }}>
<Sword size={11} /> Armes ({weapons.length})
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '0.75rem' }}>
{weapons.map(ci => (
<ItemCard
key={ci.id} ci={ci}
onEquip={() => equipMut.mutate(ci.id)}
onUnequip={() => unequipMut.mutate('weapon')}
/>
))}
</div>
</div>
)}
{/* Armures */}
{armors.length > 0 && (
<div style={{ marginBottom: '1.25rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', display:'flex', alignItems:'center', gap:4 }}>
<Shield size={11} /> Armures ({armors.length})
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '0.75rem' }}>
{armors.map(ci => (
<ItemCard
key={ci.id} ci={ci}
onEquip={() => equipMut.mutate(ci.id)}
onUnequip={() => unequipMut.mutate('armor')}
/>
))}
</div>
</div>
)}
{/* Matériaux */}
{materials && materials.length > 0 && (
<div>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
🌿 Matériaux
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: '0.5rem' }}>
{materials.map(cm => (
<div key={cm.id} className="card" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0.625rem' }}>
<span style={{ fontSize: 18 }}>🌿</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{cm.material.name}</div>
<div className={`rarity-${cm.material.rarity}`} style={{ fontSize: 11 }}>×{cm.quantity}</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { buildAuthUrl, saveVerifier } from '../lib/oauth';
const PROVIDERS = [
{ id: 'discord', label: 'Discord', color: '#5865F2', emoji: '🎮' },
{ id: 'github', label: 'GitHub', color: '#24292e', emoji: '🐙' },
{ id: 'google', label: 'Google', color: '#ea4335', emoji: '🌐' },
];
export function LoginPage() {
const login = async (provider: string) => {
const redirectUri = `${window.location.origin}/auth/callback`;
const { url, verifier } = await buildAuthUrl(redirectUri, provider);
saveVerifier(verifier);
window.location.href = url;
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'radial-gradient(ellipse at 50% 0%, #1a1f2e 0%, #0d0f14 60%)',
}}>
<div style={{ textAlign: 'center', maxWidth: 380, width: '100%', padding: '0 1rem' }}>
{/* Logo */}
<div style={{ marginBottom: 32 }}>
<div style={{ fontSize: 64, marginBottom: 8 }}>🐸</div>
<h1 style={{ margin: 0, fontSize: 36, fontWeight: 900, color: '#f4c94e', letterSpacing: '-1px' }}>TetaRdPG</h1>
<p style={{ margin: '8px 0 0', color: '#6b7a99', fontSize: 14 }}>
RPG communautaire asynchrone
</p>
</div>
{/* Card login */}
<div className="card" style={{ padding: '1.5rem' }}>
<p style={{ margin: '0 0 1.25rem', color: '#9ca3af', fontSize: 13 }}>
Connecte-toi pour commencer ton aventure
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{PROVIDERS.map(p => (
<button
key={p.id}
onClick={() => login(p.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '0.625rem 1rem',
background: '#1e2535',
border: '1px solid #2a3448',
borderRadius: 8,
color: '#dce4f0',
cursor: 'pointer',
fontSize: 14,
fontWeight: 600,
transition: 'border-color 0.2s',
width: '100%',
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = '#f4c94e')}
onMouseLeave={e => (e.currentTarget.style.borderColor = '#2a3448')}
>
<span style={{ fontSize: 18 }}>{p.emoji}</span>
<span>Continuer avec {p.label}</span>
</button>
))}
</div>
</div>
<p style={{ marginTop: 20, fontSize: 11, color: '#3a4558' }}>
En te connectant, tu acceptes les règles de la taverne du Têtard Prophétique.
</p>
</div>
</div>
);
}