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

19
.claude/settings.json Normal file
View File

@@ -0,0 +1,19 @@
{
"permissions": {
"allow": [
"Bash(npm *)",
"Bash(git *)",
"Bash(pm2 *)",
"Bash(curl *)",
"Bash(ls *)",
"Bash(cat *)",
"Bash(grep *)",
"Bash(mkdir *)",
"Bash(cp *)",
"Bash(mv *)",
"Bash(node *)",
"Bash(npx *)",
"Write(*)"
]
}
}

View File

@@ -10,9 +10,8 @@ REDIS_URL=redis://localhost:6379
# Frontend CORS (virgule-séparé pour multi-origin) # Frontend CORS (virgule-séparé pour multi-origin)
FRONTEND_URL=http://localhost:5173 FRONTEND_URL=http://localhost:5173
# SuperOAuth — service externe d'authentification # SuperOAuth — service externe d'authentification (introspection, pas de secret JWT)
SUPER_OAUTH_URL=http://localhost:3000 SUPER_OAUTH_URL=http://localhost:3000
SUPER_OAUTH_JWT_SECRET=<JWT secret SuperOAuth>
# Cookie signing # Cookie signing
COOKIE_SECRET= COOKIE_SECRET=

View File

@@ -0,0 +1,59 @@
name: CI/CD — Build & Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-deploy:
name: Build & Deploy
runs-on: vps-runner
steps:
- uses: actions/checkout@v4
# ── Backend ──────────────────────────────────────────────────────────────
- name: Install & build backend
run: |
npm ci
npm run build
- name: Deploy backend
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
mkdir -p /var/www/tetardpg/backend
rsync -a --delete dist/ /var/www/tetardpg/backend/dist/
rsync -a package.json package-lock.json /var/www/tetardpg/backend/
cd /var/www/tetardpg/backend && npm ci --omit=dev
- name: Restart pm2
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
su - tetardtek-brain -c 'pm2 reload tetardpg-backend --update-env'
# ── Frontend ─────────────────────────────────────────────────────────────
- name: Install & build frontend
working-directory: frontend
env:
VITE_API_URL: https://tetardpg.tetardtek.com/api
VITE_OAUTH_URL: https://superoauth.tetardtek.com
VITE_OAUTH_CLIENT_ID: tetardpg
run: |
npm ci
npm run build
- name: Deploy frontend
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
mkdir -p /var/www/tetardpg/frontend/dist
rsync -a --delete frontend/dist/ /var/www/tetardpg/frontend/dist/
# ── Smoke test ───────────────────────────────────────────────────────────
- name: Smoke test API
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
sleep 3
curl -sf http://localhost:4000/api/health | grep -q '"ok"'
echo "✅ API health OK"

292
SPRINT4.md Normal file
View File

@@ -0,0 +1,292 @@
# TetaRdPG — Brief Sprint 4
> Statut : ⬜ À lancer
> Objectif : Succès individuels + Succès communautaires + Hall of Fame + Profil joueur enrichi
> Stack : NestJS · MySQL · TypeORM
> Prérequis : Sprint 3 livré ✅ (items, forge, craft, economy, twitch)
> Source design : `TetaRdPG/Sprint 4 _ Focus Succès & Hall of Fame.docx` + `Annexes/5. Système de succès.docx`
---
## Scope Sprint 4
### ✅ In scope
- Entité `achievements` — catalogue de succès avec critères de déblocage
- Entité `player_achievements` — suivi progression par joueur
- 5 catégories : Progression, Combat, Zones, Équipements, Économie
- Récompenses au déblocage : Or, XP bonus, titres honorifiques
- Entité `community_goals` — objectifs collectifs (monstres tués, TetardCoin cumulés)
- Barre de progression communautaire
- Récompenses communautaires : boosts globaux temporaires (XP/loot)
- Hall of Fame mensuel — classement contributeurs + badges
- Interface profil enrichi : badges, titres, % progression succès
- Seeds : 15 succès individuels + 3 objectifs communautaires
- API : voir section dédiée
### ❌ Out of scope
- Notifications Twitch temps réel (extension Twitch) — Sprint 5
- GIGABOSS communautaire (événement 72h) — Sprint événements
- Marché communautaire (échange joueurs) — Sprint économie avancée
- Guildes et alliances — Sprint social
- Boutique événementielle — Sprint économie avancée
- Frontend React complet
---
## Décisions de design (game-designer)
| Décision | Valeur | Justification |
|----------|--------|---------------|
| Tracking succès | Event-driven : chaque action (combat, craft, forge, level) émet un check | Pas de cron — cohérent avec le pattern lazy du projet |
| Catégories succès | 5 : progression, combat, zones, equipment, economy | GDD §5.1 |
| Paliers succès | 3 niveaux par succès (bronze/silver/gold) | Engagement long terme |
| Récompenses déblocage | Or + titre. Pas d'item pour éviter la complexité inventaire Sprint 4 | Simplification — items récompense = Sprint 5 |
| Titres joueur | 1 titre actif à la fois, affiché sur le profil | GDD §4 titres liés aux zones |
| Community goals | Reset mensuel, contribution individuelle trackée | GDD §5.2 |
| Hall of Fame | Classement mensuel, top 10, badges persistants | GDD §5.3 |
| Boost communautaire | Stocké en DB, appliqué comme multiplicateur dans combat/craft | Ex: +20% XP pendant 3j |
| Progression communautaire | Compteur global incrémenté à chaque action qualifiante | Pas de WebSocket — poll GET |
---
## Schéma DB
### `achievements`
```
id uuid PK
key varchar(50) UNIQUE -- 'combat_100', 'level_50', 'zone_marais_complete'
name varchar(100)
description text
category varchar(20) -- 'progression' | 'combat' | 'zones' | 'equipment' | 'economy'
tier varchar(10) -- 'bronze' | 'silver' | 'gold'
criteria_type varchar(30) -- 'combat_wins' | 'level_reached' | 'gold_accumulated' | ...
criteria_value int -- seuil à atteindre
reward_gold int default 0
reward_title varchar(100) NULL -- titre débloqué (nullable)
```
### `player_achievements`
```
id uuid PK
character_id uuid FK characters
achievement_id uuid FK achievements
progress int default 0 -- compteur courant
unlocked boolean default false
unlocked_at timestamp NULL
```
### `community_goals`
```
id uuid PK
name varchar(100)
description text
criteria_type varchar(30) -- 'total_monsters_killed' | 'total_tetardcoin' | ...
target_value bigint -- objectif collectif
current_value bigint default 0
reward_type varchar(30) -- 'xp_boost' | 'loot_boost'
reward_multiplier decimal(3,2) -- ex: 1.20 = +20%
reward_duration_hours int -- durée du boost
period_start date
period_end date
completed boolean default false
completed_at timestamp NULL
```
### `community_contributions`
```
id uuid PK
community_goal_id uuid FK community_goals
character_id uuid FK characters
contribution_value bigint default 0
```
### `hall_of_fame`
```
id uuid PK
character_id uuid FK characters
period varchar(7) -- '2026-04' format YYYY-MM
rank int
contribution_total bigint
badge varchar(50) -- 'top1_april_2026'
```
### `active_boosts` (communautaires)
```
id uuid PK
boost_type varchar(30) -- 'xp_boost' | 'loot_boost'
multiplier decimal(3,2)
expires_at timestamp
source_goal_id uuid FK community_goals
```
---
## Seeds
### Succès individuels (15)
| Key | Nom | Catégorie | Tier | Critère | Seuil | Récompense Or | Titre |
|-----|-----|-----------|------|---------|-------|---------------|-------|
| `combat_10` | Apprenti Guerrier | combat | bronze | combat_wins | 10 | 50 | — |
| `combat_100` | Guerrier Aguerri | combat | silver | combat_wins | 100 | 200 | Guerrier Aguerri |
| `combat_1000` | Légende du Combat | combat | gold | combat_wins | 1000 | 1000 | Légende |
| `level_10` | Aventurier | progression | bronze | level_reached | 10 | 100 | — |
| `level_50` | Héros | progression | silver | level_reached | 50 | 500 | Héros |
| `level_100` | Légende Vivante | progression | gold | level_reached | 100 | 2000 | Légende Vivante |
| `gold_1000` | Marchand | economy | bronze | gold_accumulated | 1000 | 100 | — |
| `gold_10000` | Négociant | economy | silver | gold_accumulated | 10000 | 500 | Négociant |
| `gold_100000` | Magnat | economy | gold | gold_accumulated | 100000 | 2000 | Magnat |
| `forge_5` | Apprenti Forgeron | equipment | bronze | forge_upgrades | 5 | 100 | — |
| `forge_25` | Maître Forgeron | equipment | silver | forge_upgrades | 25 | 500 | Maître Forgeron |
| `forge_100` | Forgeron Légendaire | equipment | gold | forge_upgrades | 100 | 2000 | Forgeron Légendaire |
| `craft_5` | Artisan Novice | equipment | bronze | craft_completed | 5 | 75 | — |
| `craft_25` | Artisan Confirmé | equipment | silver | craft_completed | 25 | 300 | Artisan |
| `craft_100` | Grand Artisan | equipment | gold | craft_completed | 100 | 1500 | Grand Artisan |
### Objectifs communautaires (3)
| Nom | Critère | Cible | Boost | Durée |
|-----|---------|-------|-------|-------|
| Chasse aux Monstres | total_monsters_killed | 10 000 | +20% XP | 72h |
| Trésor Communautaire | total_gold_earned | 1 000 000 | +15% loot | 48h |
| Fièvre de la Forge | total_forge_upgrades | 500 | +10% XP | 48h |
---
## API Sprint 4
```
# Succès individuels
GET /api/achievements → catalogue complet des succès
GET /api/achievements/me → progression du joueur (avec %)
POST /api/achievements/claim/:id → réclamer la récompense d'un succès débloqué
# Succès communautaires
GET /api/community/goals → objectifs en cours + barre progression
GET /api/community/goals/:id/top → top 10 contributeurs d'un objectif
GET /api/community/boosts → boosts actifs (multiplicateurs en cours)
# Hall of Fame
GET /api/halloffame/current → classement du mois en cours
GET /api/halloffame/:period → classement historique (ex: 2026-04)
# Profil enrichi
GET /api/profile/me → stats + titre actif + badges + succès count
PUT /api/profile/title → { title: "Héros" } → changer titre actif
```
---
## Architecture modules
```
src/
├── achievement/
│ ├── achievement.entity.ts
│ ├── player-achievement.entity.ts
│ ├── achievement.module.ts
│ ├── achievement.service.ts → check + unlock logic
│ ├── achievement.controller.ts
│ └── achievement.listener.ts → écoute events combat/craft/forge/levelup
├── community/
│ ├── community-goal.entity.ts
│ ├── community-contribution.entity.ts
│ ├── active-boost.entity.ts
│ ├── community.module.ts
│ ├── community.service.ts
│ └── community.controller.ts
├── halloffame/
│ ├── hall-of-fame.entity.ts
│ ├── halloffame.module.ts
│ ├── halloffame.service.ts → calcul mensuel + badge attribution
│ └── halloffame.controller.ts
├── profile/
│ ├── profile.module.ts
│ ├── profile.service.ts
│ └── profile.controller.ts
└── database/
├── achievements-seed.ts
└── community-goals-seed.ts
```
---
## Intégration modules existants
### CombatService — émission événements succès
```typescript
// Après résolution combat — émettre pour achievement tracker
if (result.winner === 'player') {
this.eventEmitter.emit('achievement.check', {
characterId: character.id,
type: 'combat_wins',
increment: 1,
});
this.eventEmitter.emit('community.contribute', {
characterId: character.id,
type: 'total_monsters_killed',
increment: 1,
});
}
```
### ForgeService / CraftService — même pattern
```typescript
// Après forge réussie
this.eventEmitter.emit('achievement.check', {
characterId, type: 'forge_upgrades', increment: 1,
});
this.eventEmitter.emit('community.contribute', {
characterId, type: 'total_forge_upgrades', increment: 1,
});
```
### Boosts actifs — application dans CombatEngine
```typescript
// Dans CombatService — vérifier boosts communautaires actifs
const xpBoost = await this.communityService.getActiveMultiplier('xp_boost');
rewards.xp = Math.floor(baseXp * xpBoost); // xpBoost = 1.0 si aucun boost
```
---
## Migration TypeORM
```
Sprint4Achievements — 6 tables :
achievements, player_achievements,
community_goals, community_contributions,
hall_of_fame, active_boosts
+ ALTER characters ADD active_title VARCHAR(100) NULL
+ ALTER characters ADD total_gold_earned BIGINT DEFAULT 0 -- tracking cumulé pour succès
```
---
## Critères de validation integrator
- [ ] `GET /api/achievements` → 15 succès seedés, 5 catégories
- [ ] `POST /api/combat/start` → victoire → `player_achievements.progress` incrémenté pour `combat_wins`
- [ ] 10 victoires → succès `combat_10` débloqué automatiquement
- [ ] `GET /api/achievements/me` → progression visible avec %
- [ ] `POST /api/achievements/claim/:id` → or crédité, titre disponible
- [ ] Claim succès déjà réclamé → 400
- [ ] `PUT /api/profile/title` → titre actif changé
- [ ] `GET /api/profile/me` → titre, badges, count succès
- [ ] `GET /api/community/goals` → 3 objectifs avec barre progression
- [ ] Combat victoire → `community_contributions` incrémentée
- [ ] `GET /api/community/goals/:id/top` → top 10 contributeurs
- [ ] Objectif communautaire atteint → boost créé dans `active_boosts`
- [ ] `GET /api/community/boosts` → multiplicateur actif
- [ ] Combat avec boost actif → XP = baseXp × multiplier
- [ ] Boost expiré → non retourné par GET
- [ ] `GET /api/halloffame/current` → classement mois en cours
- [ ] Sans cookie → 401 sur toutes les routes protégées
- [ ] Level up → `player_achievements.progress` incrémenté pour `level_reached`
- [ ] Forge → incrémente `forge_upgrades` + `total_forge_upgrades` communautaire

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,80 @@
Cas concrets. Aucun padding.
---
NO HANDOFF — le brain qui sait qui il est
Lundi 7h. Tu ouvres une session sans rien préparer.
Layer 0 charge : KERNEL, BHP schema, règles de collaboration, frozen layer.
La todo dit : brainstorm architecture.
Le brain répond : "session brainstorm — pas de contexte projet chargé, espace libre."
Tu fais de l'architecture propre parce qu'il n'y a pas de bruit du sprint de vendredi. Le cold start n'est pas un handicap — c'est ce qui rend la pensée architecturale possible. Un brain qui cold-start bien sur un brainstorm vaut plus qu'un brain qui traîne 400 lignes de workspace RAM d'un sprint de jeu.
---
SEMI — le chirurgien qui n'a besoin que du dossier
22h. Bug critique en prod sur SuperOAuth. Token refresh qui expire.
Layer 0 charge. Layer 1 partiel : position debug × SuperOAuth.
Le brain sait que SuperOAuth tourne port 3006, pm2 cluster, Redis dédié.
Il ne charge pas le sprint entier, pas le workspace de la semaine.
Tu as ce qu'il faut en 30 secondes. Tu fixes. Tu fermes.
Pas de bruit. Pas de friction. Le bon contexte au bon niveau.
---
SEMI+ — lundi matin sur un sprint vivant
TetaRdPG Sprint 4. Tu n'as pas de session ouverte depuis 3 jours.
Layer 0 + Layer 1 complet : état du sprint, ce qui est livré, ce qui reste, les décisions d'architecture du GDD.
Pas besoin de handoff de la dernière session — l'état du projet suffit.
Tu reprends en 2 minutes. Le brain n'a pas besoin de ta mémoire — il a la sienne.
---
FULL — la continuation chirurgicale
Hier soir, refacto TypeORM complexe sur OriginsDigital. Session coupée à 23h en plein milieu d'une migration.
Ce matin : FULL HANDOFF. Workspace RAM complet — les 3 fichiers en cours, la décision d'hier sur le cascade delete, le message d'erreur non résolu.
Tu reprends exactement là. Pas de reconstruction. Pas de "où j'en étais déjà ?". Continuité parfaite sur une tâche qui l'exige.
---
Le gradient intelligent dans un sprint
Lundi matin → SEMI+ reprendre l'état du sprint
Lundi soir → FULL continuation directe session d'avant
Mardi matin → SEMI+ nouveau jour, état suffit
Mercredi, bug → SEMI juste la position debug, rien d'autre
Vendredi close → FULL wrap complet, scribe, metabolism
Tu ne choisis pas toujours FULL parce que tu n'en as pas toujours besoin. Le niveau juste = moins de bruit, plus de vitesse.
---
Multi-agent — le cas qui prouve tout
Sprint OriginsDigital. Agent A : backend. Agent B : frontend.
- Layer 0 : identique pour les deux — même identité, même règles
- Layer 1 : partagé — même état de sprint, même API contracts
- Layer 2 : isolé — Agent A a son workspace backend, Agent B le sien
Agent B n'a pas besoin du workspace RAM d'Agent A. Il a besoin de savoir ce que l'API expose — c'est Layer 1. Le FULL d'Agent A ne pollue pas Agent B.
C'est ça le vrai gain : des agents qui partagent l'identité et l'état, mais pas la mémoire de travail.
---
Brain-as-a-Service — la promesse qui tient
brain new installe Layer 0 sur une machine vierge.
Première session d'un utilisateur : NO HANDOFF complet.
Pourtant il est productif immédiatement — parce que Layer 0 est solide.
C'est la promesse qu'on peut vendre : un brain qui cold-start bien est un brain qui scale.
Si NO HANDOFF est cassé, brain new ne marche pas. Layer 0 est le produit.
---
⚡ Coach — tu vois ce que ça implique ?
Layer 0 n'est pas juste de la philosophie. C'est le critère de qualité mesurable du système. Si NO HANDOFF est productif en moins de 2 minutes → Layer 0 est bon. Sinon il est insuffisant.
C'est un KPI qu'on peut tester.

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3139
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
frontend/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
frontend/src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

51
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider, useAuth } from './context/AuthContext';
import { Layout } from './components/Layout';
import { LoginPage } from './pages/LoginPage';
import { AuthCallback } from './pages/AuthCallback';
import { DashboardPage } from './pages/DashboardPage';
import { CombatPage } from './pages/CombatPage';
import { InventoryPage } from './pages/InventoryPage';
import { CraftPage } from './pages/CraftPage';
import { ForgePage } from './pages/ForgePage';
const qc = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } });
function ProtectedLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#6b7a99', fontSize: 14 }}>
Chargement
</div>
);
if (!user) return <Navigate to="/login" replace />;
return <Layout>{children}</Layout>;
}
function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/dashboard" element={<ProtectedLayout><DashboardPage /></ProtectedLayout>} />
<Route path="/combat" element={<ProtectedLayout><CombatPage /></ProtectedLayout>} />
<Route path="/inventory" element={<ProtectedLayout><InventoryPage /></ProtectedLayout>} />
<Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} />
<Route path="/forge" element={<ProtectedLayout><ForgePage /></ProtectedLayout>} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
);
}
export default function App() {
return (
<QueryClientProvider client={qc}>
<AuthProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</AuthProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,59 @@
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:4000/api';
let refreshPromise: Promise<boolean> | null = null;
async function tryRefresh(): Promise<boolean> {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
try {
const res = await fetch(`${BASE}/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
return res.ok;
} catch {
return false;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
});
if (res.status === 401 && path !== '/auth/refresh') {
const refreshed = await tryRefresh();
if (refreshed) {
const retry = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
});
if (retry.ok) {
if (retry.status === 204) return undefined as T;
return retry.json();
}
}
window.dispatchEvent(new Event('auth:expired'));
throw new Error('Session expired');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message ?? `HTTP ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,54 @@
import { api } from './client';
import type {
User, Character, Monster, CombatResult, CombatLog,
CharacterItem, CharacterMaterial, Recipe, CraftJob, Item,
} from './types';
// Auth
export const authApi = {
setSession: (token: string, refreshToken?: string) =>
api.post<User>('/auth/session', { token, refreshToken }),
me: () => api.get<User>('/auth/me'),
logout: () => api.post<void>('/auth/logout'),
};
// Character
export const characterApi = {
create: (name: string, stats: Record<string, number>) =>
api.post<Character>('/characters', { name, ...stats }),
me: () => api.get<Character>('/characters/me'),
};
// Combat
export const combatApi = {
monsters: () => api.get<Monster[]>('/monsters'),
start: (monsterId: string, attackType: string) => api.post<CombatResult>('/combat/start', { monsterId, attackType }),
history: () => api.get<CombatLog[]>('/combat/history'),
};
// Items
export const itemApi = {
catalogue: () => api.get<Item[]>('/items'),
inventory: () => api.get<CharacterItem[]>('/items/inventory'),
equip: (id: string) => api.post<void>(`/items/equip/${id}`),
unequip: (slot: 'weapon' | 'armor') => api.post<void>(`/items/unequip/${slot}`),
};
// Materials
export const materialApi = {
inventory: () => api.get<CharacterMaterial[]>('/materials/inventory'),
};
// Craft
export const craftApi = {
recipes: () => api.get<Recipe[]>('/craft/recipes'),
start: (recipeId: string) => api.post<CraftJob>('/craft/start', { recipeId }),
active: () => api.get<CraftJob | { status: 'none' }>('/craft/active'),
collect: (jobId: string) => api.post<CharacterItem>(`/craft/collect/${jobId}`),
};
// Forge
export const forgeApi = {
upgrade: (characterItemId: string) =>
api.post<{ success: boolean; newForgeLevel: number; item: CharacterItem }>('/forge/upgrade', { characterItemId }),
};

126
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,126 @@
export interface User {
id: string;
username: string;
email: string | null;
createdAt: string;
}
export interface Character {
id: string;
name: string;
level: number;
xp: number;
gold: number;
force: number;
agilite: number;
intelligence: number;
chance: number;
vitalite: number;
hpCurrent: number;
hpMax: number;
endurance: number; // calculé à la lecture
enduranceMax: number;
statPoints: number;
createdAt: string;
}
export interface Monster {
id: string;
name: string;
levelMin: number;
levelMax: number;
hp: number;
attack: number;
defense: number;
attackType: 'melee' | 'ranged' | 'magic';
xpReward: number;
goldMin: number;
goldMax: number;
}
export interface CombatRound {
round: number;
playerDamage: number;
playerCrit: boolean;
monsterDodged: boolean;
monsterDamage: number;
playerDodged: boolean;
playerHp: number;
monsterHp: number;
log: string[];
}
export interface CombatResult {
winner: 'player' | 'monster';
rounds: CombatRound[];
xpGained?: number;
goldGained?: number;
enduranceCost: number;
loot?: { material: Material; quantity: number } | null;
}
export interface CombatLog {
id: string;
monsterId: string;
monsterName?: string;
winner: 'player' | 'monster';
xpGained: number;
goldGained: number;
createdAt: string;
}
export type Rarity = 'common' | 'rare' | 'epic' | 'legendary';
export interface Item {
id: string;
name: string;
description: string;
type: 'weapon' | 'armor';
rarity: Rarity;
attackBonus: number;
defenseBonus: number;
forceBonus: number;
agiliteBonus: number;
intelligenceBonus: number;
chanceBonus: number;
vitaliteBonus: number;
}
export interface CharacterItem {
id: string;
item: Item;
forgeLevel: number;
equipped: boolean;
acquiredAt: string;
}
export interface Material {
id: string;
name: string;
description: string;
rarity: Rarity;
}
export interface CharacterMaterial {
id: string;
material: Material;
quantity: number;
}
export interface Recipe {
id: string;
name: string;
resultItem: Item;
craftDurationSeconds: number;
enduranceCost: number;
ingredients: { materialId: string; materialName?: string; quantity: number }[];
}
export interface CraftJob {
id: string;
recipe: Recipe;
startedAt: string;
completedAt: string;
collected: boolean;
status: 'pending' | 'ready';
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,24 @@
interface BarProps {
value: number;
max: number;
type: 'hp' | 'end' | 'xp';
label?: string;
showValues?: boolean;
}
export function Bar({ value, max, type, label, showValues = true }: BarProps) {
const pct = Math.min(100, Math.round((value / Math.max(max, 1)) * 100));
return (
<div>
{(label || showValues) && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4, fontSize: 12, color: '#6b7a99' }}>
{label && <span>{label}</span>}
{showValues && <span>{value} / {max}</span>}
</div>
)}
<div className="bar-track">
<div className={`bar-fill-${type}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Swords, Package, Hammer, User, LogOut, Shield } from 'lucide-react';
const NAV = [
{ to: '/dashboard', icon: User, label: 'Personnage' },
{ to: '/combat', icon: Swords, label: 'Combat' },
{ to: '/inventory', icon: Package, label: 'Inventaire' },
{ to: '/craft', icon: Hammer, label: 'Artisanat' },
{ to: '/forge', icon: Shield, label: 'Forge' },
];
export function Layout({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth();
const loc = useLocation();
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<header style={{
background: '#161b25',
borderBottom: '1px solid #2a3448',
padding: '0 1.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: 52,
position: 'sticky',
top: 0,
zIndex: 10,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 20 }}>🐸</span>
<span style={{ fontWeight: 800, color: '#f4c94e', letterSpacing: '-0.5px' }}>TetaRdPG</span>
</div>
{user && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 13, color: '#6b7a99' }}>{user.username}</span>
<button className="btn btn-ghost" style={{ padding: '0.3rem 0.6rem' }} onClick={logout} title="Déconnexion">
<LogOut size={14} />
</button>
</div>
)}
</header>
<div style={{ display: 'flex', flex: 1 }}>
{/* Sidebar nav */}
<nav style={{
width: 56,
background: '#161b25',
borderRight: '1px solid #2a3448',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '1rem 0',
gap: 4,
position: 'sticky',
top: 52,
height: 'calc(100vh - 52px)',
}}>
{NAV.map(({ to, icon: Icon, label }) => {
const active = loc.pathname.startsWith(to);
return (
<Link key={to} to={to} title={label} style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: 8,
color: active ? '#f4c94e' : '#6b7a99',
background: active ? '#1e2535' : 'transparent',
border: active ? '1px solid #c49c2e' : '1px solid transparent',
textDecoration: 'none',
transition: 'all 0.15s',
}}>
<Icon size={18} />
</Link>
);
})}
</nav>
{/* Main content */}
<main style={{ flex: 1, padding: '1.5rem', maxWidth: 900, margin: '0 auto', width: '100%' }}>
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { authApi } from '../api/endpoints';
import type { User } from '../api/types';
interface AuthCtx {
user: User | null;
loading: boolean;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
const Ctx = createContext<AuthCtx | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refresh = async () => {
try {
const u = await authApi.me();
setUser(u);
} catch {
setUser(null);
}
};
useEffect(() => {
refresh().finally(() => setLoading(false));
}, []);
useEffect(() => {
const onExpired = () => setUser(null);
window.addEventListener('auth:expired', onExpired);
return () => window.removeEventListener('auth:expired', onExpired);
}, []);
const logout = async () => {
await authApi.logout();
setUser(null);
};
return <Ctx.Provider value={{ user, loading, logout, refresh }}>{children}</Ctx.Provider>;
}
export function useAuth() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

94
frontend/src/index.css Normal file
View File

@@ -0,0 +1,94 @@
@import "tailwindcss";
@theme {
--color-rpg-bg: #0d0f14;
--color-rpg-surface: #161b25;
--color-rpg-border: #2a3448;
--color-rpg-gold: #f4c94e;
--color-rpg-gold-dim: #c49c2e;
--color-rpg-red: #e84040;
--color-rpg-green: #3ddc84;
--color-rpg-blue: #5ba4f5;
--color-rpg-purple: #a78bfa;
--color-rpg-text: #dce4f0;
--color-rpg-muted: #6b7a99;
}
* { box-sizing: border-box; }
body {
margin: 0;
background-color: #0d0f14;
color: #dce4f0;
font-family: system-ui, sans-serif;
min-height: 100vh;
}
#root { min-height: 100vh; }
/* Barres */
.bar-track { background: #1e2535; border-radius: 4px; overflow: hidden; height: 10px; }
.bar-fill-hp { background: linear-gradient(90deg, #c0392b, #e84040); height: 100%; transition: width 0.4s ease; }
.bar-fill-end { background: linear-gradient(90deg, #1d6fa4, #5ba4f5); height: 100%; transition: width 0.4s ease; }
.bar-fill-xp { background: linear-gradient(90deg, #7c3aed, #a78bfa); height: 100%; transition: width 0.4s ease; }
/* Cards */
.card { background: #161b25; border: 1px solid #2a3448; border-radius: 8px; padding: 1rem; }
.card-gold { border-color: #c49c2e; }
.card-hover { cursor: pointer; transition: border-color 0.2s; }
.card-hover:hover { border-color: #f4c94e; }
/* Boutons */
.btn { font-weight: 600; padding: 0.5rem 1.25rem; border-radius: 6px; border: none; cursor: pointer; transition: opacity 0.2s; font-size: 0.875rem; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-gold { background: linear-gradient(135deg, #c49c2e, #f4c94e); color: #0d0f14; }
.btn-gold:hover:not(:disabled) { opacity: 0.85; }
.btn-red { background: linear-gradient(135deg, #c0392b, #e84040); color: #fff; }
.btn-red:hover:not(:disabled) { opacity: 0.85; }
.btn-ghost { background: #1e2535; color: #dce4f0; border: 1px solid #2a3448; }
.btn-ghost:hover:not(:disabled) { background: #2a3448; }
.btn-blue { background: linear-gradient(135deg, #1d6fa4, #5ba4f5); color: #fff; }
.btn-blue:hover:not(:disabled) { opacity: 0.85; }
/* Rareté */
.rarity-common { color: #9ca3af; }
.rarity-rare { color: #5ba4f5; }
.rarity-epic { color: #a78bfa; }
.rarity-legendary { color: #f4c94e; }
/* Badge */
.badge { font-size: 0.7rem; font-weight: 700; padding: 2px 8px; border-radius: 99px; text-transform: uppercase; letter-spacing: 0.05em; }
.badge-green { background: #0d2a1a; color: #3ddc84; border: 1px solid #1a5c35; }
.badge-red { background: #2a0d0d; color: #e84040; border: 1px solid #5c1a1a; }
.badge-gold { background: #2a1f0d; color: #f4c94e; border: 1px solid #5c420d; }
.badge-blue { background: #0d1a2a; color: #5ba4f5; border: 1px solid #1a3f5c; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0d0f14; }
::-webkit-scrollbar-thumb { background: #2a3448; border-radius: 3px; }
/* Séparateur */
.divider { border: none; border-top: 1px solid #2a3448; margin: 1rem 0; }
/* Input */
.input-rpg {
background: #1e2535;
border: 1px solid #2a3448;
color: #dce4f0;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
width: 100%;
outline: none;
transition: border-color 0.2s;
}
.input-rpg:focus { border-color: #f4c94e; }
.input-rpg::placeholder { color: #6b7a99; }
/* Combat log */
.combat-log { background: #0d0f14; border: 1px solid #2a3448; border-radius: 6px; padding: 0.75rem; max-height: 260px; overflow-y: auto; font-size: 0.8rem; font-family: monospace; }
.log-player { color: #3ddc84; }
.log-monster { color: #e84040; }
.log-system { color: #f4c94e; }
.log-crit { color: #a78bfa; font-weight: bold; }

110
frontend/src/lib/oauth.ts Normal file
View File

@@ -0,0 +1,110 @@
// OAuth 2.0 PKCE client — SuperOAuth consumer for TetaRdPG
// Adapted from OriginsDigital reference pattern
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || '';
const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || '';
const SESSION_KEY_VERIFIER = 'trpg_pkce_verifier';
// --- PKCE helpers ---
function base64UrlEncode(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array.buffer);
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(digest);
}
// --- Auth URL ---
export async function buildAuthUrl(
redirectUri: string,
provider: string,
scope = 'openid profile email',
clientId = OAUTH_CLIENT_ID,
): Promise<{ url: string; verifier: string }> {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer);
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
provider,
code_challenge: challenge,
code_challenge_method: 'S256',
});
return {
url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`,
verifier,
};
}
// --- Token exchange ---
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
scope?: string;
}
export async function exchangeCode(
code: string,
verifier: string,
redirectUri: string,
clientId = OAUTH_CLIENT_ID,
): Promise<TokenResponse> {
const response = await fetch(`${OAUTH_URL}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}).toString(),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`OAuth token exchange failed (${response.status}): ${text}`);
}
const data = await response.json() as TokenResponse;
if (!data.access_token) throw new Error('No access_token in OAuth response');
return data;
}
// --- PKCE verifier persistence (avant redirect) ---
export function saveVerifier(verifier: string): void {
sessionStorage.setItem(SESSION_KEY_VERIFIER, verifier);
}
export function loadVerifier(): string | null {
return sessionStorage.getItem(SESSION_KEY_VERIFIER);
}
export function clearVerifier(): void {
sessionStorage.removeItem(SESSION_KEY_VERIFIER);
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

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

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
})

View File

@@ -25,7 +25,7 @@ import { HealthController } from './common/health.controller';
type: 'mysql', type: 'mysql',
url: config.get<string>('DATABASE_URL'), url: config.get<string>('DATABASE_URL'),
autoLoadEntities: true, autoLoadEntities: true,
synchronize: config.get('NODE_ENV') !== 'production', synchronize: config.get('DB_SYNC') === 'true' || config.get('NODE_ENV') !== 'production',
logging: config.get('NODE_ENV') === 'development', logging: config.get('NODE_ENV') === 'development',
}), }),
}), }),

View File

@@ -4,10 +4,11 @@ import {
Get, Get,
Body, Body,
Res, Res,
Req,
UseGuards, UseGuards,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Req, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import { Response, Request } from 'express'; import { Response, Request } from 'express';
@@ -30,6 +31,20 @@ export class AuthController {
return this.authService.setSession(dto, res); return this.authService.setSession(dto, res);
} }
@Post('refresh')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { ttl: 60_000, limit: 10 } })
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const refreshToken = (req.signedCookies as Record<string, string>)?.refresh_token;
if (!refreshToken) {
throw new UnauthorizedException('Pas de refresh token');
}
return this.authService.refreshSession(res, refreshToken);
}
@Get('me') @Get('me')
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
async getMe(@Req() req: Request & { user: User }) { async getMe(@Req() req: Request & { user: User }) {

View File

@@ -1,23 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthGuard } from './guards/auth.guard'; import { AuthGuard } from './guards/auth.guard';
@Module({ @Module({
imports: [ imports: [TypeOrmModule.forFeature([User])],
TypeOrmModule.forFeature([User]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('SUPER_OAUTH_JWT_SECRET'),
}),
}),
],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, AuthGuard], providers: [AuthService, AuthGuard],
exports: [AuthGuard, TypeOrmModule], exports: [AuthGuard, TypeOrmModule],

View File

@@ -1,9 +1,7 @@
import { import {
Injectable, Injectable,
UnauthorizedException, UnauthorizedException,
InternalServerErrorException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -11,62 +9,54 @@ import { Response } from 'express';
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
import { SetSessionDto } from './dto/set-session.dto'; import { SetSessionDto } from './dto/set-session.dto';
// Payload émis par SuperOAuth interface SuperOAuthUser {
interface SuperOAuthPayload { id: string;
sub: string; // ID provider (Twitch ID, Discord ID…) tenantId: string;
provider: string; // 'twitch' | 'discord' | 'google' | 'github' email: string | null;
username: string; nickname: string;
avatar_url?: string; isActive: boolean;
iat: number; linkedProviders: string[];
exp: number;
} }
const COOKIE_NAME = 'session';
const REFRESH_COOKIE_NAME = 'refresh_token';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly superOauthUrl: string;
constructor( constructor(
@InjectRepository(User) @InjectRepository(User)
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) {
this.superOauthUrl = this.configService.getOrThrow<string>('SUPER_OAUTH_URL');
async setSession(dto: SetSessionDto, res: Response): Promise<Omit<User, 'oauthId'>> {
let payload: SuperOAuthPayload;
try {
payload = await this.jwtService.verifyAsync<SuperOAuthPayload>(dto.jwt, {
secret: this.configService.get<string>('SUPER_OAUTH_JWT_SECRET'),
});
} catch {
throw new UnauthorizedException('JWT SuperOAuth invalide ou expiré');
} }
if (!payload.sub || !payload.provider || !payload.username) { async setSession(dto: SetSessionDto, res: Response): Promise<Omit<User, 'superOauthId'>> {
throw new UnauthorizedException('Payload JWT incomplet'); const oauthUser = await this.introspectToken(dto.token);
}
// Upsert user // Upsert user
let user = await this.userRepository.findOne({ let user = await this.userRepository.findOne({
where: { oauthId: payload.sub, provider: payload.provider }, where: { superOauthId: oauthUser.id },
}); });
if (!user) { if (!user) {
user = this.userRepository.create({ user = this.userRepository.create({
oauthId: payload.sub, superOauthId: oauthUser.id,
provider: payload.provider, username: oauthUser.nickname,
username: payload.username, email: oauthUser.email,
avatarUrl: payload.avatar_url ?? null,
}); });
} else { } else {
user.username = payload.username; user.username = oauthUser.nickname;
user.avatarUrl = payload.avatar_url ?? null; user.email = oauthUser.email;
} }
await this.userRepository.save(user); await this.userRepository.save(user);
// Cookie httpOnly signé — valeur = UUID interne // Cookie httpOnly — session = UUID interne
const isProduction = this.configService.get('NODE_ENV') === 'production'; const isProduction = this.configService.get('NODE_ENV') === 'production';
res.cookie('session', user.id, { res.cookie(COOKIE_NAME, user.id, {
httpOnly: true, httpOnly: true,
signed: true, signed: true,
secure: isProduction, secure: isProduction,
@@ -74,16 +64,105 @@ export class AuthService {
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours
}); });
const { oauthId: _, ...safeUser } = user; // Refresh token cookie si fourni
if (dto.refreshToken) {
res.cookie(REFRESH_COOKIE_NAME, dto.refreshToken, {
httpOnly: true,
signed: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 jours
});
}
const { superOauthId: _, ...safeUser } = user;
return safeUser; return safeUser;
} }
async getMe(user: User): Promise<Omit<User, 'oauthId'>> { async refreshSession(res: Response, refreshToken: string): Promise<{ success: boolean }> {
const { oauthId: _, ...safeUser } = user; // Exchange refresh token for new access token via SuperOAuth
const response = await fetch(`${this.superOauthUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}).toString(),
});
if (!response.ok) {
throw new UnauthorizedException('Refresh token invalide ou expiré');
}
const data = await response.json();
if (!data.access_token) {
throw new UnauthorizedException('Refresh échoué — pas de token');
}
// Validate the new access token to get user data
const oauthUser = await this.introspectToken(data.access_token);
const user = await this.userRepository.findOne({
where: { superOauthId: oauthUser.id },
});
if (!user) {
throw new UnauthorizedException('Utilisateur introuvable après refresh');
}
// Set new cookies
const isProduction = this.configService.get('NODE_ENV') === 'production';
res.cookie(COOKIE_NAME, user.id, {
httpOnly: true,
signed: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
if (data.refresh_token) {
res.cookie(REFRESH_COOKIE_NAME, data.refresh_token, {
httpOnly: true,
signed: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
}
return { success: true };
}
async getMe(user: User): Promise<Omit<User, 'superOauthId'>> {
const { superOauthId: _, ...safeUser } = user;
return safeUser; return safeUser;
} }
logout(res: Response): void { logout(res: Response): void {
res.clearCookie('session'); res.clearCookie(COOKIE_NAME);
res.clearCookie(REFRESH_COOKIE_NAME);
}
private async introspectToken(token: string): Promise<SuperOAuthUser> {
const response = await fetch(`${this.superOauthUrl}/api/v1/auth/token/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new UnauthorizedException('Token SuperOAuth invalide');
}
const data = await response.json();
if (!data.data?.valid || !data.data.user) {
throw new UnauthorizedException('Token SuperOAuth invalide ou expiré');
}
if (!data.data.user.isActive) {
throw new UnauthorizedException('Compte SuperOAuth désactivé');
}
return data.data.user as SuperOAuthUser;
} }
} }

View File

@@ -1,7 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class SetSessionDto { export class SetSessionDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
jwt: string; token: string;
@IsString()
@IsOptional()
refreshToken?: string;
} }

View File

@@ -4,26 +4,21 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
Unique,
} from 'typeorm'; } from 'typeorm';
@Entity('users') @Entity('users')
@Unique(['oauthId', 'provider'])
export class User { export class User {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({ name: 'oauth_id', length: 255 }) @Column({ name: 'super_oauth_id', length: 255, unique: true })
oauthId: string; superOauthId: string;
@Column({ length: 50 })
provider: string;
@Column({ length: 255 }) @Column({ length: 255 })
username: string; username: string;
@Column({ name: 'avatar_url', type: 'varchar', length: 500, nullable: true }) @Column({ type: 'varchar', length: 255, nullable: true })
avatarUrl: string | null; email: string | null;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;