feat: PKCE auth + CI/CD deploy
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 1m2s
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:
74
frontend/src/pages/AuthCallback.tsx
Normal file
74
frontend/src/pages/AuthCallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
frontend/src/pages/CombatPage.tsx
Normal file
196
frontend/src/pages/CombatPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
frontend/src/pages/CraftPage.tsx
Normal file
150
frontend/src/pages/CraftPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
frontend/src/pages/DashboardPage.tsx
Normal file
188
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
frontend/src/pages/ForgePage.tsx
Normal file
159
frontend/src/pages/ForgePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
frontend/src/pages/InventoryPage.tsx
Normal file
147
frontend/src/pages/InventoryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/pages/LoginPage.tsx
Normal file
76
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user