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