Initial commit: Wycz WoW Portfolio

This commit is contained in:
2026-08-23 09:06:58 +00:00
commit cc5be652b8
93 changed files with 6502 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
ADMIN_PASSWORD=268bc591ce7e
SESSION_SECRET=72df71ef40d021cc1da8ddaf7ca7d5408552b68f058cc62fc9d49588478f3e8a
+4
View File
@@ -0,0 +1,4 @@
node_modules/
/data/
*.log
.DS_Store
+47
View File
@@ -0,0 +1,47 @@
# Wycz - Portfolio World of Warcraft
Site portfolio server-rendered dédié à l'univers World of Warcraft, avec zone d'administration, récupération de données externes planifiée et proxy model-viewer.
## Stack technique
| Composant | Technologie |
|-----------|------------|
| Runtime | Node.js 22 |
| Framework | Express (ESM) |
| Base de données | SQLite (better-sqlite3) |
| Frontend | HTML/CSS/JS statique |
## Déploiement
```bash
# Container Docker
docker compose up -d wycz
```
Accessible sur le port **3004**.
## Structure
```
/opt/web/wycz/backend/
├── server.js # Point d'entrée Express
├── scheduler.js # Planificateur de tâches (cron)
├── services/fetcher.js # Récupération de données externes
├── public/ # Assets statiques
└── data/ # SQLite (volume Docker)
```
## Variables d'environnement
| Variable | Description | Valeur |
|----------|-------------|--------|
| `PORT` | Port du serveur | `3004` |
| `ADMIN_PASSWORD` | Mot de passe admin | `Pierre2026!` |
| `SESSION_SECRET` | Secret de session | `wycz-session-secret-2026` |
| `DATA_DIR` | Répertoire de données | `/data` |
## Domaine de production
- **URL** : https://wycz.fr
- **LXC** : 107 (IP 10.10.10.24)
- **SSL** : Let's Encrypt via Nginx Proxy Manager
+4
View File
@@ -0,0 +1,4 @@
node_modules
data
.git
*.log
+4
View File
@@ -0,0 +1,4 @@
PORT=3004
ADMIN_PASSWORD=change-me-now
SESSION_SECRET=change-me-session-secret
DATA_DIR=./data
+7
View File
@@ -0,0 +1,7 @@
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3004
CMD ["node", "src/server.js"]
+1252
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"name": "wycz-backend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"better-sqlite3": "^11.10.0",
"express": "^4.21.2"
}
}
+195
View File
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Wycz — Administration</title>
<link rel="icon" type="image/png" href="/images/logo5.png">
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="admin-body">
<header class="site-header">
<div class="header-inner">
<div class="brand">
<img class="brand-logo" src="/images/logo5.png" alt="Logo Wycz">
<div class="brand-text">
<h1>ADMIN</h1>
<p>Gestion des personnages et objectifs</p>
</div>
</div>
<div class="header-right">
<div class="socials" id="socials"></div>
<div class="header-status">
<div id="header-live"></div>
<a href="/" class="btn btn-ghost">Voir le site</a>
<button class="btn btn-ghost" id="logout-btn" hidden>Déconnexion</button>
</div>
</div>
</div>
</header>
<main class="container">
<section id="login-view" hidden>
<div class="login-box">
<h2>Connexion administrateur</h2>
<form id="login-form">
<input type="password" id="login-password" placeholder="Mot de passe" autocomplete="current-password" required>
<button type="submit" class="btn btn-primary">Se connecter</button>
<div class="form-error" id="login-error" hidden></div>
</form>
</div>
</section>
<section id="admin-view" hidden>
<div class="tabs">
<button class="tab-btn active" data-tab="chars">Personnages</button>
<button class="tab-btn" data-tab="goals">Objectifs</button>
<button class="tab-btn" data-tab="sessions">Suivi</button>
<button class="tab-btn" data-tab="settings">Paramètres</button>
<button class="tab-btn" data-tab="logs">Journal</button>
</div>
<div class="tab-panel active" id="tab-chars">
<div class="panel-toolbar">
<h3>Personnages suivis</h3>
<div class="toolbar-actions">
<span class="hint" id="fetch-status"></span>
<button class="btn btn-primary" id="refresh-all-btn">Tout actualiser</button>
<button class="btn btn-primary" id="add-char-btn">+ Ajouter un personnage</button>
</div>
</div>
<div class="form-row hidden" id="add-char-form">
<input type="text" id="nc-name" placeholder="Nom (ex: Wycz)" required>
<input type="text" id="nc-realm" placeholder="Royaume (ex: Cho'gall)" required>
<input type="text" id="nc-realm-slug" placeholder="Slug Blizzard (optionnel)">
<select id="nc-region">
<option value="eu">EU</option>
<option value="us">US</option>
</select>
<select id="nc-class" aria-label="Classe">
<option value="">Classe (optionnel)</option>
<option>Guerrier</option><option>Paladin</option><option>Chasseur</option><option>Voleur</option>
<option>Prêtre</option><option>Chevalier de la mort</option><option>Chaman</option><option>Mage</option>
<option>Démoniste</option><option>Moine</option><option>Druide</option><option>Chasseur de démons</option>
<option>Évocateur</option>
</select>
<input type="text" id="nc-note" placeholder="Note (ex: Chaman main)">
<button class="btn btn-primary" id="nc-save">Ajouter</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Perso</th><th>Classe</th><th>Niveau</th><th>Royaume</th><th>Check-pvp</th><th>Sets PvP</th><th>Sets PvE</th><th>Ratings PvP</th><th>Titres</th><th>Actif</th><th>Maj</th><th></th>
</tr>
</thead>
<tbody id="chars-tbody"></tbody>
</table>
</div>
<div class="modal-backdrop" id="pvp-modal-backdrop" hidden>
<div class="modal pvp-modal">
<button class="modal-close" id="pvp-modal-close" aria-label="Fermer">&times;</button>
<div class="modal-head">
<div>
<h3 id="pvp-modal-title">Ratings PvP</h3>
<p id="pvp-modal-sub">Saison, maximum (xp), victoires et défaites par bracket</p>
</div>
</div>
<div class="pvp-form">
<div class="pvp-section">
<h4>Général</h4>
<table class="admin-table">
<thead><tr><th>Bracket</th><th>Cette saison</th><th>Max (xp)</th><th>Victoires</th><th>Défaites</th></tr></thead>
<tbody id="pvp-general-body"></tbody>
</table>
</div>
<div id="pvp-specs"></div>
<div class="pvp-actions">
<button class="btn btn-primary" id="pvp-save">Enregistrer</button>
<span class="hint" id="pvp-save-msg"></span>
</div>
</div>
</div>
</div>
<div class="tab-panel" id="tab-goals">
<div class="panel-toolbar"><h3>Objectifs et cibles</h3></div>
<div class="goals-grid admin-goals" id="admin-goals"></div>
</div>
<div class="tab-panel" id="tab-sessions">
<div class="panel-toolbar">
<h3>Suivi de session</h3>
<button class="btn btn-ghost" id="session-undo" hidden title="Annuler le dernier match">Annuler le dernier match</button>
</div>
<p class="hint" style="margin-bottom:12px">Clique à chaque match joué : Victoire / Défaite par bracket. Les totaux du jour et des 7 derniers jours sont calculés automatiquement.</p>
<div class="session-grid" id="session-grid"></div>
</div>
<div class="tab-panel" id="tab-settings">
<h3>API Blizzard</h3>
<div class="settings-card">
<p class="hint">Créez un client sur <a href="https://develop.battle.net/" target="_blank" rel="noopener">developers.battle.net</a> puis renseignez le Client ID et le Secret ci-dessous pour activer les ratings PvP, les hauts faits, les titres et l'apparence complète des personnages.</p>
<div class="form-grid">
<div><label>Client ID</label><input type="text" id="st-blizz-id"></div>
<div><label>Client Secret</label><input type="password" id="st-blizz-secret" autocomplete="off"></div>
</div>
<button class="btn btn-primary" id="st-save-blizz">Enregistrer les identifiants</button>
</div>
<h3>Vue d'ensemble (saisie manuelle)</h3>
<div class="settings-card">
<p class="hint">Laissez vide pour utiliser les données automatiques (Raider.io / Blizzard), sinon renseignez vos propres valeurs.</p>
<div class="form-grid">
<div class="span-2"><label>Classes niveau 90 <span class="hint">(sélectionnez les classes à 90)</span></label><div class="class-select" id="st-manual-class90-select"></div></div>
<div class="span-2"><label>Toutes les classes à 2300 en soloshuffle <span class="hint">(sélectionnez les classes)</span></label><div class="class-select" id="st-manual-ss2300-select"></div></div>
<div><label>Victoires Gladiateur au-dessus de 2300 en 3v3 (sur 50)</label><input type="number" id="st-manual-glad-wins" min="0" max="50" placeholder="0"></div>
<div class="span-2"><label>Classe en cours pour le farm Gladiateur <span class="hint">(cliquez sur la classe active)</span></label><div class="class-select" id="st-manual-glad-class-select"></div></div>
<div><label>Boss tués sur le dernier raid mythique</label><input type="number" id="st-manual-bosses" min="0" max="99" placeholder="(auto)"></div>
<div><label>Points de hauts faits</label><input type="number" id="st-manual-ach" min="0" placeholder="2258"></div>
<div><label>Total de hauts faits</label><input type="number" id="st-manual-ach-total" min="0" placeholder="5843"></div>
<div><label>Nom du dernier raid mythique</label><input type="text" id="st-raid-name" placeholder="L'Abîme Venimeux"></div>
<div><label>Nombre de boss du raid</label><input type="number" id="st-raid-total" min="1"></div>
<div><label>Lien Twitch</label><input type="text" id="st-twitch-url" placeholder="https://twitch.tv/wycz"></div>
<div><label>Lien YouTube</label><input type="text" id="st-youtube-url" placeholder="https://youtube.com/@..."></div>
<div><label>Lien TikTok</label><input type="text" id="st-tiktok-url" placeholder="https://tiktok.com/@..."></div>
<div><label>Saison du titre Gladiateur <span class="hint">(affichée à côté du titre)</span></label><input type="text" id="st-glad-season" placeholder="S1 Midnight"></div>
<div><label>Saison du titre Légende <span class="hint">(affichée à côté du titre)</span></label><input type="text" id="st-legend-season" placeholder="S1 Midnight"></div>
</div>
</div>
<h3>Mises à jour</h3>
<div class="settings-card">
<div class="form-grid">
<div><label>Fréquence (heures)</label><input type="number" id="st-interval" min="1"></div>
<div><label>Build wago.tools</label><input type="text" id="st-build" placeholder="12.1.0.69111"></div>
</div>
<button class="btn btn-primary" id="st-save">Enregistrer</button>
<button class="btn" id="st-refresh">Lancer une mise à jour complète</button>
<span class="hint" id="st-last-fetch"></span>
</div>
<h3>Mot de passe administrateur</h3>
<div class="settings-card">
<div class="form-grid">
<div><label>Mot de passe actuel</label><input type="password" id="pw-current"></div>
<div><label>Nouveau mot de passe</label><input type="password" id="pw-next"></div>
</div>
<button class="btn btn-primary" id="pw-save">Changer le mot de passe</button>
</div>
<div class="form-error" id="settings-error" hidden></div>
</div>
<div class="tab-panel" id="tab-logs">
<h3>Journal des mises à jour</h3>
<table class="admin-table">
<thead><tr><th>Heure</th><th>Personnage</th><th>Source</th><th>Statut</th><th>Détail</th></tr></thead>
<tbody id="logs-tbody"></tbody>
</table>
</div>
</section>
</main>
<script src="/js/api.js"></script>
<script src="/js/admin.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wycz - wow</title>
<link rel="icon" type="image/png" href="/images/logo5.png">
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0c0e14">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Wycz">
<link rel="apple-touch-icon" href="/images/icons/icon-180.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div id="starfield" aria-hidden="true"></div>
<header class="site-header">
<div class="header-inner">
<div class="brand">
<img class="brand-logo" src="/images/logo5.png" alt="Logo Wycz">
</div>
<div class="header-classes" id="header-classes"></div>
<div class="header-right">
<div class="socials" id="socials"></div>
<div class="header-status" id="header-status"></div>
</div>
</div>
</header>
<main class="container">
<section id="overview-section">
<h2 class="section-title">Objectifs de la Saison 2 Midnight</h2>
<div class="overview-grid" id="overview-grid">
<div class="loading">Chargement…</div>
</div>
</section>
</main>
<div class="modal-backdrop" id="class-modal-backdrop" hidden>
<div class="modal class-modal">
<button class="modal-close" id="class-modal-close" aria-label="Fermer">&times;</button>
<div class="class-modal-head">
<img id="class-modal-logo" class="class-modal-logo" alt="">
</div>
<div class="class-modal-chars" id="class-modal-chars"></div>
</div>
</div>
<div class="modal-backdrop" id="modal-backdrop" hidden>
<div class="modal char-detail-modal">
<button class="modal-close" id="modal-close" aria-label="Fermer">&times;</button>
<div class="modal-head">
<img id="modal-avatar" class="modal-avatar" alt="">
<div>
<h3 id="modal-title"></h3>
<p id="modal-subtitle"></p>
</div>
</div>
<div id="modal-art" class="modal-art">
<div class="loading">Chargement…</div>
</div>
<div id="modal-error" class="modal-error" hidden></div>
<div class="modal-stats" id="modal-stats"></div>
<div class="modal-section">
<h4 class="modal-section-title">Équipement</h4>
<div class="modal-gear" id="modal-gear"><div class="loading">Chargement de l'équipement…</div></div>
</div>
<div class="modal-section">
<h4 class="modal-section-title">Talents</h4>
<div class="modal-talents" id="modal-talents"><div class="loading">Chargement des talents…</div></div>
</div>
</div>
</div>
<script src="https://wow.zamimg.com/js/tooltips.js"></script>
<script src="/js/api.js"></script>
<script src="/js/app.js"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body>
</html>
+676
View File
@@ -0,0 +1,676 @@
(function () {
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const FR_CLASS_TO_EN = {
'Guerrier': 'Warrior', 'Paladin': 'Paladin', 'Chasseur': 'Hunter', 'Voleur': 'Rogue',
'Prêtre': 'Priest', 'Chevalier de la mort': 'Death Knight', 'Chaman': 'Shaman', 'Mage': 'Mage',
'Démoniste': 'Warlock', 'Moine': 'Monk', 'Druide': 'Druid', 'Chasseur de démons': 'Demon Hunter',
'Évocateur': 'Evoker'
};
const PVP_GENERAL = [['ARENA_2v2', '2v2'], ['ARENA_3v3', '3v3'], ['RATED_BG', 'RBG']];
const PVP_PER_SPEC = [['SOLO_SHUFFLE', 'Solo Shuffle'], ['BLITZ', 'Blitz']];
const SPECS_BY_CLASS = {
'Death Knight': [
{ key: 'Blood', fr: 'Sang', icon: 'spell_deathknight_bloodpresence.jpg' },
{ key: 'Frost', fr: 'Givre', icon: 'spell_deathknight_frostpresence.jpg' },
{ key: 'Unholy', fr: 'Impie', icon: 'spell_deathknight_unholypresence.jpg' }
],
'Demon Hunter': [
{ key: 'Havoc', fr: 'Dévastation', icon: 'ability_demonhunter_specdps.jpg' },
{ key: 'Vengeance', fr: 'Vengeance', icon: 'ability_demonhunter_spectank.jpg' },
{ key: 'Devoration', fr: 'Dévoration', icon: 'classicon_demonhunter_void.jpg' }
],
'Druid': [
{ key: 'Balance', fr: 'Équilibre', icon: 'spell_nature_starfall.jpg' },
{ key: 'Feral', fr: 'Farouche', icon: 'ability_druid_catform.jpg' },
{ key: 'Guardian', fr: 'Gardien', icon: 'ability_racial_bearform.jpg' },
{ key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_healingtouch.jpg' }
],
'Evoker': [
{ key: 'Devastation', fr: 'Dévastation', icon: 'classicon_evoker_devastation.jpg' },
{ key: 'Preservation', fr: 'Préservation', icon: 'classicon_evoker_preservation.jpg' },
{ key: 'Augmentation', fr: 'Augmentation', icon: 'classicon_evoker_augmentation.jpg' }
],
'Hunter': [
{ key: 'Beast Mastery', fr: 'Maîtrise des bêtes', icon: 'ability_hunter_bestialdiscipline.jpg' },
{ key: 'Marksmanship', fr: 'Précision', icon: 'ability_hunter_focusedaim.jpg' },
{ key: 'Survival', fr: 'Survie', icon: 'ability_hunter_camouflage.jpg' }
],
'Mage': [
{ key: 'Arcane', fr: 'Arcanes', icon: 'spell_nature_magicimmunity.jpg' },
{ key: 'Fire', fr: 'Feu', icon: 'spell_fire_firebolt02.jpg' },
{ key: 'Frost', fr: 'Givre', icon: 'spell_frost_frostbolt02.jpg' }
],
'Monk': [
{ key: 'Brewmaster', fr: 'Maître brasseur', icon: 'spell_monk_brewmaster_spec.jpg' },
{ key: 'Mistweaver', fr: 'Tisse-brume', icon: 'spell_monk_mistweaver_spec.jpg' },
{ key: 'Windwalker', fr: 'Marche-vent', icon: 'spell_monk_windwalker_spec.jpg' }
],
'Paladin': [
{ key: 'Holy', fr: 'Sacré', icon: 'spell_holy_holybolt.jpg' },
{ key: 'Protection', fr: 'Protection', icon: 'ability_paladin_shieldofthetemplar.jpg' },
{ key: 'Retribution', fr: 'Vindicte', icon: 'spell_holy_auraoflight.jpg' }
],
'Priest': [
{ key: 'Discipline', fr: 'Discipline', icon: 'spell_holy_powerwordshield.jpg' },
{ key: 'Holy', fr: 'Sacré', icon: 'spell_holy_guardianspirit.jpg' },
{ key: 'Shadow', fr: 'Ombre', icon: 'spell_shadow_shadowwordpain.jpg' }
],
'Rogue': [
{ key: 'Assassination', fr: 'Assassinat', icon: 'ability_rogue_deadlybrew.jpg' },
{ key: 'Outlaw', fr: 'Hors-la-loi', icon: 'ability_rogue_waylay.jpg' },
{ key: 'Subtlety', fr: 'Finesse', icon: 'ability_stealth.jpg' }
],
'Shaman': [
{ key: 'Elemental', fr: 'Élémentaire', icon: 'spell_nature_lightning.jpg' },
{ key: 'Enhancement', fr: 'Amélioration', icon: 'spell_shaman_improvedstormstrike.jpg' },
{ key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_healingtouch.jpg' }
],
'Warlock': [
{ key: 'Affliction', fr: 'Affliction', icon: 'spell_shadow_deathcoil.jpg' },
{ key: 'Demonology', fr: 'Démonologie', icon: 'spell_shadow_metamorphosis.jpg' },
{ key: 'Destruction', fr: 'Destruction', icon: 'spell_shadow_rainoffire.jpg' }
],
'Warrior': [
{ key: 'Arms', fr: 'Armes', icon: 'ability_warrior_innerrage.jpg' },
{ key: 'Fury', fr: 'Fureur', icon: 'ability_warrior_savageblow.jpg' },
{ key: 'Protection', fr: 'Protection', icon: 'ability_warrior_defensivestance.jpg' }
]
};
const ADMIN_CLASSES = [
{ name: 'Warrior', fr: 'Guerrier', logo: 'War.png' },
{ name: 'Paladin', fr: 'Paladin', logo: 'Pal.png' },
{ name: 'Hunter', fr: 'Chasseur', logo: 'Hunt.png' },
{ name: 'Rogue', fr: 'Voleur', logo: 'Rogue.png' },
{ name: 'Priest', fr: 'Prêtre', logo: 'Priest.png' },
{ name: 'Death Knight', fr: 'Chevalier de la mort', logo: 'DK.png' },
{ name: 'Shaman', fr: 'Chaman', logo: 'Sham.png' },
{ name: 'Mage', fr: 'Mage', logo: 'Mage.png' },
{ name: 'Warlock', fr: 'Démoniste', logo: 'Lock.png' },
{ name: 'Monk', fr: 'Moine', logo: 'monk.png' },
{ name: 'Druid', fr: 'Druide', logo: 'Druid.png' },
{ name: 'Demon Hunter', fr: 'Chasseur de démons', logo: 'dh.png' },
{ name: 'Evoker', fr: 'Évocateur', logo: 'evoker.png' }
];
let pvpEditId = null;
let state = { logged: false };
async function checkLogin() {
try {
await api.get('/api/admin/me');
state.logged = true;
} catch {
state.logged = false;
}
render();
}
function render() {
$('#login-view').hidden = state.logged;
$('#admin-view').hidden = !state.logged;
$('#logout-btn').hidden = !state.logged;
if (state.logged) {
loadChars();
loadGoals();
loadSettings();
loadSessions();
loadLogs();
}
}
const SESSION_BRACKETS = [['SOLO_SHUFFLE', 'Solo Shuffle'], ['BLITZ', 'Blitz'], ['ARENA_2v2', '2v2'], ['ARENA_3v3', '3v3'], ['RATED_BG', 'RBG']];
async function loadSessions() {
const grid = $('#session-grid');
if (!grid) return;
try {
const data = await api.get('/api/admin/sessions');
const today = {};
const week = {};
(data.today || []).forEach((x) => { today[x.bracket] = x; });
(data.week || []).forEach((x) => { week[x.bracket] = x; });
grid.innerHTML = SESSION_BRACKETS.map(([key, label]) => {
const t = today[key] || { wins: 0, losses: 0 };
const w = week[key] || { wins: 0, losses: 0 };
const total = (t.wins || 0) + (t.losses || 0);
const winrate = total > 0 ? `${Math.round((t.wins / total) * 100)}%` : '—';
return `<div class="session-card">
<div class="session-head">${label}
<span class="session-today">${t.wins}V · ${t.losses}D <em>${winrate}</em></span>
</div>
<div class="session-btns">
<button class="btn session-win" data-bracket="${key}" data-won="1">Victoire</button>
<button class="btn session-loss" data-bracket="${key}" data-won="0">Défaite</button>
</div>
<div class="session-week hint">7 derniers jours : ${w.wins}V · ${w.losses}D</div>
</div>`;
}).join('');
grid.querySelectorAll('.session-win, .session-loss').forEach((b) => {
b.addEventListener('click', async () => {
await api.post('/api/admin/sessions', { bracket: b.dataset.bracket, won: Number(b.dataset.won) });
loadSessions();
});
});
$('#session-undo').hidden = !(data.last && data.last.length);
} catch {
grid.innerHTML = '<p class="hint">Impossible de charger le suivi de session.</p>';
}
}
async function sessionUndo() {
try {
await api.post('/api/admin/sessions/undo');
loadSessions();
} catch {}
}
$('#login-form').addEventListener('submit', async (e) => {
e.preventDefault();
$('#login-error').hidden = true;
try {
await api.post('/api/admin/login', { password: $('#login-password').value });
state.logged = true;
$('#login-password').value = '';
render();
} catch (err) {
$('#login-error').textContent = err.message;
$('#login-error').hidden = false;
}
});
$('#logout-btn').addEventListener('click', async () => {
await api.post('/api/admin/logout');
state.logged = false;
render();
});
// Tabs
$$('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
$$('.tab-btn').forEach((b) => b.classList.remove('active'));
$$('.tab-panel').forEach((p) => p.classList.remove('active'));
btn.classList.add('active');
$(`#tab-${btn.dataset.tab}`).classList.add('active');
});
});
// ---- Characters ----
async function loadChars() {
const chars = await api.get('/api/admin/characters');
window.__adminChars = chars;
$('#chars-tbody').innerHTML = chars.map((c) => `
<tr data-id="${c.id}">
<td class="char-cell-name"><b>${esc(c.name)}</b>${c.note ? `<br><span class="hint">${esc(c.note)}</span>` : ''}</td>
<td>
<select class="mini-select" data-field="class">
<option value="">—</option>
${Object.entries(FR_CLASS_TO_EN).map(([fr, en]) => `<option value="${en}" ${c.class === en ? 'selected' : ''}>${fr}</option>`).join('')}
</select>
</td>
<td><input type="text" class="mini-num" data-field="level" value="${c.level ?? ''}" placeholder="—"></td>
<td>${esc(c.realm)} <span class="hint">(${c.region})</span></td>
<td><input type="text" class="mini-url" data-field="check_pvp_url" value="${esc(c.check_pvp_url ?? '')}" placeholder="https://check-pvp.fr/eu/…"></td>
<td><input type="text" class="mini-num" data-field="pvp_sets" value="${esc(c.pvp_sets ?? '')}" placeholder="0/4"></td>
<td><input type="text" class="mini-num" data-field="pve_sets" value="${esc(c.pve_sets ?? '')}" placeholder="0/4"></td>
<td><button class="btn mini-btn" data-action="pvp">Ratings</button></td>
<td>
<div class="admin-titles">
${(c.titles || []).map((t) => `
<span class="at-entry at-${t.title}">
<img src="/images/titles/${t.title === 'glad' ? 'gladiateur.jpg' : 'legende.webp'}" alt="${t.title === 'glad' ? 'Gladiateur' : 'Légende'}">
<b>${t.title === 'glad' ? 'Glad' : 'Leg'}</b>
${t.season ? `<span class="hint at-season-label">${esc(t.season)}</span>` : ''}
<button type="button" class="at-del" data-action="del-title" data-title-id="${t.id}" title="Retirer le titre">&times;</button>
</span>`).join('') || '<span class="hint">Aucun</span>'}
<span class="at-add">
<select class="mini-select" data-title-kind title="Type de titre">
<option value="glad">Gladiateur</option>
<option value="legend">Légende</option>
</select>
<input type="text" class="mini-num at-season" data-title-season placeholder="Saison" title="Saison du titre">
<button type="button" class="btn mini-btn" data-action="add-title" title="Ajouter le titre">+</button>
</span>
</div>
</td>
<td><input type="checkbox" data-field="active" ${c.active ? 'checked' : ''}></td>
<td class="hint">${esc(fmtDate(c.last_updated))}</td>
<td class="table-actions">
<button class="btn mini-btn" data-action="refresh">Maj</button>
<button class="del-btn" data-action="delete">Suppr</button>
</td>
</tr>`).join('');
}
$('#add-char-btn').addEventListener('click', () => {
$('#add-char-form').classList.toggle('hidden');
});
$('#nc-save').addEventListener('click', async () => {
try {
await api.post('/api/admin/characters', {
region: $('#nc-region').value,
realm: $('#nc-realm').value,
name: $('#nc-name').value,
realm_slug: $('#nc-realm-slug').value || null,
class: FR_CLASS_TO_EN[$('#nc-class').value] || null,
note: $('#nc-note').value
});
$('#nc-name').value = ''; $('#nc-realm').value = ''; $('#nc-realm-slug').value = ''; $('#nc-class').value = ''; $('#nc-note').value = '';
$('#add-char-form').classList.add('hidden');
await loadChars();
await refreshAll();
} catch (e) {
alert(e.message);
}
});
$('#chars-tbody').addEventListener('change', async (e) => {
const tr = e.target.closest('tr');
if (!tr) return;
if (e.target.matches('[data-field="active"]')) {
await api.put(`/api/admin/characters/${tr.dataset.id}`, { active: e.target.checked ? 1 : 0 });
} else if (e.target.matches('[data-field="class"]')) {
await api.put(`/api/admin/characters/${tr.dataset.id}`, { class: e.target.value || null });
} else if (e.target.matches('[data-field="level"]')) {
const v = e.target.value.trim();
await api.put(`/api/admin/characters/${tr.dataset.id}`, { level: v === '' ? null : Number(v) });
} else if (e.target.matches('[data-field="check_pvp_url"]')) {
await api.put(`/api/admin/characters/${tr.dataset.id}`, { check_pvp_url: e.target.value });
} else if (e.target.matches('[data-field="pvp_sets"]')) {
await api.put(`/api/admin/characters/${tr.dataset.id}`, { pvp_sets: e.target.value });
} else if (e.target.matches('[data-field="pve_sets"]')) {
await api.put(`/api/admin/characters/${tr.dataset.id}`, { pve_sets: e.target.value });
}
});
$('#chars-tbody').addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const tr = btn.closest('tr');
const id = tr.dataset.id;
if (btn.dataset.action === 'delete') {
if (!confirm('Supprimer ce personnage ?')) return;
await api.del(`/api/admin/characters/${id}`);
await loadChars();
} else if (btn.dataset.action === 'refresh') {
btn.textContent = '…';
await api.post('/api/admin/refresh', { id });
btn.textContent = 'Maj';
await loadChars();
await loadGoals();
await loadLogs();
} else if (btn.dataset.action === 'pvp') {
openPvpModal(id, tr.querySelector('.char-cell-name')?.textContent || '');
} else if (btn.dataset.action === 'add-title') {
const kind = tr.querySelector('[data-title-kind]')?.value || 'glad';
const season = (tr.querySelector('[data-title-season]')?.value || '').trim();
await api.post(`/api/admin/characters/${id}/titles`, { title: kind, season });
await loadChars();
} else if (btn.dataset.action === 'del-title') {
if (!confirm('Retirer ce titre ?')) return;
await api.del(`/api/admin/characters/${id}/titles/${btn.dataset.titleId}`);
await loadChars();
}
});
// ---- Ratings PvP modal ----
function openPvpModal(id, name) {
const chars = window.__adminChars || [];
const c = chars.find((x) => String(x.id) === String(id));
pvpEditId = id;
$('#pvp-modal-title').textContent = `Ratings PvP — ${name}`;
let pm = {};
try { pm = c && c.pvp_manual ? JSON.parse(c.pvp_manual || '{}') : {}; } catch {}
const classSpecs = (c && SPECS_BY_CLASS[c.class]) || [];
const cell = (spec, key, slot, val) => {
const ds = spec ? `data-spec="${spec}" ` : '';
return `<input type="number" class="mini-num" ${ds}data-bracket="${key}" data-slot="${slot}" value="${val ?? ''}" min="0">`;
};
$('#pvp-general-body').innerHTML = PVP_GENERAL.map(([key, label]) => `
<tr>
<td><b>${label}</b></td>
<td>${cell(null, key, 'season', pm[key]?.season)}</td>
<td>${cell(null, key, 'max', pm[key]?.max)}</td>
<td>${cell(null, key, 'won', pm[key]?.won)}</td>
<td>${cell(null, key, 'lost', pm[key]?.lost)}</td>
</tr>`).join('');
$('#pvp-specs').innerHTML = classSpecs.map((s) => {
const sp = (pm.specs && pm.specs[s.key]) || {};
return `
<div class="pvp-section">
<h4>${esc(s.fr)}</h4>
<table class="admin-table">
<thead><tr><th>Bracket</th><th>Cette saison</th><th>Max (xp)</th><th>Victoires</th><th>Défaites</th></tr></thead>
<tbody>${PVP_PER_SPEC.map(([key, label]) => `
<tr>
<td><b>${label}</b></td>
<td>${cell(s.key, key, 'season', sp[key]?.season)}</td>
<td>${cell(s.key, key, 'max', sp[key]?.max)}</td>
<td>${cell(s.key, key, 'won', sp[key]?.won)}</td>
<td>${cell(s.key, key, 'lost', sp[key]?.lost)}</td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
}).join('');
$('#pvp-modal-backdrop').hidden = false;
$('#pvp-save-msg').textContent = '';
document.body.style.overflow = 'hidden';
}
$('#pvp-save').addEventListener('click', async () => {
const out = { specs: {} };
const apply = (input) => {
const key = input.dataset.bracket;
const slot = input.dataset.slot;
const v = input.value.trim();
return { key, slot, v: v === '' ? 0 : Number(v) };
};
for (const input of $$('#pvp-general-body input')) {
const { key, slot, v } = apply(input);
if (!out[key]) out[key] = {};
out[key][slot] = v;
}
for (const input of $$('#pvp-specs input')) {
const spec = input.dataset.spec;
const { key, slot, v } = apply(input);
if (!out.specs[spec]) out.specs[spec] = {};
if (!out.specs[spec][key]) out.specs[spec][key] = {};
out.specs[spec][key][slot] = v;
}
try {
await api.put(`/api/admin/characters/${pvpEditId}`, { pvp_manual: JSON.stringify(out) });
$('#pvp-save-msg').textContent = 'Enregistré.';
$('#pvp-save-msg').style.color = 'var(--green)';
setTimeout(() => closePvpModal(), 600);
} catch (err) {
$('#pvp-save-msg').textContent = err.message;
$('#pvp-save-msg').style.color = 'var(--red)';
}
});
function closePvpModal() {
$('#pvp-modal-backdrop').hidden = true;
document.body.style.overflow = '';
}
$('#pvp-modal-close').addEventListener('click', closePvpModal);
$('#pvp-modal-backdrop').addEventListener('click', (e) => { if (e.target === e.currentTarget) closePvpModal(); });
// ---- Goals ----
async function loadGoals() {
const goals = await api.get('/api/admin/goals');
$('#admin-goals').innerHTML = goals.map((g) => `
<div class="goal-card" data-key="${esc(g.key)}">
<div class="goal-top">
<h3>${esc(g.title)}</h3>
<label class="badge ok" style="cursor:pointer"><input type="checkbox" data-gfield="enabled" ${g.enabled ? 'checked' : ''}> actif</label>
</div>
<div>
<label>Cible</label>
<input type="number" data-gfield="target" value="${g.target ?? ''}" placeholder="(max)">
</div>
<div>
<label>Barre max (affichage)</label>
<input type="number" data-gfield="display_max" value="${g.display_max ?? ''}">
</div>
<div>
<label>Unité</label>
<input type="text" data-gfield="unit" value="${esc(g.unit || '')}">
</div>
<div>
<label>Description</label>
<input type="text" data-gfield="description" value="${esc(g.description || '')}">
</div>
</div>`).join('');
}
$('#admin-goals').addEventListener('change', async (e) => {
const field = e.target.dataset.gfield;
if (!field) return;
const card = e.target.closest('.goal-card');
const key = card.dataset.key;
let value;
if (field === 'enabled') value = e.target.checked ? 1 : 0;
else if (field === 'target' || field === 'display_max') value = e.target.value === '' ? null : Number(e.target.value);
else value = e.target.value;
try {
await api.put(`/api/admin/goals/${key}`, { [field]: value });
} catch (err) {
alert(err.message);
}
});
// ---- Settings ----
function renderClassSelect(el, selected, onSave) {
const sel = new Set((selected || '').split(',').map((s) => s.trim()).filter(Boolean));
el.innerHTML = ADMIN_CLASSES.map((c) => `
<label class="class-chip${sel.has(c.name) ? ' on' : ''}" data-name="${c.name}">
<img src="/images/classes/${c.logo}" alt="${esc(c.fr)}" loading="lazy">
<span>${esc(c.fr)}</span>
</label>`).join('');
el.querySelectorAll('.class-chip').forEach((chip) => {
chip.addEventListener('click', () => {
const name = chip.dataset.name;
if (chip.classList.toggle('on')) sel.add(name); else sel.delete(name);
onSave([...sel].join(','));
});
});
}
function renderSingleClassSelect(el, selected, onSave) {
let current = (selected || '').trim();
el.innerHTML = ADMIN_CLASSES.map((c) => `
<label class="class-chip${current === c.name ? ' on' : ''}" data-name="${c.name}">
<img src="/images/classes/${c.logo}" alt="${esc(c.fr)}" loading="lazy">
<span>${esc(c.fr)}</span>
</label>`).join('');
el.querySelectorAll('.class-chip').forEach((chip) => {
chip.addEventListener('click', () => {
const name = chip.dataset.name;
current = current === name ? '' : name;
el.querySelectorAll('.class-chip').forEach((x) => x.classList.toggle('on', x.dataset.name === current));
onSave(current);
});
});
}
async function loadSettings() {
const s = await api.get('/api/admin/settings');
const fetchEl = $('#fetch-status');
if (fetchEl) {
const interval = s.fetch_interval_hours ? `${s.fetch_interval_hours} h` : '6 h';
fetchEl.innerHTML = s.last_fetch_at
? `Maj auto toutes les <b>${esc(interval)}</b> · Dernière : <b>${esc(fmtDate(s.last_fetch_at))}</b>`
: 'Aucune mise à jour automatique';
}
$('#st-interval').value = s.fetch_interval_hours || '';
$('#st-blizz-id').value = s.blizzard_client_id || '';
$('#st-blizz-secret').value = s.blizzard_client_secret || '';
$('#st-build').value = s.wow_tools_build || '';
$('#st-manual-bosses').value = s.manual_mythic_bosses || '';
$('#st-manual-ach').value = s.manual_achievements || '';
$('#st-manual-ach-total').value = s.manual_achievements_total || '';
$('#st-manual-glad-wins').value = s.manual_glad_wins || '';
renderSingleClassSelect($('#st-manual-glad-class-select'), s.manual_glad_class, async (v) => {
await api.put('/api/admin/settings', { manual_glad_class: v });
});
$('#st-raid-name').value = s.mythic_raid_name || '';
$('#st-raid-total').value = s.mythic_raid_total || '';
$('#st-twitch-url').value = s.twitch_url || '';
$('#st-youtube-url').value = s.youtube_url || '';
$('#st-tiktok-url').value = s.tiktok_url || '';
$('#st-glad-season').value = s.glad_title_season || '';
$('#st-legend-season').value = s.legend_title_season || '';
renderClassSelect($('#st-manual-class90-select'), s.manual_class90_classes, async (v) => {
await api.put('/api/admin/settings', { manual_class90_classes: v });
});
renderClassSelect($('#st-manual-ss2300-select'), s.manual_ss2300_classes, async (v) => {
await api.put('/api/admin/settings', { manual_ss2300_classes: v });
});
const gladWinsInput = $('#st-manual-glad-wins');
gladWinsInput.dataset.savedValue = gladWinsInput.value;
gladWinsInput.addEventListener('change', async () => {
const v = gladWinsInput.value;
try {
await api.put('/api/admin/settings', { manual_glad_wins: v });
gladWinsInput.dataset.savedValue = v;
$('#settings-error').textContent = 'Victoires gladiateur enregistrées.';
$('#settings-error').style.color = 'var(--green)';
} catch (e) {
gladWinsInput.value = gladWinsInput.dataset.savedValue;
$('#settings-error').textContent = e.message;
$('#settings-error').style.color = '';
}
$('#settings-error').hidden = false;
});
$('#st-last-fetch').textContent = s.last_fetch_at ? `Dernière mise à jour : ${fmtDate(s.last_fetch_at)}` : 'Aucune mise à jour effectuée.';
$('#settings-error').hidden = true;
}
async function saveSettings() {
$('#settings-error').hidden = true;
try {
await api.put('/api/admin/settings', {
fetch_interval_hours: $('#st-interval').value,
blizzard_client_id: $('#st-blizz-id').value,
blizzard_client_secret: $('#st-blizz-secret').value,
wow_tools_build: $('#st-build').value,
manual_mythic_bosses: $('#st-manual-bosses').value,
manual_achievements: $('#st-manual-ach').value,
manual_achievements_total: $('#st-manual-ach-total').value,
manual_glad_wins: $('#st-manual-glad-wins').value,
mythic_raid_name: $('#st-raid-name').value,
mythic_raid_total: $('#st-raid-total').value,
twitch_url: $('#st-twitch-url').value,
youtube_url: $('#st-youtube-url').value,
tiktok_url: $('#st-tiktok-url').value,
glad_title_season: $('#st-glad-season').value,
legend_title_season: $('#st-legend-season').value
});
$('#settings-error').textContent = 'Paramètres enregistrés.';
$('#settings-error').hidden = false;
$('#settings-error').style.color = 'var(--green)';
await loadSettings();
} catch (e) {
$('#settings-error').textContent = e.message;
$('#settings-error').hidden = false;
}
}
$('#st-save').addEventListener('click', saveSettings);
$('#st-save-blizz').addEventListener('click', saveSettings);
$('#st-refresh').addEventListener('click', async () => {
$('#st-refresh').textContent = 'Mise à jour en cours…';
await api.post('/api/admin/refresh', {});
$('#st-refresh').textContent = 'Lancer une mise à jour complète';
await loadSettings();
await loadLogs();
await loadGoals();
});
$('#pw-save').addEventListener('click', async () => {
try {
await api.post('/api/admin/password', { current: $('#pw-current').value, next: $('#pw-next').value });
$('#pw-current').value = ''; $('#pw-next').value = '';
alert('Mot de passe changé.');
} catch (e) {
alert(e.message);
}
});
async function refreshAll() {
await api.post('/api/admin/refresh', {});
await loadChars();
await loadSettings();
await loadLogs();
await loadGoals();
}
$('#refresh-all-btn').addEventListener('click', async () => {
const btn = $('#refresh-all-btn');
btn.disabled = true;
btn.textContent = 'Mise à jour en cours…';
try {
await refreshAll();
} finally {
btn.disabled = false;
btn.textContent = 'Tout actualiser';
}
});
// ---- Logs ----
async function loadLogs() {
const logs = await api.get('/api/admin/logs');
$('#logs-tbody').innerHTML = logs.map((l) => `
<tr>
<td class="hint">${esc(fmtDate(l.at))}</td>
<td>#${l.character_id ?? '—'}</td>
<td>${esc(l.source)}</td>
<td><span class="status-dot ${l.status === 'ok' ? 'ok' : 'error'}"></span>${esc(l.status)}</td>
<td class="hint">${esc(shortDetail(l.detail))}</td>
</tr>`).join('') || '<tr><td colspan="5" class="hint">Aucun log.</td></tr>';
}
function shortDetail(d) {
try {
const j = JSON.parse(d || '{}');
return Object.entries(j).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`).join(' · ');
} catch {
return d || '';
}
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
const SOCIAL_SVG = {
youtube: '<svg viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>',
tiktok: '<svg viewBox="0 0 24 24"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>'
};
function renderSocials(el, socials) {
const buttons = [];
if (socials.youtube) buttons.push(`<a class="social-btn" href="${esc(socials.youtube)}" target="_blank" rel="noopener" title="YouTube">${SOCIAL_SVG.youtube}</a>`);
if (socials.tiktok) buttons.push(`<a class="social-btn" href="${esc(socials.tiktok)}" target="_blank" rel="noopener" title="TikTok">${SOCIAL_SVG.tiktok}</a>`);
el.innerHTML = buttons.join('');
}
async function updateLiveStatus(statusEl, twitchUrl) {
const channel = twitchUrl.replace(/\/+$/, '').split('/').pop() || 'wycz';
const link = `<a class="live-status" href="${esc(twitchUrl)}" target="_blank" rel="noopener">`;
try {
const res = await fetch(`https://decapi.me/twitch/uptime/${encodeURIComponent(channel)}`, { mode: 'cors' });
const text = (await res.text()).trim();
const isLive = text && !/offline|hors ligne|hors-ligne|not live/i.test(text);
statusEl.innerHTML = isLive
? `${link}<span class="live-dot on"></span> Live en cours</a>`
: `${link}<span class="live-dot"></span> Hors ligne</a>`;
} catch {
statusEl.innerHTML = `${link}<span class="live-dot"></span> Twitch</a>`;
}
}
async function loadHeader() {
try {
const overview = await api.get('/api/overview');
renderSocials($('#socials'), { youtube: (overview.youtube && overview.youtube.url) || '', tiktok: (overview.tiktok && overview.tiktok.url) || '' });
updateLiveStatus($('#header-live'), (overview.twitch && overview.twitch.url) || 'https://twitch.tv/wycz');
} catch {
$('#socials').innerHTML = '';
}
}
const sessionUndoBtn = $('#session-undo');
if (sessionUndoBtn) sessionUndoBtn.addEventListener('click', sessionUndo);
loadHeader();
checkLogin();
})();
+27
View File
@@ -0,0 +1,27 @@
window.api = {
async get(path) {
const r = await fetch(path);
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try { const j = await r.json(); msg = j.error || msg; } catch {}
throw new Error(msg);
}
return r.json();
},
async send(method, path, body) {
const r = await fetch(path, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try { const j = await r.json(); msg = j.error || msg; } catch {}
throw new Error(msg);
}
return r.json();
},
post(path, body) { return this.send('POST', path, body); },
put(path, body) { return this.send('PUT', path, body); },
del(path) { return this.send('DELETE', path); }
};
+772
View File
@@ -0,0 +1,772 @@
(function () {
const CLASSES = [
{ name: 'Warrior', fr: 'Guerrier', logo: 'War.png', color: '#C79C6E' },
{ name: 'Paladin', fr: 'Paladin', logo: 'Pal.png', color: '#F58CBA' },
{ name: 'Hunter', fr: 'Chasseur', logo: 'Hunt.png', color: '#ABD473' },
{ name: 'Rogue', fr: 'Voleur', logo: 'Rogue.png', color: '#FFF569' },
{ name: 'Priest', fr: 'Prêtre', logo: 'Priest.png', color: '#FFFFFF' },
{ name: 'Death Knight', fr: 'Chevalier de la mort', logo: 'DK.png', color: '#C41F3B' },
{ name: 'Shaman', fr: 'Chaman', logo: 'Sham.png', color: '#0070DE' },
{ name: 'Mage', fr: 'Mage', logo: 'Mage.png', color: '#40C7EB' },
{ name: 'Warlock', fr: 'Démoniste', logo: 'Lock.png', color: '#8787ED' },
{ name: 'Monk', fr: 'Moine', logo: 'monk.png', color: '#00FF96' },
{ name: 'Druid', fr: 'Druide', logo: 'Druid.png', color: '#FF7D0A' },
{ name: 'Demon Hunter', fr: 'Chasseur de démons', logo: 'dh.png', color: '#A330C9' },
{ name: 'Evoker', fr: 'Évocateur', logo: 'evoker.png', color: '#33937F' }
];
const CLASS_BY_NAME = Object.fromEntries(CLASSES.map((c) => [c.name, c]));
const classInfo = (name) => CLASS_BY_NAME[name] || { name: name || '?', fr: name || 'Inconnu', logo: null, color: '#8b94a7' };
const SPECS_FR = {
'Elemental': 'Élémentaire', 'Enhancement': 'Amélioration', 'Restoration': 'Restauration',
'Holy': 'Sacré', 'Protection': 'Protection', 'Retribution': 'Rétribution',
'Discipline': 'Discipline', 'Shadow': 'Ombre', 'Blood': 'Sang', 'Frost': 'Givre', 'Unholy': 'Impiété',
'Havoc': 'Dévastation', 'Vengeance': 'Vengeance', 'Devastation': 'Dévastation', 'Preservation': 'Préservation',
'Augmentation': 'Augmentation', 'Arcane': 'Arcane', 'Fire': 'Feu', 'Assassination': 'Assassinat',
'Outlaw': 'Hors-la-loi', 'Subtlety': 'Subtilité', 'Beast Mastery': 'Maîtrise des bêtes',
'Marksmanship': 'Précision', 'Survival': 'Survie', 'Arms': 'Armes', 'Fury': 'Fureur',
'Balance': 'Équilibre', 'Feral': 'Farouche', 'Guardian': 'Gardien', 'Brewmaster': 'Maître brasseur',
'Mistweaver': 'Tisse-brume', 'Windwalker': 'Marche-vent', 'Affliction': 'Affliction',
'Demonology': 'Démonologie', 'Destruction': 'Destruction'
};
const specFr = (s) => (s ? (SPECS_FR[s] || s) : null);
const SPECS_BY_CLASS = {
'Death Knight': [
{ key: 'Blood', fr: 'Sang', icon: 'spell_deathknight_bloodpresence.jpg' },
{ key: 'Frost', fr: 'Givre', icon: 'spell_deathknight_frostpresence.jpg' },
{ key: 'Unholy', fr: 'Impie', icon: 'spell_deathknight_unholypresence.jpg' }
],
'Demon Hunter': [
{ key: 'Havoc', fr: 'Dévastation', icon: 'ability_demonhunter_specdps.jpg' },
{ key: 'Vengeance', fr: 'Vengeance', icon: 'ability_demonhunter_spectank.jpg' },
{ key: 'Devoration', fr: 'Dévoration', icon: 'classicon_demonhunter_void.jpg' }
],
'Druid': [
{ key: 'Balance', fr: 'Équilibre', icon: 'spell_nature_starfall.jpg' },
{ key: 'Feral', fr: 'Farouche', icon: 'ability_druid_catform.jpg' },
{ key: 'Guardian', fr: 'Gardien', icon: 'ability_racial_bearform.jpg' },
{ key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_healingtouch.jpg' }
],
'Evoker': [
{ key: 'Devastation', fr: 'Dévastation', icon: 'classicon_evoker_devastation.jpg' },
{ key: 'Preservation', fr: 'Préservation', icon: 'classicon_evoker_preservation.jpg' },
{ key: 'Augmentation', fr: 'Augmentation', icon: 'classicon_evoker_augmentation.jpg' }
],
'Hunter': [
{ key: 'Beast Mastery', fr: 'Maîtrise des bêtes', icon: 'ability_hunter_bestialdiscipline.jpg' },
{ key: 'Marksmanship', fr: 'Précision', icon: 'ability_hunter_focusedaim.jpg' },
{ key: 'Survival', fr: 'Survie', icon: 'ability_hunter_camouflage.jpg' }
],
'Mage': [
{ key: 'Arcane', fr: 'Arcanes', icon: 'spell_holy_magicalsentry.jpg' },
{ key: 'Fire', fr: 'Feu', icon: 'spell_fire_firebolt02.jpg' },
{ key: 'Frost', fr: 'Givre', icon: 'spell_frost_frostbolt02.jpg' }
],
'Monk': [
{ key: 'Brewmaster', fr: 'Maître brasseur', icon: 'spell_monk_brewmaster_spec.jpg' },
{ key: 'Mistweaver', fr: 'Tisse-brume', icon: 'spell_monk_mistweaver_spec.jpg' },
{ key: 'Windwalker', fr: 'Marche-vent', icon: 'spell_monk_windwalker_spec.jpg' }
],
'Paladin': [
{ key: 'Holy', fr: 'Sacré', icon: 'spell_holy_holybolt.jpg' },
{ key: 'Protection', fr: 'Protection', icon: 'ability_paladin_shieldofthetemplar.jpg' },
{ key: 'Retribution', fr: 'Vindicte', icon: 'spell_holy_auraoflight.jpg' }
],
'Priest': [
{ key: 'Discipline', fr: 'Discipline', icon: 'spell_holy_powerwordshield.jpg' },
{ key: 'Holy', fr: 'Sacré', icon: 'spell_holy_guardianspirit.jpg' },
{ key: 'Shadow', fr: 'Ombre', icon: 'spell_shadow_shadowwordpain.jpg' }
],
'Rogue': [
{ key: 'Assassination', fr: 'Assassinat', icon: 'ability_rogue_deadlybrew.jpg' },
{ key: 'Outlaw', fr: 'Hors-la-loi', icon: 'ability_rogue_waylay.jpg' },
{ key: 'Subtlety', fr: 'Finesse', icon: 'ability_stealth.jpg' }
],
'Shaman': [
{ key: 'Elemental', fr: 'Élémentaire', icon: 'spell_nature_lightning.jpg' },
{ key: 'Enhancement', fr: 'Amélioration', icon: 'spell_shaman_improvedstormstrike.jpg' },
{ key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_magicimmunity.jpg' }
],
'Warlock': [
{ key: 'Affliction', fr: 'Affliction', icon: 'spell_shadow_deathcoil.jpg' },
{ key: 'Demonology', fr: 'Démonologie', icon: 'spell_shadow_metamorphosis.jpg' },
{ key: 'Destruction', fr: 'Destruction', icon: 'spell_shadow_rainoffire.jpg' }
],
'Warrior': [
{ key: 'Arms', fr: 'Armes', icon: 'ability_warrior_innerrage.jpg' },
{ key: 'Fury', fr: 'Fureur', icon: 'ability_warrior_savageblow.jpg' },
{ key: 'Protection', fr: 'Protection', icon: 'ability_warrior_defensivestance.jpg' }
]
};
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
let classGroups = new Map();
let classTitles = { glad: [], legend: [] };
function fmt(n) {
if (n === null || n === undefined) return '—';
return n.toLocaleString('fr-FR');
}
function fmtSet(v) {
if (v === null || v === undefined || v === '' || v === 0) return '—';
return String(v);
}
function pct(n, d) {
return d > 0 ? Math.max(0, Math.min(100, Math.round((n / d) * 100))) : 0;
}
function charTopRating(c) {
let best = 0;
const pm = c.pvp_manual || {};
for (const k of ['ARENA_2v2', 'ARENA_3v3', 'RATED_BG']) {
const m = pm[k] || {};
const v = Math.max(m.max || 0, m.season || 0);
if (v > best) best = v;
}
const specs = pm.specs || {};
for (const k of Object.keys(specs)) {
for (const br of ['SOLO_SHUFFLE', 'BLITZ']) {
const m = specs[k][br] || {};
const v = Math.max(m.max || 0, m.season || 0);
if (v > best) best = v;
}
}
if (best) return best;
const auto = (c.data && (c.data.blizzard || c.data.raiderio || {}).pvp) || {};
return Math.max(0, ...Object.values(auto).map((x) => (x && typeof x.rating === 'number' ? x.rating : 0)));
}
function armoryImage(thumbnail) {
if (!thumbnail) return null;
return thumbnail.replace(/-avatar\.jpg/i, '-inset.jpg');
}
function overviewBadges(classes) {
const covered = new Set(classes || []);
const items = CLASSES.map((cls) => {
const done = covered.has(cls.name);
const img = cls.logo
? `<img class="overview-badge" src="/images/classes/${esc(cls.logo)}" alt="${esc(cls.fr)}" title="${esc(cls.fr)}" loading="lazy">`
: `<span class="overview-badge overview-badge-txt" title="${esc(cls.fr)}">${esc(cls.fr[0])}</span>`;
return `<span class="goal-class${done ? ' done' : ''}" title="${esc(cls.fr)}">${img}${done ? '<span class="goal-check">✓</span>' : ''}</span>`;
}).join('');
return `<div class="overview-badges">${items}</div>`;
}
const BEST_ORDER = [['ARENA_2v2', '2v2'], ['ARENA_3v3', '3v3'], ['SOLO_SHUFFLE', 'Solo Shuffle'], ['BLITZ', 'Blitz'], ['RATED_BG', 'RBG']];
const BEST_COLOR = {
ARENA_2v2: '#5aa9ff',
ARENA_3v3: '#ff5c5c',
SOLO_SHUFFLE: '#37e0ff',
BLITZ: '#ffb347',
RATED_BG: '#7ed957'
};
function bestsTable(bests) {
const rows = BEST_ORDER.map(([key, label]) => {
const b = bests[key] || null;
if (!b) {
return `<div class="best-panel" style="--brk:${BEST_COLOR[key] || '#5aa9ff'}">
<div class="best-head"><span class="best-brk">${label}</span></div>
<div class="best-empty">—</div>
</div>`;
}
const info = classInfo(b.class);
const spec = b.spec ? specFr(b.spec) : null;
const specEntry = (SPECS_BY_CLASS[info.name] || []).find((s) => s.key === b.spec);
const avatar = specEntry && specEntry.icon
? `<img class="best-avatar" src="/images/specs/${esc(specEntry.icon)}" alt="" loading="lazy">`
: (info.logo ? `<img class="best-avatar" src="/images/classes/${esc(info.logo)}" alt="" loading="lazy">` : '<span class="best-avatar best-avatar-empty"></span>');
const tier = tierFor(b.rating);
return `<div class="best-panel" style="--brk:${BEST_COLOR[key] || '#5aa9ff'}">
<div class="best-head">
<span class="best-brk">${label}</span>
${tier ? `<span class="brk-tier" style="color:${tier.color};border-color:${tier.color}">${tier.name}</span>` : ''}
</div>
<div class="best-char">
${avatar}
<div class="best-cname">${esc(info.fr)}${spec ? `<span class="muted"> · ${esc(spec)}</span>` : ''}</div>
<div class="best-csub">${esc(b.name || '')}</div>
</div>
<div class="best-rating">${fmt(b.rating)}</div>
</div>`;
}).join('');
return `<div class="overview-card bests-card">
<div class="overview-head"><h3>Meilleure classe &amp; spé par bracket</h3></div>
<div class="bests-grid">${rows}</div>
</div>`;
}
function overviewCard(g) {
const manualBadge = g.manual && g.key !== 'achievements' ? '<span class="badge muted">manuel</span>' : '';
const done = !!g.achieved;
const doneBadge = done ? '<span class="badge done">✓ Atteint</span>' : '';
let value;
let sub;
let progress = 0;
let badges = '';
if (g.key === 'class90' || g.key === 'ss2300') {
value = `<b>${fmt(g.covered)}</b> / ${g.total} classes`;
sub = g.total > 0 ? `${pct(g.covered, g.total)}%` : '—';
progress = g.total > 0 ? g.covered / g.total : 0;
badges = overviewBadges(g.classes);
} else if (g.key === 'gladiator_s2') {
const w = g.wins || 0;
const t = g.total || 50;
value = g.obtained ? '<b style="color:var(--green)">Obtenu</b>' : `<b>${fmt(w)}</b> / ${fmt(t)} victoires`;
sub = '';
progress = t > 0 ? Math.min(1, w / t) : 0;
badges = overviewBadges(g.classes || []);
} else if (g.key === 'achievements') {
const pc = g.total > 0 ? pct(g.points, g.total) : 0;
value = `<b>${fmt(g.points)}</b> / ${fmt(g.total)}`;
sub = `${pc}% · Points de hauts faits`;
progress = g.total > 0 ? Math.min(1, g.points / g.total) : 0;
}
return `
<div class="overview-card${done ? ' achieved' : ''}">
<div class="overview-head">
<h3>${esc(g.title)}</h3>
${manualBadge}
${doneBadge}
</div>
<div class="overview-value">${value}</div>
${sub ? `<div class="overview-sub">${esc(sub)}</div>` : ''}
<div class="progress-bar"><div class="fill" style="width:${pct(progress * 100, 100)}%"></div></div>
${badges}
</div>`;
}
const TIERS = [
{ min: 2400, name: 'Elite', color: '#ff8000' },
{ min: 2100, name: 'Duelist', color: '#a335ee' },
{ min: 1800, name: 'Rival', color: '#0070dd' },
{ min: 1400, name: 'Challenger', color: '#1eff00' },
{ min: 0, name: 'Combatant', color: '#9d9d9d' }
];
const tierFor = (rating) => {
if (!rating || rating <= 0) return null;
return TIERS.find((t) => rating >= t.min) || null;
};
const brkBlock = (label, color, b) => {
const current = b.season || null;
const max = b.max || null;
const tier = tierFor(current);
const total = (b.won || 0) + (b.lost || 0);
const wr = total > 0 ? Math.round((b.won / total) * 100) : null;
return `
<div class="brk-block" style="--brk:${color}">
<div class="brk-head">
<span class="brk-name">${label}</span>
${tier ? `<span class="brk-tier" style="color:${tier.color};border-color:${tier.color}">${tier.name}</span>` : '<span class="brk-tier muted">Non classé</span>'}
${b.isRecord ? '<span class="brk-record">★ Record</span>' : ''}
</div>
<div class="brk-rating">
<span class="brk-current">${current ? fmt(current) : '—'}</span>
${max != null ? `<span class="brk-maxsep">/</span><span class="brk-max" title="Max XP (saison en cours)">${fmt(max)}</span>` : ''}
</div>
<div class="brk-sub">${current != null ? `Côte actuelle · ` : ''}${max != null ? `Max XP ${fmt(max)}` : (current != null ? 'Aucun max enregistré' : 'Aucune côte')}</div>
<div class="brk-bar${total ? '' : ' empty'}"><div class="brk-bar-fill" style="width:${wr || 0}%"></div></div>
<div class="brk-wl">${fmt(b.won)}V · ${fmt(b.lost)}D${wr != null ? ` <b>${wr}%</b>` : ''}</div>
<div class="brk-games">${total ? `${fmt(total)} games` : 'Aucune game cette saison'}</div>
</div>`;
};
function bracketValues(c) {
const pm = c.pvp_manual || {};
const auto = c.pvp || {};
const autoOf = (key) => (auto[key] && typeof auto[key].rating === 'number' ? auto[key] : null);
const resolve = (key, m) => {
const a = autoOf(key) || {};
const season = m.season || (a.rating || null);
const max = m.max || (c.pvp_best && c.pvp_best[key]) || null;
return {
season,
max,
won: m.won || a.won || 0,
lost: m.lost || a.lost || 0,
weekly_won: a.weekly_won || 0,
weekly_lost: a.weekly_lost || 0,
tier: a.tier || null,
isRecord: !!(c.pvp_records && c.pvp_records[key])
};
};
return {
generalVal: (key) => resolve(key, pm[key] || {}),
specVal: (spec, key) => {
const s = (pm.specs && pm.specs[spec]) || {};
return resolve(key, s[key] || {});
}
};
}
function bracketsGrid(c, spec) {
const { generalVal, specVal } = bracketValues(c);
const PANELS = [
['2v2', '#5aa9ff', generalVal('ARENA_2v2')],
['3v3', '#ff5c5c', generalVal('ARENA_3v3')],
['Solo', '#37e0ff', spec ? specVal(spec, 'SOLO_SHUFFLE') : null],
['Blitz', '#ffb347', spec ? specVal(spec, 'BLITZ') : null],
['RBG', '#7ed957', generalVal('RATED_BG')]
];
return `<div class="brk-grid">${PANELS.map(([label, color, b]) => brkBlock(label, color, b)).join('')}</div>`;
}
let modalChars = [];
let modalSpecByChar = {};
const fullCache = {};
function getFull(id) {
if (!fullCache[id]) fullCache[id] = api.get(`/api/characters/${id}`);
return fullCache[id];
}
function charRow(c) {
const info = classInfo(c.class);
const frozen = (c.media && (c.media.main || c.media.inset || c.media.avatar)) || renderImage(c.thumbnail_url) || c.thumbnail_url || null;
const ilvl = c.ilvl || null;
const specs = SPECS_BY_CLASS[info.name] || [];
const sel = modalSpecByChar[c.id] || null;
const specPanels = specs.map((s) => `
<button type="button" class="wm-spec-panel${sel === s.key ? ' on' : ''}" data-char="${c.id}" data-spec="${esc(s.key)}" title="${esc(s.fr)}">
<img class="spec-icon" src="/images/specs/${esc(s.icon)}" alt="" loading="lazy">
<span>${esc(s.fr)}</span>
</button>`).join('');
const metaBits = [];
if (c.realm) metaBits.push(esc(c.realm));
if (c.race) metaBits.push(esc(c.race));
const faction = c.faction === 'ALLIANCE' || c.faction === 'Alliance' ? 'Alliance' : c.faction === 'HORDE' || c.faction === 'Horde' ? 'Horde' : null;
if (faction) metaBits.push(faction);
const metaText = metaBits.filter(Boolean).join(' · ');
let detail = '';
if (sel) {
detail = `
<div class="wm-detail">
${bracketsGrid(c, sel)}
<div class="modal-section wm-section">
<h4 class="modal-section-title">Équipement</h4>
<div class="modal-gear" data-gear="${c.id}"><div class="loading">Chargement…</div></div>
</div>
<div class="modal-section wm-section">
<h4 class="modal-section-title">Spécialisation</h4>
<div class="modal-talents" data-talents="${c.id}"><div class="loading">Chargement…</div></div>
</div>
</div>`;
}
return `
<div class="class-char wm-card" data-id="${c.id}" style="--class-color:${info.color}">
<div class="wm-specs wm-specs-top">${specPanels}</div>
<div class="wm-img" aria-hidden="true">
${frozen ? `<img src="${esc(frozen)}" alt="" loading="lazy">` : `<span class="wm-img-empty">${esc(c.name[0])}</span>`}
</div>
<div class="wm-info">
<div class="wm-name">${c.check_pvp_url ? `<a class="char-link" href="${esc(c.check_pvp_url)}" target="_blank" rel="noopener">${esc(c.name)}</a>` : esc(c.name)}</div>
<div class="wm-meta">${metaText}${ilvl != null ? `<span class="wm-ilvl">ILVL ${fmt(ilvl)}</span>` : ''}</div>
</div>
<div class="wm-titles">${classTitleEntries(info, classTitles)}</div>
${detail}
</div>`;
}
function titleTags(cls, titles) {
const parts = [];
if ((titles.glad || []).includes(cls.name)) parts.push('<img class="hdr-vignette" src="/images/titles/gladiateur.jpg" alt="Gladiateur" title="Titre Gladiateur">');
if ((titles.legend || []).includes(cls.name)) parts.push('<img class="hdr-vignette" src="/images/titles/legende.webp" alt="Légende" title="Titre Légende">');
if (!parts.length) return '';
return `<span class="hdr-titles">${parts.join('')}</span>`;
}
function classTitleEntries(cls, titles) {
const items = [];
for (const e of (titles.glad_entries || [])) {
if (e.class === cls.name) items.push(`<span class="wm-title"><img src="/images/titles/gladiateur.jpg" alt="Gladiateur" title="Titre Gladiateur"><b>Gladiateur</b>${e.season ? esc(e.season) : ''}</span>`);
}
for (const e of (titles.legend_entries || [])) {
if (e.class === cls.name) items.push(`<span class="wm-title"><img src="/images/titles/legende.webp" alt="Légende" title="Titre Légende"><b>Légende</b>${e.season ? esc(e.season) : ''}</span>`);
}
if (!items.length) {
if ((titles.glad || []).includes(cls.name)) items.push(`<span class="wm-title"><img src="/images/titles/gladiateur.jpg" alt="Gladiateur" title="Titre Gladiateur"><b>Gladiateur</b>${titles.glad_season ? esc(titles.glad_season) : ''}</span>`);
if ((titles.legend || []).includes(cls.name)) items.push(`<span class="wm-title"><img src="/images/titles/legende.webp" alt="Légende" title="Titre Légende"><b>Légende</b>${titles.legend_season ? esc(titles.legend_season) : ''}</span>`);
}
return items.join('');
}
function renderModalChars() {
if (!modalChars.length) {
$('#class-modal-chars').innerHTML = '<div class="class-empty">Aucun personnage pour cette classe</div>';
return;
}
$('#class-modal-chars').innerHTML = modalChars.map((c) => charRow(c)).join('');
for (const c of modalChars) {
const sel = modalSpecByChar[c.id];
if (!sel) continue;
const gearEl = document.querySelector(`[data-gear="${c.id}"]`);
const talentsEl = document.querySelector(`[data-talents="${c.id}"]`);
if (gearEl) {
getFull(c.id).then((full) => {
if (gearEl) gearEl.innerHTML = gearHTML(full.gear);
}).catch(() => {
if (gearEl) gearEl.innerHTML = '<div class="gear-empty">Équipement indisponible</div>';
});
}
if (talentsEl) {
talentsEl.innerHTML = '<div class="loading">Chargement…</div>';
api.get(`/api/characters/${c.id}/talents?spec=${encodeURIComponent(sel)}`).then((t) => {
if (talentsEl) talentsEl.innerHTML = talentTreeHTML(t);
}).catch(() => {
if (talentsEl) talentsEl.innerHTML = '<div class="gear-empty">Détails indisponibles</div>';
});
}
}
}
function openClassModal(cls, chars, titles) {
modalChars = chars;
modalSpecByChar = {};
$('#class-modal-logo').src = cls.logo ? `/images/classes/${cls.logo}` : '';
$('#class-modal-logo').style.display = cls.logo ? '' : 'none';
renderModalChars();
$('#class-modal-backdrop').hidden = false;
document.body.style.overflow = 'hidden';
}
$('#class-modal-chars').addEventListener('click', (e) => {
const panel = e.target.closest('.wm-spec-panel');
if (!panel) return;
const id = panel.dataset.char;
const spec = panel.dataset.spec;
modalSpecByChar[id] = modalSpecByChar[id] === spec ? null : spec;
renderModalChars();
const selPanel = $('#class-modal-chars').querySelector(`.wm-spec-panel.on[data-char="${CSS.escape(id)}"]`);
if (selPanel) {
selPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const detail = selPanel.closest('.class-char').querySelector('.wm-detail');
if (detail) detail.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
function closeClassModal() {
$('#class-modal-backdrop').hidden = true;
document.body.style.overflow = '';
}
async function load() {
const statusEl = $('#header-status');
const overviewEl = $('#overview-grid');
try {
const [overview, chars] = await Promise.all([api.get('/api/overview'), api.get('/api/characters')]);
const cards = [
{ key: 'class90', title: 'Classes niveau 90', covered: overview.class90.covered, total: overview.class90.total, manual: overview.class90.manual, classes: overview.class90.classes, achieved: overview.class90.total > 0 && overview.class90.covered >= overview.class90.total },
{ key: 'ss2300', title: 'Toutes les classes à 2300 en soloshuffle — Saison 2', covered: overview.ss2300.covered, total: overview.ss2300.total, manual: overview.ss2300.manual, classes: overview.ss2300.classes, achieved: overview.ss2300.total > 0 && overview.ss2300.covered >= overview.ss2300.total },
{ key: 'gladiator_s2', title: 'Gladiateur S2 Midnight', obtained: overview.gladiator_s2.obtained, wins: overview.gladiator_s2.wins, total: overview.gladiator_s2.total, rating: overview.gladiator_s2.rating, class: overview.gladiator_s2.class, classes: (overview.titles && overview.titles.glad_s2) || [], achieved: !!overview.gladiator_s2.obtained }
];
overviewEl.innerHTML = cards.map(overviewCard).join('') + bestsTable(overview.bests || {});
const grouped = new Map();
for (const c of chars) {
const key = classInfo(c.class).name;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(c);
}
for (const list of grouped.values()) {
list.sort((a, b) => charTopRating(b) - charTopRating(a));
}
classGroups = grouped;
classTitles = overview.titles || { glad: [], legend: [] };
const headerClasses = $('#header-classes');
if (headerClasses) {
const class90 = new Set(overview.class90.classes || []);
headerClasses.innerHTML = CLASSES.map((cls) => `
<button type="button" class="class-icon header-class-icon${class90.has(cls.name) ? '' : ' dim'}" data-class="${esc(cls.name)}" style="--class-color:${cls.color}" title="${esc(cls.fr)}">
${cls.logo ? `<img class="class-icon-img" src="/images/classes/${esc(cls.logo)}" alt="${esc(cls.fr)}" loading="lazy">` : `<span class="class-icon-empty">${esc(cls.fr[0])}</span>`}
${titleTags(cls, classTitles)}
</button>`).join('');
}
const twitchUrl = overview.twitch.url || 'https://twitch.tv/wycz';
updateLiveStatus(statusEl, twitchUrl);
renderSocials($('#socials'), {
youtube: (overview.youtube && overview.youtube.url) || '',
tiktok: (overview.tiktok && overview.tiktok.url) || ''
});
} catch (e) {
overviewEl.innerHTML = `<div class="loading">Erreur de chargement : ${esc(e.message)}</div>`;
}
}
async function updateLiveStatus(statusEl, twitchUrl) {
const channel = twitchUrl.replace(/\/+$/, '').split('/').pop() || 'wycz';
const safeChannel = encodeURIComponent(channel);
const link = `<a class="live-status" href="${esc(twitchUrl)}" target="_blank" rel="noopener">`;
const twitchLogo = `<span class="twitch-logo-img">${SOCIAL_SVG.twitch}</span>`;
try {
const res = await fetch(`https://decapi.me/twitch/uptime/${safeChannel}`, { mode: 'cors' });
const text = (await res.text()).trim();
const isLive = text && !/offline|hors ligne|hors-ligne|not live/i.test(text);
statusEl.innerHTML = `${link}${twitchLogo}<span class="live-dot${isLive ? ' on' : ''}"></span></a>`;
} catch {
statusEl.innerHTML = `${link}${twitchLogo}<span class="live-dot"></span></a>`;
}
}
const SOCIAL_SVG = {
twitch: '<svg viewBox="0 0 24 24"><path d="M4.265 0L.79 3.517v16.966h5.804V24h3.225l3.476-3.517h4.455L23.21 12V0H4.265zm17.55 11.31l-3.225 3.586h-4.586l-3.215 3.379v-3.379H5.526V2.62h16.29v8.69z"/></svg>',
youtube: '<svg viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>',
tiktok: '<svg viewBox="0 0 24 24"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>'
};
function renderSocials(el, socials) {
const buttons = [];
if (socials.youtube) {
buttons.push(`<a class="social-btn" href="${esc(socials.youtube)}" target="_blank" rel="noopener" title="YouTube">${SOCIAL_SVG.youtube}</a>`);
}
if (socials.tiktok) {
buttons.push(`<a class="social-btn" href="${esc(socials.tiktok)}" target="_blank" rel="noopener" title="TikTok">${SOCIAL_SVG.tiktok}</a>`);
}
el.innerHTML = buttons.join('');
}
const backdrop = $('#modal-backdrop');
const modalArt = $('#modal-art');
const modalError = $('#modal-error');
function closeModal() {
backdrop.hidden = true;
document.body.style.overflow = '';
}
document.addEventListener('click', (e) => {
const copyBtn = e.target.closest('[data-copy]');
if (copyBtn) {
navigator.clipboard.writeText(copyBtn.dataset.copy || '').then(() => {
copyBtn.textContent = 'Copié !';
setTimeout(() => { copyBtn.textContent = 'Copier le build'; }, 1600);
}).catch(() => {});
return;
}
const classIcon = e.target.closest('.class-icon');
if (classIcon) {
const name = classIcon.dataset.class;
openClassModal(classInfo(name), classGroups.get(name) || [], classTitles);
return;
}
if (e.target === $('#class-modal-backdrop')) {
closeClassModal();
return;
}
const openBtn = e.target.closest('[data-open3d]');
if (openBtn) {
if (e.target.closest('a')) return;
const id = openBtn.dataset.open3d;
modalError.hidden = true;
modalArt.style.display = 'flex';
modalArt.innerHTML = '<div class="loading">Chargement…</div>';
$('#modal-stats').innerHTML = '';
$('#modal-title').textContent = '…';
$('#modal-subtitle').textContent = '';
backdrop.hidden = false;
document.body.style.overflow = 'hidden';
api.get(`/api/characters/${id}`).then((full) => {
$('#modal-title').textContent = full.name;
const sub = [];
if (full.spec) sub.push(specFr(full.spec));
if (full.race) sub.push(full.race);
if (full.faction) sub.push(full.faction);
sub.push(full.realm);
$('#modal-subtitle').textContent = sub.filter(Boolean).join(' · ');
const thumb = (full.media && full.media.main) || full.media?.avatar || full.thumbnail_url || null;
const img = full.media && full.media.main ? thumb : renderImage(thumb);
if (img) {
modalArt.innerHTML = `<img src="${esc(img)}" alt="">`;
} else {
modalArt.innerHTML = '<div class="loading">Image de l\'armurerie non disponible</div>';
}
renderStats(full);
$('#modal-gear').innerHTML = gearHTML(full.gear);
if (full.spec) {
$('#modal-talents').innerHTML = '<div class="loading">Chargement…</div>';
api.get(`/api/characters/${id}/talents?spec=${encodeURIComponent(full.spec)}`).then((t) => {
$('#modal-talents').innerHTML = talentTreeHTML(t);
}).catch(() => {
$('#modal-talents').innerHTML = talentHTML(full);
});
} else {
$('#modal-talents').innerHTML = talentHTML(full);
}
}).catch((err) => {
modalArt.innerHTML = '';
modalError.textContent = `Impossible d'afficher les détails : ${err.message}`;
modalError.hidden = false;
});
return;
}
if (e.target === backdrop) closeModal();
});
function renderImage(thumbnail) {
if (!thumbnail) return null;
return thumbnail.split('?')[0].replace(/-avatar\.jpg/i, '-main-raw.png');
}
const GEAR_ORDER = [
['head', 'Tête'], ['neck', 'Cou'], ['shoulder', 'Épaules'], ['back', 'Dos'], ['chest', 'Torse'],
['wrist', 'Poignets'], ['hands', 'Mains'], ['waist', 'Taille'], ['legs', 'Jambes'], ['feet', 'Pieds'],
['finger1', 'Doigt 1'], ['finger2', 'Doigt 2'], ['trinket1', 'Bijou 1'], ['trinket2', 'Bijou 2'],
['mainhand', 'Main droite'], ['offhand', 'Main gauche']
];
const QUALITY_COLOR = ['#9d9d9d', '#ffffff', '#1eff00', '#0070dd', '#a335ee', '#ff8000', '#e6cc80'];
function gearHTML(gear) {
if (!gear || !gear.length) return '<div class="gear-empty">Aucun équipement disponible (données Raider.io)</div>';
const bySlot = {};
for (const it of gear) bySlot[it.slot_name] = it;
const items = GEAR_ORDER.map(([slot, label]) => bySlot[slot] || { slot, slot_name: slot, label, empty: true });
const main = (it) => it.empty
? `<span class="gear-item gear-empty-item">
<span class="gear-icon"><span class="gear-slot-letter">${esc((it.label || it.slot_name || '?')[0])}</span></span>
<span class="gear-info"><span class="gear-name">${esc(it.label || it.slot_name)}</span><span class="gear-ilvl">Vide</span></span>
</span>`
: `<span class="gear-item" style="--q:${it.quality >= 0 ? QUALITY_COLOR[Math.min(it.quality, QUALITY_COLOR.length - 1)] : 'var(--text)'}">
<span class="gear-icon">${it.icon
? `<img src="https://wow.zamimg.com/images/wow/icons/large/${esc(it.icon)}.jpg" alt="" loading="lazy" onerror="this.style.display='none'">`
: `<span class="gear-slot-letter">${esc((it.name || '?')[0])}</span>`}</span>
<span class="gear-info">
${it.item_id
? `<a class="gear-name" data-wowhead="item=${it.item_id}${it.item_level ? `&ilvl=${it.item_level}` : ''}&domain=fr" href="https://www.wowhead.com/item=${it.item_id}" target="_blank" rel="noopener">${esc(it.name)}</a>`
: `<span class="gear-name">${esc(it.name)}</span>`}
<span class="gear-ilvl">${it.item_level ? `ILVL ${it.item_level}` : ''}</span>
</span>
</span>`;
return items.map(main).join('');
}
const TALENT_BG = {
'Warrior': { Arms: 'warrior-arms', Fury: 'warrior-fury', Protection: 'warrior-protection' },
'Paladin': { Holy: 'paladin-holy', Protection: 'paladin-protection', Retribution: 'paladin-retribution' },
'Hunter': { 'Beast Mastery': 'hunter-beastmastery', Marksmanship: 'hunter-marksmanship', Survival: 'hunter-survival' },
'Rogue': { Assassination: 'rogue-assassination', Outlaw: 'rogue-outlaw', Subtlety: 'rogue-subtlety' },
'Priest': { Discipline: 'priest-discipline', Holy: 'priest-holy', Shadow: 'priest-shadow' },
'Death Knight': { Blood: 'death-knight-blood', Frost: 'death-knight-frost', Unholy: 'death-knight-unholy' },
'Shaman': { Elemental: 'shaman-elemental', Enhancement: 'shaman-enhancement', Restoration: 'shaman-restoration' },
'Mage': { Arcane: 'mage-arcane', Fire: 'mage-fire', Frost: 'mage-frost' },
'Warlock': { Affliction: 'warlock-affliction', Demonology: 'warlock-demonology', Destruction: 'warlock-destruction' },
'Monk': { Brewmaster: 'monk-brewmaster', Mistweaver: 'monk-mistweaver', Windwalker: 'monk-windwalker' },
'Druid': { Balance: 'druid-balance', Feral: 'druid-feral', Guardian: 'druid-guardian', Restoration: 'druid-restoration' },
'Demon Hunter': { Havoc: 'demon-hunter-havoc', Vengeance: 'demon-hunter-vengeance' },
'Evoker': { Devastation: 'evoker-devastation', Preservation: 'evoker-preservation', Augmentation: 'evoker-augmentation' }
};
function talentHTML(full) {
const cls = full.class || '';
const spec = full.spec || '';
const bg = TALENT_BG[cls] && TALENT_BG[cls][spec] ? `talentbg-${TALENT_BG[cls][spec]}.jpg` : null;
return `<div class="talent-panel${bg ? '' : ' no-bg'}">
${bg ? `<img class="talent-bg" src="https://wow.zamimg.com/images/tools/dragonflight-talent-calc/blizzard/${bg}" alt="" loading="lazy">` : ''}
<div class="talent-overlay">
<span class="talent-title">Arbre de talents — ${esc(specFr(spec) || spec || '?')}</span>
<span class="talent-note">Arbre indisponible pour le moment.</span>
</div>
</div>`;
}
function talentTreeHTML(data) {
if (!data || !data.trees) return '<div class="gear-empty">Arbre de talents indisponible</div>';
const selByTalent = { class: {}, spec: {} };
const selected = data.selected || { class: [], spec: [] };
for (const k of ['class', 'spec']) {
for (const t of (selected[k] || [])) {
const arr = selByTalent[k][t.id] || (selByTalent[k][t.id] = []);
arr.push(t.rank || 1);
}
}
const CELL = 58, PAD = 8;
const renderTree = (kind, title) => {
const tree = data.trees[kind];
if (!tree || !tree.nodes || !tree.nodes.length) return '';
const w = (tree.width ?? 12) + 1;
const h = (tree.height ?? 10) + 1;
const nodes = tree.nodes.map((n) => {
const ranks = selByTalent[kind][n.id] || selByTalent[kind][n.talent_id];
const on = ranks && ranks.length;
const letter = esc((n.talent_name || '?')[0]);
const badge = on ? `<span class="tn-rank">${ranks[0]}${n.max_rank > 1 ? '/' + n.max_rank : ''}</span>` : '';
const tip = n.spell_id
? `data-wowhead="spell=${n.spell_id}&domain=fr" title="${esc(n.talent_name || n.spell_name || '')}"`
: (n.talent_name ? `title="${esc(n.talent_name)}"` : '');
return `<span class="tn${on ? ' on' : ''}" style="left:${PAD + n.x * CELL}px;top:${PAD + n.y * CELL}px" ${tip}>
${n.icon ? `<img src="${esc(n.icon.startsWith('http') ? n.icon : `https://wow.zamimg.com/images/wow/icons/large/${esc(n.icon)}.jpg`)}" alt="" loading="lazy" onerror="this.parentElement.classList.add('no-icon');this.remove()">` : ''}
<span class="tn-letter">${letter}</span>
${badge}
</span>`;
}).join('');
return `<div class="tt-block">
<div class="tt-head"><b>${title}</b>${tree.name ? `<span class="muted">${esc(tree.name)}</span>` : ''}</div>
<div class="tt-scroll"><div class="tt-grid" style="width:${PAD * 2 + w * CELL}px;height:${PAD * 2 + h * CELL}px">${nodes}</div></div>
</div>`;
};
const copyBtn = data.loadout_text
? `<button type="button" class="wm-copy" data-copy="${esc(data.loadout_text)}" title="Copier le build (import en jeu)">Copier le build</button>`
: '';
return `<div class="tt-wrap">
<div class="tt-top"><span class="talent-title">Talents — ${esc(specFr(data.spec) || data.spec)}</span>${copyBtn}</div>
${renderTree('class', 'Arbre de classe')}
${renderTree('spec', 'Arbre de spécialisation')}
</div>`;
}
function renderStats(card) {
const stats = [];
stats.push(['ILVL', fmt(card.ilvl || null)]);
stats.push(['Hauts faits', fmt(card.achievement_points || 0)]);
stats.push(['Sets PvP', fmtSet(card.pvp_sets)]);
stats.push(['Sets PvE', fmtSet(card.pve_sets)]);
for (const [label, key] of [['Blitz', 'BLITZ'], ['Solo Shuffle', 'SOLO_SHUFFLE'], ['2v2', 'ARENA_2v2'], ['3v3', 'ARENA_3v3'], ['RBG', 'RATED_BG']]) {
const rating = card.pvp && card.pvp[key] ? card.pvp[key].rating : null;
const best = card.pvp_best && card.pvp_best[key] || null;
const v = rating != null ? `${fmt(rating)}${best != null ? `<span class="stat-max${best > rating ? ' on' : ''}" title="Max XP">/ ${fmt(best)}</span>` : ''}` : '—';
stats.push([label, v]);
}
$('#modal-stats').innerHTML = stats
.map(([l, v]) => `<span class="stat-chip">${l} <b>${v}</b></span>`)
.join('');
}
$('#modal-close').addEventListener('click', closeModal);
$('#class-modal-close').addEventListener('click', closeClassModal);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!backdrop.hidden) closeModal();
if (!$('#class-modal-backdrop').hidden) closeClassModal();
}
});
function initStarfield() {
const field = $('#starfield');
if (!field) return;
const count = Math.max(26, Math.min(70, Math.round((window.innerWidth * window.innerHeight) / 34000)));
const color = '#f5a623';
let html = '';
for (let i = 0; i < count; i++) {
const size = (Math.random() * 7 + 3.5).toFixed(2);
const left = (Math.random() * 100).toFixed(2);
const fall = (Math.random() * 16 + 12).toFixed(2);
const sway = (Math.random() * 4 + 3.5).toFixed(2);
const delay = (-Math.random() * 30).toFixed(2);
const peak = (Math.random() * 0.25 + 0.12).toFixed(2);
html += `<span class="star" style="left:${left}%;width:${size}px;height:${size}px;background:${color};box-shadow:0 0 ${(parseFloat(size) * 2.2).toFixed(1)}px ${color};animation-duration:${fall}s, ${sway}s;animation-delay:${delay}s, ${delay}s;--peak:${peak}"></span>`;
}
field.innerHTML = html;
}
initStarfield();
load();
})();
+100
View File
@@ -0,0 +1,100 @@
window.model3d = (function () {
const CONTENT_PATH = '/mv/';
let libsLoaded = false;
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.onload = resolve;
s.onerror = () => reject(new Error('Impossible de charger ' + src));
document.head.appendChild(s);
});
}
async function ensureLibs() {
if (libsLoaded && window.jQuery && window.ZamModelViewer) return;
if (!window.jQuery) await loadScript('https://code.jquery.com/jquery-3.7.1.min.js');
if (!window.ZamModelViewer) await loadScript(CONTENT_PATH + 'viewer/viewer.min.js');
if (!window.jQuery) throw new Error('jQuery indisponible');
if (!window.ZamModelViewer) throw new Error('Moteur 3D indisponible');
if (!window.WH) window.WH = {};
window.WH.defaultAnimation = 'Stand';
if (!window.WH.debug) window.WH.debug = function () {};
if (!window.WH.WebP) {
window.WH.WebP = {
getImageExtension: function () { return '.webp'; }
};
}
libsLoaded = true;
}
async function loadCharacterOptions(race, gender) {
const id = race * 2 - 1 + gender;
const res = await fetch(`${CONTENT_PATH}meta/charactercustomization/${id}.json`);
if (!res.ok) return { Options: [] };
return res.json();
}
function buildOptions(model, fullOptions) {
const map = {
'Face': model.appearance && model.appearance.face,
'Skin Color': model.appearance && model.appearance.skin_color,
'Hair Style': model.appearance && model.appearance.hair_variation,
'Hair Color': model.appearance && model.appearance.hair_color,
'Facial Hair': model.appearance && model.appearance.facial_hair
};
const hasAppearance = Object.values(map).some((v) => v !== null && v !== undefined && v !== false);
const options = [];
if (!hasAppearance) return options;
for (const part of fullOptions.Options || []) {
const val = map[part.Name];
if (val === null || val === undefined || val === false) continue;
const choice = part.Choices && (part.Choices[val] || part.Choices.find((c) => String(c.Id) === String(val)));
if (choice) options.push({ optionId: part.Id, choiceId: choice.Id });
}
return options;
}
async function render(container, model) {
await ensureLibs();
const $c = jQuery(container);
$c.empty();
const viewerOptions = {
type: 2,
contentPath: CONTENT_PATH,
container: $c,
aspect: 1,
models: { id: model.race * 2 - 1 + model.gender, type: 16 },
items: model.items || [],
hd: true
};
let viewer = null;
try {
const options = buildOptions(model, await loadCharacterOptions(model.race, model.gender));
if (options.length) viewerOptions.charCustomization = { options };
viewer = await new window.ZamModelViewer(viewerOptions);
} catch (e) {
viewer = null;
}
if (!viewer) {
const options = buildOptions(model, { Options: [] });
viewer = await new window.ZamModelViewer(Object.assign({}, viewerOptions, { charCustomization: { options } }));
}
try { viewer.renderer.distance = 2.1; } catch (e) {}
try {
const actor = viewer.renderer.actors && viewer.renderer.actors[0];
if (actor && actor.setAnimation) actor.setAnimation('Stand');
} catch (e) {}
return viewer;
}
function dispose(viewer) {
try { viewer && viewer.dispose && viewer.dispose(); } catch (e) {}
}
return { render, dispose, ensureLibs };
})();
+18
View File
@@ -0,0 +1,18 @@
{
"name": "Wycz - World of Warcraft",
"short_name": "Wycz",
"description": "Objectifs de saison, classes et cotes PvP de Wycz.",
"lang": "fr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0c0e14",
"theme_color": "#0c0e14",
"icons": [
{ "src": "/images/icons/icon-96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/images/icons/icon-180.png", "sizes": "180x180", "type": "image/png" },
{ "src": "/images/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/images/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+68
View File
@@ -0,0 +1,68 @@
const CACHE = 'wycz-v1';
const CORE = [
'/',
'/css/style.css',
'/js/app.js',
'/manifest.webmanifest',
'/images/logo.png',
'/images/icons/icon-96.png',
'/images/icons/icon-192.png',
'/images/icons/icon-512.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE)
.then((cache) => cache.addAll(CORE))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith('/api/')) {
event.respondWith(fetch(req));
return;
}
if (req.mode === 'navigate') {
event.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put('/', copy));
return res;
})
.catch(() => caches.match('/'))
);
return;
}
event.respondWith(
caches.match(req).then((cached) => {
const network = fetch(req)
.then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return res;
})
.catch(() => cached);
return cached || network;
})
);
});
+58
View File
@@ -0,0 +1,58 @@
import crypto from 'node:crypto';
import { config } from './config.js';
import { getSetting } from './db.js';
export function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
return `${salt}:${hash}`;
}
export function verifyPassword(password, stored) {
if (!stored) return false;
const [salt, hash] = stored.split(':');
if (!salt || !hash) return false;
const test = crypto.scryptSync(password, salt, 64).toString('hex');
return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(test, 'hex'));
}
export function sign(payload) {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = crypto.createHmac('sha256', config.sessionSecret).update(body).digest('base64url');
return `${body}.${sig}`;
}
export function verify(token) {
if (!token) return null;
const [body, sig] = token.split('.');
if (!body || !sig) return null;
const expected = crypto.createHmac('sha256', config.sessionSecret).update(body).digest('base64url');
let ok = false;
try {
ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
} catch {
return null;
}
if (!ok) return null;
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
if (payload.exp && payload.exp < Date.now()) return null;
return payload;
} catch {
return null;
}
}
export function issueSession() {
return sign({ user: 'admin', exp: Date.now() + 1000 * 60 * 60 * 24 * 30 });
}
export function isAdmin(req) {
const cookie = req.cookies?.wycz_session;
const payload = verify(cookie);
return payload && payload.user === 'admin';
}
export function adminPassword() {
return getSetting('admin_password');
}
+34
View File
@@ -0,0 +1,34 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const config = {
port: Number(process.env.PORT || 3004),
dataDir: process.env.DATA_DIR || path.join(__dirname, '..', 'data'),
dbPath: process.env.DB_PATH || path.join(process.env.DATA_DIR || path.join(__dirname, '..', 'data'), 'wycz.db'),
sessionSecret: process.env.SESSION_SECRET || 'wycz-session-secret-change-me',
adminPassword: process.env.ADMIN_PASSWORD || 'admin',
adminPath: process.env.ADMIN_PATH || '/admin',
blizzardClientId: process.env.BLIZZARD_CLIENT_ID || '',
blizzardClientSecret: process.env.BLIZZARD_CLIENT_SECRET || '',
fetchIntervalHours: Number(process.env.FETCH_INTERVAL_HOURS || 6),
publicUrl: process.env.PUBLIC_URL || 'http://localhost:3004',
wowToolsBuild: process.env.WOW_TOOLS_BUILD || '12.1.0.69111'
};
export const classColors = {
'Death Knight': '#C41F3B',
'Demon Hunter': '#A330C9',
'Druid': '#FF7D0A',
'Evoker': '#33937F',
'Hunter': '#ABD473',
'Mage': '#40C7EB',
'Monk': '#00FF96',
'Paladin': '#F58CBA',
'Priest': '#FFFFFF',
'Rogue': '#FFF569',
'Shaman': '#0070DE',
'Warlock': '#8787ED',
'Warrior': '#C79C6E'
};
+201
View File
@@ -0,0 +1,201 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { config } from './config.js';
import { hashPassword } from './auth.js';
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
export const db = new Database(config.dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS characters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
region TEXT NOT NULL DEFAULT 'eu',
realm TEXT NOT NULL,
name TEXT NOT NULL,
realm_slug TEXT,
class TEXT,
spec TEXT,
level INTEGER,
faction TEXT,
race TEXT,
race_id INTEGER,
gender INTEGER,
note TEXT DEFAULT '',
active INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0,
data TEXT DEFAULT '{}',
last_updated TEXT,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(region, realm, name)
);
CREATE TABLE IF NOT EXISTS goals_config (
key TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
target REAL,
display_max REAL,
unit TEXT DEFAULT '',
enabled INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0,
source TEXT DEFAULT 'mixed'
);
CREATE TABLE IF NOT EXISTS goals_state (
key TEXT PRIMARY KEY,
label TEXT,
current TEXT DEFAULT '{}',
progress REAL DEFAULT 0,
achieved INTEGER DEFAULT 0,
status TEXT DEFAULT 'ok',
detail TEXT DEFAULT '{}',
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS settings (
k TEXT PRIMARY KEY,
v TEXT
);
CREATE TABLE IF NOT EXISTS item_cache (
item_id INTEGER PRIMARY KEY,
display_id INTEGER,
display_type INTEGER,
resolved_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS fetch_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER,
source TEXT,
status TEXT,
detail TEXT,
at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS pvp_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL,
bracket TEXT NOT NULL,
rating INTEGER NOT NULL,
tier TEXT,
won INTEGER DEFAULT 0,
lost INTEGER DEFAULT 0,
at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_pvp_history_char ON pvp_history (character_id, bracket);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bracket TEXT NOT NULL,
won INTEGER NOT NULL,
at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_sessions_bracket ON sessions (bracket, at);
CREATE TABLE IF NOT EXISTS character_titles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL,
title TEXT NOT NULL,
season TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_character_titles_char ON character_titles (character_id);
CREATE TABLE IF NOT EXISTS talent_cache (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
resolved_at TEXT DEFAULT (datetime('now'))
);
`);
function seedGoals() {
const insert = db.prepare(`INSERT OR IGNORE INTO goals_config (key, title, description, target, display_max, unit, enabled, sort_order, source) VALUES (?,?,?,?,?,?,1,?,?)`);
const goals = [
['gladiator_title', 'Titre Gladiateur', 'Obtenir le titre Gladiateur sur un personnage', 1, 1, 'titre', 1, 'blizzard'],
['mythic_plus_max', 'Score Mythic+ maximal', 'Monter le plus haut possible en Mythic+', null, 10000, 'score', 2, 'raiderio'],
['all_classes_90', 'Toutes les classes niveau 90', 'Un personnage de chaque classe au niveau maximum', 90, 90, 'niveau', 3, 'mixed'],
['blitz_2800', 'Blitz 2800', 'Atteindre 2800 de rating en Blitz', 2800, 3000, 'rating', 4, 'blizzard'],
['shuffle_2300', 'Solo Shuffle 2300', 'Atteindre 2300 de rating en Solo Shuffle', 2300, 3000, 'rating', 5, 'blizzard'],
['arena2v2_2300', '2v2 2300', 'Atteindre 2300 de rating en 2v2', 2300, 3000, 'rating', 6, 'blizzard'],
['arena3v3_2300', '3v3 2300', 'Atteindre 2300 de rating en 3v3', 2300, 3000, 'rating', 7, 'blizzard'],
['achievements', 'Hauts faits', 'Cumuler le maximum de points de hauts faits', null, 50000, 'points', 8, 'blizzard'],
['mythic_raids', 'Raids en mode Mythique', 'Terminer tous les raids en mode Mythique', null, 1, 'raids', 9, 'raiderio'],
['all_sets', 'Tous les sets', 'Collectionner tous les sets d\u2019armure du jeu', null, 1000, 'sets', 10, 'blizzard']
];
for (const g of goals) insert.run(...g);
}
function seedSettings() {
const get = db.prepare('SELECT v FROM settings WHERE k = ?');
const set = db.prepare('INSERT OR IGNORE INTO settings (k, v) VALUES (?, ?)');
if (!get.get('admin_password')) set.run('admin_password', hashPassword(config.adminPassword));
if (!get.get('admin_path')) set.run('admin_path', config.adminPath);
if (!get.get('fetch_interval_hours')) set.run('fetch_interval_hours', String(config.fetchIntervalHours));
if (!get.get('blizzard_client_id')) set.run('blizzard_client_id', config.blizzardClientId);
if (!get.get('blizzard_client_secret')) set.run('blizzard_client_secret', config.blizzardClientSecret);
if (!get.get('last_fetch_at')) set.run('last_fetch_at', '');
if (!get.get('manual_class90_count')) set.run('manual_class90_count', '');
if (!get.get('manual_gladiator_s2')) set.run('manual_gladiator_s2', '');
if (!get.get('manual_mythic_bosses')) set.run('manual_mythic_bosses', '');
if (!get.get('manual_achievements')) set.run('manual_achievements', '');
if (!get.get('manual_achievements_total')) set.run('manual_achievements_total', '5843');
if (!get.get('manual_class90_classes')) set.run('manual_class90_classes', '');
if (!get.get('manual_ss2300_classes')) set.run('manual_ss2300_classes', '');
if (!get.get('manual_glad_wins')) set.run('manual_glad_wins', '');
if (!get.get('manual_glad_class')) set.run('manual_glad_class', '');
if (!get.get('twitch_url')) set.run('twitch_url', 'https://twitch.tv/wycz');
if (!get.get('youtube_url')) set.run('youtube_url', '');
if (!get.get('tiktok_url')) set.run('tiktok_url', '');
if (!get.get('mythic_raid_name')) set.run('mythic_raid_name', "L'Abîme Venimeux");
if (!get.get('mythic_raid_total')) set.run('mythic_raid_total', '8');
if (!get.get('glad_title_season')) set.run('glad_title_season', 'S1 Midnight');
if (!get.get('legend_title_season')) set.run('legend_title_season', 'S1 Midnight');
}
seedGoals();
seedSettings();
function migrate() {
const cols = db.prepare('PRAGMA table_info(characters)').all().map((c) => c.name);
if (!cols.includes('realm_slug')) {
db.exec('ALTER TABLE characters ADD COLUMN realm_slug TEXT');
}
if (!cols.includes('pvp_sets')) {
db.exec('ALTER TABLE characters ADD COLUMN pvp_sets INTEGER DEFAULT 0');
}
if (!cols.includes('pve_sets')) {
db.exec('ALTER TABLE characters ADD COLUMN pve_sets INTEGER DEFAULT 0');
}
if (!cols.includes('pvp_manual')) {
db.exec('ALTER TABLE characters ADD COLUMN pvp_manual TEXT DEFAULT \'{}\'');
}
if (!cols.includes('check_pvp_url')) {
db.exec('ALTER TABLE characters ADD COLUMN check_pvp_url TEXT DEFAULT \'\'');
}
if (!cols.includes('glad_title')) {
db.exec('ALTER TABLE characters ADD COLUMN glad_title INTEGER DEFAULT 0');
}
if (!cols.includes('legend_title')) {
db.exec('ALTER TABLE characters ADD COLUMN legend_title INTEGER DEFAULT 0');
}
}
migrate();
export function getSetting(k) {
const row = db.prepare('SELECT v FROM settings WHERE k = ?').get(k);
return row ? row.v : null;
}
export function setSetting(k, v) {
db.prepare('INSERT INTO settings (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v').run(k, v);
}
export function logFetch(characterId, source, status, detail) {
db.prepare('INSERT INTO fetch_log (character_id, source, status, detail) VALUES (?, ?, ?, ?)').run(characterId, source, status, JSON.stringify(detail));
}
+241
View File
@@ -0,0 +1,241 @@
import { Router } from 'express';
import { db, getSetting, setSetting } from '../db.js';
import { isAdmin } from '../auth.js';
import { refreshAll, refreshOne } from '../services/fetcher.js';
import { hashPassword, verifyPassword, issueSession } from '../auth.js';
import { recomputeGoals } from '../services/goals.js';
export const adminRouter = Router();
function requireAdmin(req, res, next) {
if (!isAdmin(req)) return res.status(401).json({ error: 'Non autorisé' });
next();
}
adminRouter.post('/login', (req, res) => {
const { password } = req.body || {};
const stored = getSetting('admin_password');
if (!verifyPassword(password || '', stored)) {
return res.status(401).json({ error: 'Mot de passe incorrect' });
}
res.cookie('wycz_session', issueSession(), {
httpOnly: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000,
secure: req.secure || req.get('x-forwarded-proto') === 'https'
});
res.json({ ok: true });
});
adminRouter.post('/logout', (req, res) => {
res.clearCookie('wycz_session');
res.json({ ok: true });
});
adminRouter.get('/me', requireAdmin, (req, res) => res.json({ admin: true }));
adminRouter.get('/characters', requireAdmin, (req, res) => {
const chars = db.prepare('SELECT id, region, realm, name, class, spec, level, faction, race, race_id, gender, note, active, sort_order, pvp_sets, pve_sets, pvp_manual, check_pvp_url, glad_title, legend_title, last_updated, created_at FROM characters ORDER BY sort_order, name').all();
const titles = db.prepare('SELECT id, character_id, title, season FROM character_titles ORDER BY id').all();
const byChar = {};
for (const t of titles) {
(byChar[t.character_id] = byChar[t.character_id] || []).push(t);
}
for (const c of chars) c.titles = byChar[c.id] || [];
res.json(chars);
});
adminRouter.post('/characters', requireAdmin, (req, res) => {
const { region, realm, name, note = '', realm_slug, class: cls } = req.body || {};
if (!realm || !name) return res.status(400).json({ error: 'realm et name requis' });
const maxSort = db.prepare('SELECT MAX(sort_order) as m FROM characters').get().m || 0;
try {
const info = db.prepare('INSERT INTO characters (region, realm, name, note, realm_slug, class, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(region || 'eu', realm.trim(), name.trim(), note, realm_slug || null, cls || null, maxSort + 1);
res.json({ ok: true, id: info.lastInsertRowid });
} catch (e) {
if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') return res.status(409).json({ error: 'Ce personnage existe déjà' });
res.status(500).json({ error: e.message });
}
});
adminRouter.put('/characters/:id', requireAdmin, (req, res) => {
const id = req.params.id;
const allowed = ['region', 'realm', 'name', 'realm_slug', 'note', 'active', 'sort_order', 'pvp_sets', 'pve_sets', 'pvp_manual', 'class', 'level', 'check_pvp_url', 'glad_title', 'legend_title'];
const sets = [];
const vals = [];
for (const k of allowed) {
if (k in (req.body || {})) {
sets.push(`${k} = ?`);
vals.push(req.body[k]);
}
}
if (!sets.length) return res.status(400).json({ error: 'rien à mettre à jour' });
vals.push(id);
db.prepare(`UPDATE characters SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
res.json({ ok: true });
});
adminRouter.delete('/characters/:id', requireAdmin, (req, res) => {
db.prepare('DELETE FROM characters WHERE id = ?').run(req.params.id);
res.json({ ok: true });
});
adminRouter.post('/characters/:id/titles', requireAdmin, (req, res) => {
const { title, season = '' } = req.body || {};
if (!['glad', 'legend'].includes(title)) return res.status(400).json({ error: 'title doit être glad ou legend' });
const char = db.prepare('SELECT id FROM characters WHERE id = ?').get(req.params.id);
if (!char) return res.status(404).json({ error: 'Introuvable' });
const info = db.prepare('INSERT INTO character_titles (character_id, title, season) VALUES (?, ?, ?)').run(char.id, title, String(season).trim());
res.json({ ok: true, id: info.lastInsertRowid });
});
adminRouter.delete('/characters/:id/titles/:titleId', requireAdmin, (req, res) => {
db.prepare('DELETE FROM character_titles WHERE id = ? AND character_id = ?').run(req.params.titleId, req.params.id);
res.json({ ok: true });
});
adminRouter.post('/refresh', requireAdmin, async (req, res) => {
const { id } = req.body || {};
res.json({ ok: true, started: true });
try {
if (id) {
await refreshOne(id);
} else {
await refreshAll();
}
} catch (e) {
console.error('[refresh]', e.message);
}
});
adminRouter.get('/goals', requireAdmin, (req, res) => {
const goals = db.prepare(`
SELECT g.*, s.current, s.progress, s.achieved, s.status, s.updated_at
FROM goals_config g LEFT JOIN goals_state s ON g.key = s.key
ORDER BY g.sort_order`).all();
res.json(goals);
});
adminRouter.put('/goals/:key', requireAdmin, (req, res) => {
const { key } = req.params;
const allowed = ['title', 'description', 'target', 'display_max', 'unit', 'enabled', 'sort_order', 'source'];
const sets = [];
const vals = [];
for (const k of allowed) {
if (k in (req.body || {})) {
sets.push(`${k} = ?`);
vals.push(req.body[k]);
}
}
if (!sets.length) return res.status(400).json({ error: 'rien à mettre à jour' });
vals.push(key);
db.prepare(`UPDATE goals_config SET ${sets.join(', ')} WHERE key = ?`).run(...vals);
recomputeGoals();
res.json({ ok: true });
});
adminRouter.get('/settings', requireAdmin, (req, res) => {
res.json({
fetch_interval_hours: getSetting('fetch_interval_hours'),
blizzard_client_id: getSetting('blizzard_client_id'),
blizzard_client_secret: getSetting('blizzard_client_secret') ? '********' : '',
blizzard_configured: !!getSetting('blizzard_client_secret'),
last_fetch_at: getSetting('last_fetch_at'),
wow_tools_build: getSetting('wow_tools_build'),
manual_class90_count: getSetting('manual_class90_count') || '',
manual_class90_classes: getSetting('manual_class90_classes') || '',
manual_ss2300_classes: getSetting('manual_ss2300_classes') || '',
manual_gladiator_s2: getSetting('manual_gladiator_s2') || '',
manual_glad_wins: getSetting('manual_glad_wins') || '',
manual_glad_class: getSetting('manual_glad_class') || '',
manual_mythic_bosses: getSetting('manual_mythic_bosses') || '',
manual_achievements: getSetting('manual_achievements') || '',
manual_achievements_total: getSetting('manual_achievements_total') || '',
twitch_url: getSetting('twitch_url') || '',
youtube_url: getSetting('youtube_url') || '',
tiktok_url: getSetting('tiktok_url') || '',
mythic_raid_name: getSetting('mythic_raid_name') || '',
mythic_raid_total: getSetting('mythic_raid_total') || '',
glad_title_season: getSetting('glad_title_season') || '',
legend_title_season: getSetting('legend_title_season') || ''
});
});
adminRouter.put('/settings', requireAdmin, (req, res) => {
const { fetch_interval_hours, blizzard_client_id, blizzard_client_secret, wow_tools_build,
manual_class90_count, manual_class90_classes, manual_ss2300_classes, manual_gladiator_s2, manual_glad_wins, manual_glad_class,
manual_mythic_bosses, manual_achievements, manual_achievements_total,
twitch_url, youtube_url, tiktok_url, mythic_raid_name, mythic_raid_total,
glad_title_season, legend_title_season } = req.body || {};
if (fetch_interval_hours) setSetting('fetch_interval_hours', String(fetch_interval_hours));
if (blizzard_client_id !== undefined) setSetting('blizzard_client_id', blizzard_client_id);
if (blizzard_client_secret && blizzard_client_secret !== '********') setSetting('blizzard_client_secret', blizzard_client_secret);
if (wow_tools_build) setSetting('wow_tools_build', wow_tools_build);
if (manual_class90_count !== undefined) setSetting('manual_class90_count', String(manual_class90_count).trim());
if (manual_class90_classes !== undefined) setSetting('manual_class90_classes', String(manual_class90_classes).trim());
if (manual_ss2300_classes !== undefined) setSetting('manual_ss2300_classes', String(manual_ss2300_classes).trim());
if (manual_gladiator_s2 !== undefined) setSetting('manual_gladiator_s2', manual_gladiator_s2 ? '1' : '');
if (manual_glad_wins !== undefined) setSetting('manual_glad_wins', String(manual_glad_wins).trim());
if (manual_glad_class !== undefined) setSetting('manual_glad_class', String(manual_glad_class).trim());
if (manual_mythic_bosses !== undefined) setSetting('manual_mythic_bosses', String(manual_mythic_bosses).trim());
if (manual_achievements !== undefined) setSetting('manual_achievements', String(manual_achievements).trim());
if (twitch_url !== undefined) setSetting('twitch_url', String(twitch_url).trim());
if (youtube_url !== undefined) setSetting('youtube_url', String(youtube_url).trim());
if (tiktok_url !== undefined) setSetting('tiktok_url', String(tiktok_url).trim());
if (mythic_raid_name !== undefined) setSetting('mythic_raid_name', String(mythic_raid_name).trim());
if (mythic_raid_total !== undefined) setSetting('mythic_raid_total', String(mythic_raid_total).trim());
if (glad_title_season !== undefined) setSetting('glad_title_season', String(glad_title_season).trim());
if (legend_title_season !== undefined) setSetting('legend_title_season', String(legend_title_season).trim());
res.json({ ok: true });
});
adminRouter.post('/password', requireAdmin, (req, res) => {
const { current, next } = req.body || {};
const stored = getSetting('admin_password');
if (!verifyPassword(current || '', stored)) return res.status(401).json({ error: 'Mot de passe actuel incorrect' });
if (!next || next.length < 6) return res.status(400).json({ error: 'Nouveau mot de passe trop court (min 6 caractères)' });
setSetting('admin_password', hashPassword(next));
res.json({ ok: true });
});
adminRouter.get('/logs', requireAdmin, (req, res) => {
const logs = db.prepare('SELECT * FROM fetch_log ORDER BY id DESC LIMIT 100').all();
res.json(logs);
});
adminRouter.post('/sessions', requireAdmin, (req, res) => {
const { bracket, won } = req.body || {};
if (!bracket || (won !== 1 && won !== 0)) return res.status(400).json({ error: 'bracket et won (0/1) requis' });
db.prepare('INSERT INTO sessions (bracket, won) VALUES (?, ?)').run(String(bracket), won ? 1 : 0);
res.json({ ok: true, sessions: sessionSummary() });
});
adminRouter.post('/sessions/undo', requireAdmin, (req, res) => {
const { bracket } = req.body || {};
if (bracket) {
db.prepare('DELETE FROM sessions WHERE id = (SELECT id FROM sessions WHERE bracket = ? ORDER BY id DESC LIMIT 1)').run(String(bracket));
} else {
db.prepare('DELETE FROM sessions WHERE id = (SELECT id FROM sessions ORDER BY id DESC LIMIT 1)').run();
}
res.json({ ok: true, sessions: sessionSummary() });
});
adminRouter.get('/sessions', requireAdmin, (req, res) => {
res.json(sessionSummary());
});
function sessionSummary() {
const today = db.prepare(`
SELECT bracket, SUM(won) AS wins, COUNT(*) - SUM(won) AS losses
FROM sessions
WHERE date(at) = date('now', 'localtime')
GROUP BY bracket`).all();
const week = db.prepare(`
SELECT bracket, SUM(won) AS wins, COUNT(*) - SUM(won) AS losses
FROM sessions
WHERE at >= datetime('now', 'localtime', '-7 days')
GROUP BY bracket`).all();
const last = db.prepare('SELECT bracket, won, at FROM sessions ORDER BY id DESC LIMIT 20').all();
return { today, week, last };
}
+493
View File
@@ -0,0 +1,493 @@
import { Router } from 'express';
import { db, getSetting } from '../db.js';
import { recomputeGoals } from '../services/goals.js';
import { apiStatic, SPEC_IDS } from '../services/blizzard.js';
export const publicRouter = Router();
publicRouter.get('/overview', (req, res) => {
const chars = db.prepare('SELECT * FROM characters WHERE active = 1').all().map((c) => {
const data = safeParse(c.data);
return { ...c, data };
});
const manualClass90 = parseInt(getSetting('manual_class90_count') || '', 10);
const manualClass90List = (getSetting('manual_class90_classes') || '').split(',').map((s) => s.trim()).filter(Boolean);
const manualSS2300List = (getSetting('manual_ss2300_classes') || '').split(',').map((s) => s.trim()).filter(Boolean);
const manualGlad = getSetting('manual_gladiator_s2') || '';
const manualGladWins = parseInt(getSetting('manual_glad_wins') || '', 10);
const manualBosses = parseInt(getSetting('manual_mythic_bosses') || '', 10);
const manualAch = parseInt(getSetting('manual_achievements') || '', 10);
const classes90 = new Set();
const classesSS2300 = new Set();
let max3v3 = null;
let max3v3Char = null;
const bests = {};
const track = (bracket, rating, meta) => {
if (!rating) return;
if (!bests[bracket] || rating > bests[bracket].rating) bests[bracket] = { rating, ...meta };
};
for (const c of chars) {
const cls = c.data?.blizzard?.class || c.data?.raiderio?.class || c.class;
const level = c.data?.blizzard?.level ?? c.data?.raiderio?.level ?? c.level ?? null;
if (cls && typeof level === 'number' && level >= 90) classes90.add(cls);
let ss = null;
let a3 = null;
const pm = safeParse(c.pvp_manual);
if (pm.SOLO_SHUFFLE) ss = pm.SOLO_SHUFFLE.season || null;
if (pm.ARENA_3v3) a3 = Math.max(pm.ARENA_3v3.season || 0, pm.ARENA_3v3.max || 0) || null;
if (pm.specs) {
for (const k of Object.keys(pm.specs)) {
const s = pm.specs[k].SOLO_SHUFFLE || {};
const v = s.season || null;
if (v && v > (ss || 0)) ss = v;
const trackSpec = (br) => {
const m = (pm.specs[k][br] || {});
const r = m.max || m.season || null;
if (r) track(br, r, { name: c.name, class: cls, spec: k });
};
trackSpec('SOLO_SHUFFLE');
trackSpec('BLITZ');
}
}
const autoSolo = c.data?.blizzard?.pvp?.SOLO_SHUFFLE || c.data?.raiderio?.pvp?.SOLO_SHUFFLE || {};
if (ss === null && typeof autoSolo.rating === 'number') ss = autoSolo.rating;
const autoArena = c.data?.blizzard?.pvp?.ARENA_3v3 || c.data?.raiderio?.pvp?.ARENA_3v3 || {};
if (a3 === null && typeof autoArena.rating === 'number') a3 = autoArena.rating;
if (cls && typeof ss === 'number' && ss >= 2300) classesSS2300.add(cls);
if (typeof a3 === 'number' && a3 > (max3v3 ?? 0)) { max3v3 = a3; max3v3Char = cls; }
for (const k of ['ARENA_2v2', 'ARENA_3v3', 'RATED_BG']) {
const m = pm[k] || {};
const auto = c.data?.blizzard?.pvp?.[k] || c.data?.raiderio?.pvp?.[k] || {};
const r = m.max || m.season || (typeof auto.rating === 'number' ? auto.rating : null) || null;
if (r) track(k, r, { name: c.name, class: cls, spec: c.spec || null });
}
}
const class90List = manualClass90List.length ? manualClass90List
: (Number.isFinite(manualClass90) ? [] : [...classes90]);
const class90Covered = manualClass90List.length ? manualClass90List.length
: (Number.isFinite(manualClass90) ? Math.max(0, Math.min(13, manualClass90)) : classes90.size);
const class90Manual = manualClass90List.length > 0 || Number.isFinite(manualClass90);
const ss2300List = manualSS2300List.length ? manualSS2300List : [...classesSS2300];
const ss2300Covered = manualSS2300List.length ? manualSS2300List.length : classesSS2300.size;
const ss2300Manual = manualSS2300List.length > 0;
const gladWins = Number.isFinite(manualGladWins) ? Math.max(0, Math.min(50, manualGladWins)) : 0;
const mythicRaidName = getSetting('mythic_raid_name') || "L'Abîme Venimeux";
const mythicRaidTotal = parseInt(getSetting('mythic_raid_total') || '8', 10);
let autoBosses = null;
let autoAch = null;
let glads = [];
for (const c of chars) {
const blizz = c.data?.blizzard || {};
const ri = c.data?.raiderio || {};
if (Array.isArray(blizz.titles)) glads = glads.concat(blizz.titles);
const ach = blizz.achievement_count ?? null;
if (typeof ach === 'number') autoAch = Math.max(autoAch ?? 0, ach);
const raids = ri.raid_progression || {};
const lastRaid = Object.entries(raids)
.filter(([, r]) => r?.mythic?.total > 0)
.sort(([, a], [, b]) => b.mythic.total - a.mythic.total)[0];
if (lastRaid && typeof lastRaid[1].mythic.killed === 'number') {
autoBosses = Math.max(autoBosses ?? 0, lastRaid[1].mythic.killed);
}
}
const gladiatorFound = glads.some((t) => typeof t === 'string' && /gladiateur/i.test(t));
const gladObtained = manualGlad === '1' || gladWins >= 50 || (manualGlad === '' && manualGladWins === '' && gladiatorFound);
const sessions = sessionSummary();
res.json({
class90: { covered: class90Covered, total: 13, manual: class90Manual, classes: class90List },
ss2300: { covered: ss2300Covered, total: 13, manual: ss2300Manual, classes: ss2300List },
gladiator_s2: {
obtained: gladObtained,
wins: gladWins,
total: 50,
rating: max3v3,
class: getSetting('manual_glad_class') || max3v3Char || null
},
mythic_raid: {
name: mythicRaidName,
total: mythicRaidTotal,
killed: Number.isFinite(manualBosses) ? manualBosses : (autoBosses ?? 0),
manual: Number.isFinite(manualBosses)
},
achievements: {
points: Number.isFinite(manualAch) ? manualAch : (autoAch ?? 0),
total: parseInt(getSetting('manual_achievements_total') || '5843', 10) || 5843,
manual: Number.isFinite(manualAch)
},
twitch: { url: getSetting('twitch_url') || 'https://twitch.tv/wycz' },
youtube: { url: getSetting('youtube_url') || '' },
tiktok: { url: getSetting('tiktok_url') || '' },
titles: titlesOverview(chars),
bests,
sessions
});
});
function titlesOverview(chars) {
const entries = db.prepare(`
SELECT ct.character_id, ct.title, ct.season, c.class
FROM character_titles ct
JOIN characters c ON c.id = ct.character_id
WHERE c.active = 1
ORDER BY ct.id`).all();
const classOf = (c) => c.data?.blizzard?.class || c.data?.raiderio?.class || c.class;
const byTitle = { glad: [], legend: [] };
for (const e of entries) {
const cls = classOf(chars.find((c) => c.id === e.character_id) || { class: e.class });
if (!cls) continue;
if (e.title === 'glad') byTitle.glad.push({ class: cls, season: e.season || '' });
else if (e.title === 'legend') byTitle.legend.push({ class: cls, season: e.season || '' });
}
const isS2 = (s) => /s2/i.test(s || '');
if (!entries.length) {
const gladSeasonS2 = isS2(getSetting('glad_title_season'));
for (const c of chars) {
const cls = classOf(c);
if (!cls) continue;
if (c.glad_title && gladSeasonS2) byTitle.glad.push({ class: cls, season: getSetting('glad_title_season') || '' });
if (c.legend_title) byTitle.legend.push({ class: cls, season: getSetting('legend_title_season') || '' });
}
}
return {
glad: byTitle.glad.map((e) => e.class),
legend: byTitle.legend.map((e) => e.class),
glad_s2: byTitle.glad.filter((e) => isS2(e.season)).map((e) => e.class),
glad_season: getSetting('glad_title_season') || '',
legend_season: getSetting('legend_title_season') || '',
glad_entries: byTitle.glad,
legend_entries: byTitle.legend
};
}
function sessionSummary() {
const rows = db.prepare('SELECT bracket, won, at FROM sessions ORDER BY id DESC LIMIT 500').all();
const today = {};
const week = {};
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const weekAgo = new Date(now.getTime() - 7 * 24 * 3600 * 1000).toISOString();
for (const r of rows) {
const at = r.at.replace(' ', 'T') + 'Z';
const d = new Date(at);
const day = d.toISOString().slice(0, 10);
if (day === todayStr) {
today[r.bracket] = today[r.bracket] || { wins: 0, losses: 0 };
if (r.won) today[r.bracket].wins++; else today[r.bracket].losses++;
}
if (at >= weekAgo) {
week[r.bracket] = week[r.bracket] || { wins: 0, losses: 0 };
if (r.won) week[r.bracket].wins++; else week[r.bracket].losses++;
}
}
const perBracket = {};
const order = [];
for (const r of rows) {
if (!(r.bracket in perBracket)) {
perBracket[r.bracket] = { wins: 0, losses: 0, streak: 0, streakKind: null };
order.push(r.bracket);
}
const b = perBracket[r.bracket];
if (b.streak === 0 || b.streakKind === r.won) {
b.streak = r.won ? b.streak + 1 : b.streak - 1;
b.streakKind = r.won;
}
}
const out = {};
for (const br of order) {
out[br] = { today: today[br] || { wins: 0, losses: 0 }, week: week[br] || { wins: 0, losses: 0 }, streak: perBracket[br].streak };
}
return out;
}
publicRouter.get('/goals', (req, res) => {
recomputeGoals();
const goals = db.prepare(`
SELECT g.key, g.title, g.description, g.target, g.display_max, g.unit, g.source,
s.current, s.progress, s.achieved, s.status, s.updated_at
FROM goals_config g LEFT JOIN goals_state s ON g.key = s.key
WHERE g.enabled = 1
ORDER BY g.sort_order`).all();
res.json(goals);
});
publicRouter.get('/characters', (req, res) => {
const chars = db.prepare('SELECT * FROM characters WHERE active = 1 ORDER BY sort_order, name').all().map((c) => {
const data = safeParse(c.data);
return publicCharacter(c, data);
});
res.json(chars);
});
publicRouter.get('/characters/:id', (req, res) => {
const c = db.prepare('SELECT * FROM characters WHERE id = ? AND active = 1').get(req.params.id);
if (!c) return res.status(404).json({ error: 'Introuvable' });
const data = safeParse(c.data);
res.json(publicCharacter(c, data, true));
});
publicRouter.get('/characters/:id/pvp-history', (req, res) => {
const c = db.prepare('SELECT * FROM characters WHERE id = ? AND active = 1').get(req.params.id);
if (!c) return res.status(404).json({ error: 'Introuvable' });
const rows = db.prepare('SELECT bracket, rating, tier, won, lost, at FROM pvp_history WHERE character_id = ? ORDER BY at ASC').all(c.id);
const series = {};
const best = {};
for (const r of rows) {
if (!series[r.bracket]) series[r.bracket] = [];
series[r.bracket].push({ at: r.at, rating: r.rating });
if (!(r.bracket in best) || r.rating > best[r.bracket]) best[r.bracket] = r.rating;
}
res.json({ series, best });
});
publicRouter.get('/characters/:id/model3d', async (req, res) => {
const c = db.prepare('SELECT * FROM characters WHERE id = ? AND active = 1').get(req.params.id);
if (!c) return res.status(404).json({ error: 'Introuvable' });
const data = safeParse(c.data);
const blizz = data.blizzard || {};
const raider = data.raiderio || {};
const model = {
race: blizz.race_id ?? raider.race_id ?? c.race_id ?? null,
gender: blizz.gender ?? raider.gender ?? c.gender ?? 0,
appearance: blizz.appearance || null,
items: (data.model3d && data.model3d.items) || []
};
if (model.race === null) {
return res.status(404).json({ error: 'Modèle non disponible (race inconnue)' });
}
res.json(model);
});
publicRouter.get('/characters/:id/talents', async (req, res) => {
const c = db.prepare('SELECT * FROM characters WHERE id = ? AND active = 1').get(req.params.id);
if (!c) return res.status(404).json({ error: 'Introuvable' });
const data = safeParse(c.data);
const spec = req.query.spec || data.blizzard?.spec || data.raiderio?.spec || c.spec || null;
const cls = data.blizzard?.class || data.raiderio?.class || c.class || null;
if (!cls || !spec) return res.status(400).json({ error: 'Classe/spécialisation inconnue' });
const specializations = data.blizzard?.specializations;
const specId = SPEC_IDS[cls]?.[spec];
if (!specId) return res.status(400).json({ error: `Spécialisation ${spec} inconnue` });
const specEntry = (specializations?.specs || []).find((s) => s.spec_id === specId) || null;
const selected = specEntry ? { class: [], spec: [] } : null;
if (specEntry?.talents) {
for (const t of specEntry.talents) {
if (t.tree === 'spec') selected.spec.push(t);
else selected.class.push(t);
}
}
try {
const treeIds = [];
if (specEntry?.class_tree_id) treeIds.push([specEntry.class_tree_id, 'class']);
if (specEntry?.spec_tree_id) treeIds.push([specEntry.spec_tree_id, 'spec']);
if (!treeIds.length) {
const resolved = await resolveTreeIds(c.region, specId);
for (const [tid, kind] of resolved) treeIds.push([tid, kind]);
}
if (!treeIds.length) return res.status(404).json({ error: 'Arbre de talents introuvable' });
const trees = {};
for (const [treeId, kind] of treeIds) {
trees[kind] = await loadTalentTree(c.region, treeId, specId, kind);
}
res.json({
cls,
spec,
spec_id: specId,
active: specializations?.active_spec_id != null
? (specEntry?.spec_id === specializations.active_spec_id)
: (specEntry?.spec === (specializations?.active_spec ?? null)),
loadout_text: specEntry?.loadout_text || null,
selected,
trees
});
} catch (e) {
res.status(e.code === 'NOT_CONFIGURED' ? 503 : 502).json({
error: e.code === 'NOT_CONFIGURED' ? 'API Blizzard non configurée' : `Impossible de récupérer l'arbre de talents (${e.message})`
});
}
});
async function resolveTreeIds(region, specId) {
const index = await loadTalentTreeIndex(region);
const entry = (index.spec_talent_trees || []).find((e) => {
const m = /\/playable-specialization\/(\d+)/.exec(e.key?.href || '');
return m && Number(m[1]) === specId;
});
const treeId = treeIdFromHref(entry?.key?.href);
if (!treeId) return [];
return [[treeId, 'class'], [treeId, 'spec']];
}
async function loadTalentTreeIndex(region) {
const cached = db.prepare('SELECT data FROM talent_cache WHERE key = ?').get('talent-tree-index');
if (cached) return JSON.parse(cached.data);
const index = await apiStatic(region, '/data/wow/talent-tree/index');
const out = { spec_talent_trees: index.spec_talent_trees || [], class_talent_trees: index.class_talent_trees || [] };
db.prepare('INSERT INTO talent_cache (key, data) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET data = excluded.data, resolved_at = datetime(\'now\')').run('talent-tree-index', JSON.stringify(out));
return out;
}
function treeIdFromHref(href) {
const m = /\/talent-tree\/(\d+)/.exec(href || '');
return m ? Number(m[1]) : null;
}
async function loadTalentTree(region, treeId, specId, kind) {
const cacheKey = `tree-${kind}-${treeId}-${specId || ''}`;
const cached = db.prepare('SELECT data FROM talent_cache WHERE key = ?').get(cacheKey);
if (cached) return JSON.parse(cached.data);
const path = `/data/wow/talent-tree/${treeId}/playable-specialization/${specId}`;
const tt = await apiStatic(region, path);
const raw = (kind === 'spec' ? tt.spec_talent_nodes : tt.class_talent_nodes) || tt.talent_nodes || tt.nodes || [];
const nodes = raw.map((n) => {
const ranks = n.ranks || [];
const first = ranks[0] || {};
const tooltip = first.tooltip || {};
const talent = tooltip.talent || {};
const st = tooltip.spell_tooltip || {};
const spell = st.spell || {};
return {
id: n.id,
node_type: n.node_type?.type || n.node_type || null,
rank: ranks.length || 1,
max_rank: ranks.length || 1,
x: n.display_col ?? 0,
y: n.display_row ?? 0,
talent_id: talent.id ?? null,
talent_name: talent.name || spell.name || null,
spell_id: spell.id ?? null,
spell_name: spell.name || null,
icon: null,
tooltip: st.description || null,
cast_time: st.cast_time || null,
connected: n.unlocks || []
};
});
const icons = await resolveSpellIcons(region, [...new Set(nodes.map((n) => n.spell_id).filter(Boolean))]);
for (const n of nodes) {
if (n.spell_id && icons[n.spell_id]) n.icon = icons[n.spell_id];
}
const out = {
id: treeId,
name: tt.name || null,
width: Math.max(0, ...nodes.map((n) => n.x)),
height: Math.max(0, ...nodes.map((n) => n.y)),
nodes
};
db.prepare('INSERT INTO talent_cache (key, data) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET data = excluded.data, resolved_at = datetime(\'now\')').run(cacheKey, JSON.stringify(out));
return out;
}
async function resolveSpellIcons(region, spellIds) {
const out = {};
const missing = [];
for (const sid of spellIds) {
const hit = db.prepare('SELECT data FROM talent_cache WHERE key = ?').get(`icon-${sid}`);
if (hit) {
try { out[sid] = JSON.parse(hit.data); } catch {}
} else {
missing.push(sid);
}
}
for (let i = 0; i < missing.length; i += 8) {
const batch = missing.slice(i, i + 8);
const results = await Promise.allSettled(batch.map(async (sid) => {
const m = await apiStatic(region, `/data/wow/media/spell/${sid}`);
const asset = (m.assets || []).find((a) => a.key === 'icon');
const url = asset?.value || null;
if (url) {
db.prepare('INSERT INTO talent_cache (key, data) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET data = excluded.data, resolved_at = datetime(\'now\')').run(`icon-${sid}`, JSON.stringify(url));
}
return [sid, url];
}));
for (const r of results) {
if (r.status === 'fulfilled' && r.value && r.value[1]) out[r.value[0]] = r.value[1];
}
}
return out;
}
function safeParse(str) {
try {
return JSON.parse(str || '{}');
} catch {
return {};
}
}
function pvpHistorySummary(characterId) {
const rows = db.prepare('SELECT bracket, rating, at FROM pvp_history WHERE character_id = ? ORDER BY id ASC').all(characterId);
const byBracket = {};
const best = {};
const records = {};
for (const r of rows) {
if (!byBracket[r.bracket]) byBracket[r.bracket] = [];
byBracket[r.bracket].push(r);
}
for (const [br, pts] of Object.entries(byBracket)) {
let max = 0;
for (const p of pts) if (p.rating > max) max = p.rating;
best[br] = max;
const last = pts[pts.length - 1];
const prev = pts[pts.length - 2];
if (max > 0 && last.rating === max && prev && prev.rating < max) records[br] = true;
}
return { pvp_best: best, pvp_records: records };
}
function publicCharacter(c, data, full = false) {
const raider = data.raiderio || {};
const blizz = data.blizzard || {};
const pvp = blizz.pvp || {};
const gear = full ? raider.gear || blizz.equipment || [] : null;
const model3d = full ? (data.model3d || null) : null;
const { pvp_best, pvp_records } = pvpHistorySummary(c.id);
return {
id: c.id,
region: c.region,
realm: c.realm,
name: c.name,
note: c.note,
class: blizz.class || raider.class || c.class || null,
spec: blizz.spec || raider.spec || c.spec || null,
level: blizz.level ?? raider.level ?? c.level ?? null,
faction: blizz.faction || raider.faction || c.faction || null,
race: blizz.race || raider.race || c.race || null,
gender: blizz.gender ?? raider.gender ?? c.gender ?? 0,
ilvl: raider.ilvl || 0,
achievement_points: blizz.achievement_points ?? raider.achievement_points ?? 0,
mythic_plus_scores: raider.mythic_plus_scores || null,
mythic_plus_best_runs: raider.mythic_plus_best_runs || [],
raid_progression: raider.raid_progression || null,
pvp,
pvp_best,
pvp_records,
pvp_manual: safeParse(c.pvp_manual || '{}'),
thumbnail_url: raider.thumbnail_url || null,
media: blizz.media || null,
pvp_sets: c.pvp_sets ?? '',
pve_sets: c.pve_sets ?? '',
check_pvp_url: c.check_pvp_url || '',
glad_title: !!c.glad_title,
legend_title: !!c.legend_title,
last_updated: c.last_updated,
gear,
model3d,
specializations: full ? (data.blizzard?.specializations || null) : null,
sources: {
raiderio: !!data.raiderio,
blizzard: !!data.blizzard
}
};
}
+39
View File
@@ -0,0 +1,39 @@
import { getSetting } from './db.js';
import { refreshAll } from './services/fetcher.js';
import { recomputeGoals } from './services/goals.js';
let timer = null;
function intervalHours() {
return Math.max(1, Number(getSetting('fetch_interval_hours')) || 6);
}
export function scheduleFetch() {
if (timer) clearInterval(timer);
const ms = intervalHours() * 60 * 60 * 1000;
timer = setInterval(async () => {
console.log('[scheduler] refresh all characters');
try {
await refreshAll();
} catch (e) {
console.error('[scheduler] error', e.message);
}
}, ms);
}
export async function initialRefresh() {
const last = getSetting('last_fetch_at');
const chars = 0;
try {
if (!last) {
console.log('[scheduler] first run, fetching all characters');
await refreshAll();
} else {
recomputeGoals();
console.log('[scheduler] data exists, computing goals');
}
} catch (e) {
console.error('[scheduler] initial refresh error', e.message);
}
return chars;
}
+72
View File
@@ -0,0 +1,72 @@
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from './config.js';
import { getSetting } from './db.js';
import { adminRouter } from './routes/admin.js';
import { publicRouter } from './routes/public.js';
import { scheduleFetch, initialRefresh } from './scheduler.js';
import { refreshAll } from './services/fetcher.js';
import { proxyModelViewer } from './services/modelviewerProxy.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const publicDir = path.join(__dirname, '..', 'public');
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '1mb' }));
app.use((req, res, next) => {
const header = req.headers.cookie;
const cookies = {};
if (header) {
for (const part of header.split(';')) {
const idx = part.indexOf('=');
if (idx > 0) cookies[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
}
}
req.cookies = cookies;
next();
});
app.use('/api', publicRouter);
const adminPath = () => getSetting('admin_path') || config.adminPath;
app.use('/api/admin', adminRouter);
app.get('/mv/*', proxyModelViewer);
app.get(adminPath(), (req, res) => {
res.sendFile(path.join(publicDir, 'admin.html'));
});
app.get('/', (req, res) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
app.get('/manifest.webmanifest', (req, res) => {
res.type('application/manifest+json');
res.sendFile(path.join(publicDir, 'manifest.webmanifest'));
});
app.get('/sw.js', (req, res) => {
res.set('Cache-Control', 'no-cache');
res.type('application/javascript');
res.sendFile(path.join(publicDir, 'sw.js'));
});
app.use(express.static(publicDir, { extensions: ['html'] }));
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
app.listen(config.port, () => {
console.log(`Wycz listening on port ${config.port}`);
console.log(`Admin: /admin (configurable via settings)`);
scheduleFetch();
initialRefresh().catch((e) => console.error(e));
});
// initial refresh already runs on first boot; if no characters exist, nothing to do.
process.on('SIGTERM', () => process.exit(0));
process.on('SIGINT', () => process.exit(0));
+277
View File
@@ -0,0 +1,277 @@
import { getSetting } from '../db.js';
import { slugifyRealm } from './raiderio.js';
let cachedToken = { token: null, expiresAt: 0 };
export function isConfigured() {
return !!(getSetting('blizzard_client_id') && getSetting('blizzard_client_secret'));
}
async function getToken() {
if (cachedToken.token && Date.now() < cachedToken.expiresAt) return cachedToken.token;
const res = await fetch('https://oauth.battle.net/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + Buffer.from(`${getSetting('blizzard_client_id')}:${getSetting('blizzard_client_secret')}`).toString('base64')
},
body: 'grant_type=client_credentials'
});
if (!res.ok) {
const err = new Error(`Blizzard OAuth HTTP ${res.status}`);
err.code = 'AUTH_ERROR';
throw err;
}
const data = await res.json();
cachedToken = { token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 - 60000 };
return cachedToken.token;
}
async function api(region, path) {
const token = await getToken();
const url = `https://${region}.api.blizzard.com${path}`;
const params = new URLSearchParams({
namespace: `profile-${region}`,
locale: 'fr_FR'
});
const res = await fetch(`${url}?${params.toString()}`, { headers: { 'User-Agent': 'wycz-app/1.0', Authorization: `Bearer ${token}` } });
if (res.status === 404) {
const err = new Error(`Blizzard 404 pour ${path}`);
err.code = 'NOT_FOUND';
throw err;
}
if (!res.ok) {
const err = new Error(`Blizzard HTTP ${res.status} pour ${path}`);
err.code = 'HTTP_ERROR';
throw err;
}
return res.json();
}
export async function apiStatic(region, path) {
if (!isConfigured()) {
const err = new Error('API Blizzard non configurée');
err.code = 'NOT_CONFIGURED';
throw err;
}
const token = await getToken();
const url = `https://${region}.api.blizzard.com${path}`;
const params = new URLSearchParams({
namespace: `static-${region}`,
locale: 'fr_FR'
});
const res = await fetch(`${url}?${params.toString()}`, { headers: { 'User-Agent': 'wycz-app/1.0', Authorization: `Bearer ${token}` } });
if (res.status === 404) {
const err = new Error(`Blizzard 404 pour ${path}`);
err.code = 'NOT_FOUND';
throw err;
}
if (!res.ok) {
const err = new Error(`Blizzard HTTP ${res.status} pour ${path}`);
err.code = 'HTTP_ERROR';
throw err;
}
return res.json();
}
export const SPEC_IDS = {
'Warrior': { Arms: 71, Fury: 72, Protection: 73 },
'Paladin': { Holy: 65, Protection: 66, Retribution: 70 },
'Hunter': { 'Beast Mastery': 253, Marksmanship: 254, Survival: 255 },
'Rogue': { Assassination: 259, Outlaw: 260, Subtlety: 261 },
'Priest': { Discipline: 256, Holy: 257, Shadow: 258 },
'Death Knight': { Blood: 250, Frost: 251, Unholy: 252 },
'Shaman': { Elemental: 262, Enhancement: 263, Restoration: 264 },
'Mage': { Arcane: 62, Fire: 63, Frost: 64 },
'Warlock': { Affliction: 265, Demonology: 266, Destruction: 267 },
'Monk': { Brewmaster: 268, Mistweaver: 270, Windwalker: 269 },
'Druid': { Balance: 102, Feral: 103, Guardian: 104, Restoration: 105 },
'Demon Hunter': { Havoc: 577, Vengeance: 581 },
'Evoker': { Devastation: 1467, Preservation: 1468, Augmentation: 1473 }
};
const CLASS_IDS = {
1: 'Warrior', 2: 'Paladin', 3: 'Hunter', 4: 'Rogue', 5: 'Priest',
6: 'Death Knight', 7: 'Shaman', 8: 'Mage', 9: 'Warlock', 10: 'Monk',
11: 'Druid', 12: 'Demon Hunter', 13: 'Evoker'
};
const invTypeToSlot = {
INVTYPE_HEAD: 1,
INVTYPE_NECK: 2,
INVTYPE_SHOULDER: 3,
INVTYPE_SHIRT: 4,
INVTYPE_CHEST: 5,
INVTYPE_WAIST: 6,
INVTYPE_LEGS: 7,
INVTYPE_FEET: 8,
INVTYPE_WRIST: 9,
INVTYPE_HANDS: 10,
INVTYPE_FINGER: 11,
INVTYPE_TRINKET: 12,
INVTYPE_WEAPON: 13,
INVTYPE_SHIELD: 14,
INVTYPE_RANGED: 15,
INVTYPE_CLOAK: 16,
INVTYPE_2HWEAPON: 17,
INVTYPE_BAG: 18,
INVTYPE_TABARD: 19,
INVTYPE_ROBE: 20,
INVTYPE_WEAPONMAINHAND: 21,
INVTYPE_WEAPONOFFHAND: 22,
INVTYPE_HOLDABLE: 23
};
function parseEquipment(equipment) {
const items = [];
for (const it of equipment.equipped_items || []) {
const slot = invTypeToSlot[it.inventory_type?.type] ?? null;
const transmogId =
it.transmog_item?.id ??
it.transmog?.item?.id ??
it.transmog?.id ??
null;
items.push({
slot,
slot_name: it.slot?.type || null,
item_id: it.item?.id ?? null,
name: it.name,
item_level: it.level?.value ?? null,
quality: it.quality?.type ? qualityRank(it.quality.type) : null,
icon: it.display?.display_string || null,
transmog_id: transmogId
});
}
return items;
}
function qualityRank(type) {
const ranks = { POOR: 0, COMMON: 1, UNCOMMON: 2, RARE: 3, EPIC: 4, LEGENDARY: 5, ARTIFACT: 6 };
return ranks[type] ?? null;
}
function parseSpecializations(data) {
const out = { active_spec: null, active_spec_id: null, specs: [] };
if (data.active_specialization?.name) out.active_spec = data.active_specialization.name;
out.active_spec_id = data.active_specialization?.id ?? null;
for (const s of data.specializations || []) {
const spec = s.specialization?.name || null;
if (!spec) continue;
const entry = { spec, spec_id: s.specialization?.id ?? null, talents: null, loadout_text: null, class_tree_id: null, spec_tree_id: null };
const lo = (s.loadouts || []).find((l) => l.is_active) || (s.loadouts || [])[0] || {};
if (lo.selected_class_talents || lo.selected_spec_talents) {
entry.talents = [
...(lo.selected_class_talents || []).map((t) => ({ id: t.id ?? t.talent?.id ?? null, rank: t.rank ?? 1, tree: 'class' })),
...(lo.selected_spec_talents || []).map((t) => ({ id: t.id ?? t.talent?.id ?? null, rank: t.rank ?? 1, tree: 'spec' }))
].filter((t) => t.id != null);
entry.loadout_text = lo.talent_loadout_code || null;
}
entry.class_tree_id = extractTreeId(lo.selected_class_talent_tree?.key?.href);
entry.spec_tree_id = extractTreeId(lo.selected_spec_talent_tree?.key?.href);
out.specs.push(entry);
}
return out;
}
function extractTreeId(href) {
const m = /\/talent-tree\/(\d+)/.exec(href || '');
return m ? Number(m[1]) : null;
}
export function tierForRating(rating) {
if (rating >= 2400) return 'Gladiateur';
if (rating >= 2100) return 'Élite';
if (rating >= 1800) return 'Duelliste';
if (rating >= 1600) return 'Rival';
if (rating >= 1400) return 'Combattant';
return 'Challenger';
}
function parsePvp(pvp) {
const out = {};
for (const b of pvp.pvp_bracket_statistics || []) {
const type = b.bracket?.type;
if (!type) continue;
const season = b.season_match_statistics || {};
const weekly = b.weekly_match_statistics || {};
const rating = b.rating || 0;
out[type] = {
rating,
tier: rating > 0 ? tierForRating(rating) : null,
won: season.won || 0,
lost: season.lost || 0,
weekly_won: weekly.won || 0,
weekly_lost: weekly.lost || 0
};
}
return out;
}
export async function fetchCharacterProfile(char) {
if (!isConfigured()) {
const err = new Error('API Blizzard non configurée');
err.code = 'NOT_CONFIGURED';
throw err;
}
const region = char.region || 'eu';
const realm = char.realm_slug || slugifyRealm(char.realm);
const name = encodeURIComponent(String(char.name || '').toLowerCase());
const base = `/profile/wow/character/${realm}/${name}`;
const [profile, pvp, titles, achievements, equipment, appearance, media, specializations] = await Promise.allSettled([
api(region, base),
api(region, `${base}/pvp-summary`),
api(region, `${base}/titles`),
api(region, `${base}/achievements`),
api(region, `${base}/equipment`),
api(region, `${base}/appearance`),
api(region, `${base}/media`),
api(region, `${base}/specializations`)
]);
const profileData = profile.status === 'fulfilled' ? profile.value : null;
if (!profileData) throw new Error('Profil Blizzard introuvable');
const result = {
name: profileData.name,
level: profileData.level,
faction: profileData.faction?.name || null,
race: profileData.race?.name || null,
race_id: profileData.race?.id ?? null,
class: CLASS_IDS[profileData.character_class?.id] || profileData.character_class?.name || null,
gender: profileData.gender?.type === 'FEMALE' ? 1 : 0,
achievement_points: profileData.achievement_points,
title: profileData.title?.name || null,
media: media.status === 'fulfilled' ? {
avatar: media.value.avatar_url || null,
inset: media.value.inset_url || null,
main: media.value.main_raw_url || media.value.render_url || null
} : {}
};
if (pvp.status === 'fulfilled') result.pvp = parsePvp(pvp.value);
if (titles.status === 'fulfilled') {
result.titles = (titles.value.titles || []).map((t) => t.name).filter(Boolean);
result.active_title = titles.value.active_title?.name || null;
}
if (achievements.status === 'fulfilled') {
result.achievements = achievements.value;
result.achievement_count = achievements.value?.total_quantity ?? null;
}
if (equipment.status === 'fulfilled') result.equipment = parseEquipment(equipment.value);
if (specializations.status === 'fulfilled') result.specializations = parseSpecializations(specializations.value);
if (appearance.status === 'fulfilled') {
const a = appearance.value;
result.appearance = {
face: a.customization?.face ?? null,
skin_color: a.customization?.skin_color ?? null,
hair_variation: a.customization?.hair_variation ?? null,
hair_color: a.customization?.hair_color ?? null,
facial_hair: a.customization?.facial_hair ?? null,
horn_style: a.customization?.horn_style ?? null,
...(a.customization_options ? { customization_options: a.customization_options } : {})
};
}
return result;
}
+92
View File
@@ -0,0 +1,92 @@
import { db, logFetch, getSetting, setSetting } from '../db.js';
import { fetchCharacterProfile as fetchRaiderIo } from './raiderio.js';
import { fetchCharacterProfile as fetchBlizzard, isConfigured as blizzardConfigured } from './blizzard.js';
import { buildModel3d } from './wago.js';
import { recomputeGoals } from './goals.js';
export async function refreshCharacter(charId) {
const char = db.prepare('SELECT * FROM characters WHERE id = ?').get(charId);
if (!char) return null;
const merged = {
raiderio: null,
blizzard: null,
model3d: { items: [] }
};
try {
merged.raiderio = await fetchRaiderIo(char);
logFetch(charId, 'raiderio', 'ok', {});
} catch (e) {
logFetch(charId, 'raiderio', 'error', { error: e.message, code: e.code });
}
if (blizzardConfigured()) {
try {
merged.blizzard = await fetchBlizzard(char);
logFetch(charId, 'blizzard', 'ok', {});
} catch (e) {
logFetch(charId, 'blizzard', 'error', { error: e.message, code: e.code });
}
}
const equipment = merged.blizzard?.equipment || merged.raiderio?.gear || [];
try {
merged.model3d = await buildModel3d(equipment);
logFetch(charId, 'model3d', 'ok', { items: merged.model3d.items.length });
} catch (e) {
merged.model3d = { items: [] };
logFetch(charId, 'model3d', 'error', { error: e.message });
}
snapshotPvpHistory(charId, merged.blizzard?.pvp);
const summary = merged.blizzard || merged.raiderio || {};
const raider = merged.raiderio || {};
const blizz = merged.blizzard || {};
db.prepare(`UPDATE characters SET
class = ?, spec = ?, level = ?, faction = ?, race = ?, race_id = ?, gender = ?,
data = ?, last_updated = datetime('now')
WHERE id = ?`).run(
summary.class || char.class || null,
summary.spec || char.spec || null,
summary.level ?? char.level ?? null,
summary.faction || char.faction || null,
summary.race || char.race || null,
blizz.race_id ?? char.race_id ?? null,
blizz.gender ?? raider.gender ?? char.gender ?? null,
JSON.stringify(merged),
charId
);
return db.prepare('SELECT * FROM characters WHERE id = ?').get(charId);
}
export async function refreshAll() {
const chars = db.prepare('SELECT id FROM characters WHERE active = 1').all();
for (const c of chars) {
try {
await refreshCharacter(c.id);
} catch (e) {
logFetch(c.id, 'refresh', 'error', { error: e.message });
}
}
setSetting('last_fetch_at', new Date().toISOString());
recomputeGoals();
return chars.length;
}
export async function refreshOne(charId) {
await refreshCharacter(charId);
recomputeGoals();
return db.prepare('SELECT * FROM characters WHERE id = ?').get(charId);
}
function snapshotPvpHistory(charId, pvp) {
if (!pvp || typeof pvp !== 'object') return;
const insert = db.prepare('INSERT INTO pvp_history (character_id, bracket, rating, tier, won, lost) VALUES (?, ?, ?, ?, ?, ?)');
for (const [bracket, b] of Object.entries(pvp)) {
if (b && b.rating > 0) insert.run(charId, bracket, b.rating, b.tier || null, b.won || 0, b.lost || 0);
}
}
+180
View File
@@ -0,0 +1,180 @@
import { db } from '../db.js';
const ALL_CLASSES = [
'Death Knight', 'Demon Hunter', 'Druid', 'Evoker', 'Hunter', 'Mage', 'Monk',
'Paladin', 'Priest', 'Rogue', 'Shaman', 'Warlock', 'Warrior'
];
const BRACKET_KEYS = {
blitz_2800: 'BLITZ',
shuffle_2300: 'SOLO_SHUFFLE',
arena2v2_2300: 'ARENA_2v2',
arena3v3_2300: 'ARENA_3v3'
};
function getCharacters() {
return db.prepare('SELECT * FROM characters WHERE active = 1').all().map((c) => {
try {
return { ...c, data: JSON.parse(c.data || '{}') };
} catch {
return { ...c, data: {} };
}
});
}
function bestPvpRating(chars, bracket) {
let best = { rating: 0, char: null };
for (const c of chars) {
const rating = c.data?.blizzard?.pvp?.[bracket]?.rating;
if (typeof rating === 'number' && rating > best.rating) best = { rating, char: c.name };
}
return best;
}
function bestAchievementPoints(chars) {
let best = { points: 0, char: null };
for (const c of chars) {
const p = c.data?.blizzard?.achievement_points ?? c.data?.raiderio?.achievement_points;
if (typeof p === 'number' && p > best.points) best = { points: p, char: c.name };
}
return best;
}
function bestMythicPlus(chars) {
let best = { score: 0, level: 0, char: null, dungeon: null };
let hasData = false;
for (const c of chars) {
const ri = c.data?.raiderio;
if (!ri) continue;
hasData = true;
const score = ri.mythic_plus_scores?.all || 0;
const runs = ri.mythic_plus_best_runs || [];
const bestRun = runs.reduce((a, b) => (b.level > a.level ? b : a), { level: 0 });
if (score > best.score) {
best = { score, level: bestRun.level, char: c.name, dungeon: bestRun.dungeon || null };
}
}
return { ...best, hasData };
}
function mythicRaidProgress(chars) {
const raids = {};
for (const c of chars) {
for (const [key, raid] of Object.entries(c.data?.raiderio?.raid_progression || {})) {
if (!raids[key]) raids[key] = { name: raid.name, killed: 0, total: 0 };
const m = raid.mythic;
if (m && m.total > 0) {
raids[key].name = raid.name;
raids[key].total = Math.max(raids[key].total, m.total);
raids[key].killed = Math.max(raids[key].killed, m.killed);
}
}
}
const cleared = Object.values(raids).filter((r) => r.total > 0 && r.killed >= r.total).length;
const total = Object.values(raids).filter((r) => r.total > 0).length;
return { raids, cleared, total };
}
function classLevelProgress(chars, target) {
const perClass = {};
let hasData = false;
for (const c of chars) {
const cls = c.data?.blizzard?.class || c.data?.raiderio?.class || c.class;
const level = c.data?.blizzard?.level ?? c.data?.raiderio?.level ?? c.level ?? null;
if (!cls) continue;
if (typeof level === 'number') hasData = true;
if (typeof level === 'number' && (perClass[cls] === undefined || level > perClass[cls])) {
perClass[cls] = level;
}
}
const covered = ALL_CLASSES.filter((cls) => {
const lvl = perClass[cls];
return typeof lvl === 'number' && (target ? lvl >= target : lvl > 0);
});
return { perClass, covered, total: ALL_CLASSES.length, list: ALL_CLASSES, hasData };
}
function gladiatorTitles(chars) {
const found = [];
for (const c of chars) {
for (const t of c.data?.blizzard?.titles || []) {
if (typeof t === 'string' && /gladiator/i.test(t)) {
found.push({ char: c.name, title: t });
}
}
}
return found;
}
export function recomputeGoals() {
const chars = getCharacters();
const goals = db.prepare('SELECT * FROM goals_config WHERE enabled = 1 ORDER BY sort_order').all();
const upsert = db.prepare(`INSERT INTO goals_state (key, label, current, progress, achieved, status, detail, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET label=excluded.label, current=excluded.current, progress=excluded.progress,
achieved=excluded.achieved, status=excluded.status, detail=excluded.detail, updated_at=excluded.updated_at`);
const blizzardConfigured = chars.some((c) => c.data?.blizzard);
function save(key, goal, current, progress, achieved, status, detail) {
upsert.run(key, goal.title, JSON.stringify(current), progress, achieved ? 1 : 0, status, JSON.stringify(detail || {}));
}
for (const goal of goals) {
const key = goal.key;
const target = goal.target;
if (key === 'gladiator_title') {
const glads = gladiatorTitles(chars);
const achieved = glads.length > 0;
save(key, goal, { found: glads, total: glads.length }, achieved ? 1 : 0, achieved, blizzardConfigured ? 'ok' : 'pending_blizzard', { glads });
}
else if (key === 'mythic_plus_max') {
const best = bestMythicPlus(chars);
const hasData = best.hasData;
save(key, goal, best, hasData ? Math.min(1, best.score / (goal.display_max || 10000)) : 0, false, hasData ? 'ok' : 'no_data', {});
}
else if (key === 'all_classes_90') {
const prog = classLevelProgress(chars, target);
const achieved = prog.covered.length >= prog.total;
const status = prog.hasData ? 'mixed' : (blizzardConfigured ? 'no_data' : 'pending_blizzard');
save(key, goal, { covered: prog.covered, total: prog.total, perClass: prog.perClass }, prog.covered / prog.total, achieved, status, {});
}
else if (BRACKET_KEYS[key]) {
const bracket = BRACKET_KEYS[key];
const best = bestPvpRating(chars, bracket);
const hasData = best.rating > 0;
const achieved = hasData && target !== null && best.rating >= target;
const progress = target ? Math.min(1, best.rating / target) : (hasData ? Math.min(1, best.rating / (goal.display_max || 3000)) : 0);
save(key, goal, best, progress, achieved, hasData ? 'ok' : (blizzardConfigured ? 'no_data' : 'pending_blizzard'), {});
}
else if (key === 'achievements') {
const best = bestAchievementPoints(chars);
const hasData = best.points > 0;
save(key, goal, best, hasData ? Math.min(1, best.points / (goal.display_max || 50000)) : 0, false, hasData ? 'ok' : (blizzardConfigured ? 'no_data' : 'pending_blizzard'), {});
}
else if (key === 'mythic_raids') {
const prog = mythicRaidProgress(chars);
const total = prog.total;
const achieved = total > 0 && prog.cleared >= total;
const progress = total > 0 ? prog.cleared / total : 0;
save(key, goal, { cleared: prog.cleared, total, raids: prog.raids }, progress, achieved, total > 0 ? 'ok' : 'no_data', {});
}
else if (key === 'all_sets') {
const counts = chars.map((c) => ({ char: c.name, sets: c.data?.blizzard?.collections?.sets ?? null }));
const known = counts.filter((x) => typeof x.sets === 'number');
const totalSets = known.reduce((a, b) => a + b.sets, 0);
const hasData = known.length > 0;
const displayMax = goal.display_max || 1000;
save(key, goal, { characters: counts, collected: totalSets }, hasData ? Math.min(1, totalSets / displayMax) : 0, false, hasData ? 'ok' : 'pending_blizzard', {});
}
}
db.prepare(`DELETE FROM goals_state WHERE key NOT IN (SELECT key FROM goals_config WHERE enabled = 1)`).run();
}
+22
View File
@@ -0,0 +1,22 @@
const CONTENT_BASE = 'https://wow.zamimg.com/modelviewer/live/';
const UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36';
export async function proxyModelViewer(req, res) {
const rel = req.params[0];
if (!rel || rel.includes('..')) return res.status(400).json({ error: 'Bad path' });
const upstream = await fetch(CONTENT_BASE + rel, {
headers: { 'User-Agent': UA, Accept: '*/*' }
});
if (!upstream.ok) return res.status(upstream.status).send('Upstream error');
const contentType = upstream.headers.get('content-type') || 'application/octet-stream';
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('Access-Control-Allow-Origin', '*');
const buf = Buffer.from(await upstream.arrayBuffer());
res.send(buf);
}
+122
View File
@@ -0,0 +1,122 @@
const BASE = 'https://raider.io/api/v1/characters/profile';
export function slugifyRealm(realm) {
return realm
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9\s-]+/g, '')
.trim()
.replace(/\s+/g, '-');
}
const raiderSlotToInventoryType = {
head: 1,
neck: 2,
shoulder: 3,
shirt: 4,
chest: 5,
waist: 6,
legs: 7,
feet: 8,
wrist: 9,
hands: 10,
finger1: 11,
finger2: 11,
trinket1: 12,
trinket2: 12,
back: 16,
tabard: 19,
mainhand: 21,
offhand: 22
};
const RACE_IDS = {
'Human': 1, 'Orc': 2, 'Dwarf': 3, 'Night Elf': 4, 'Undead': 5, 'Tauren': 6,
'Gnome': 7, 'Troll': 8, 'Goblin': 9, 'Blood Elf': 10, 'Draenei': 11, 'Worgen': 22,
'Pandaren': 24, 'Nightborne': 27, 'Highmountain Tauren': 28, 'Void Elf': 29,
'Lightforged Draenei': 30, 'Zandalari Troll': 31, 'Kul Tiran': 32,
'Dark Iron Dwarf': 34, 'Vulpera': 35, "Mag'har Orc": 36, 'Mechagnome': 37,
'Dracthyr': 52, 'Earthen': 80
};
export async function fetchCharacterProfile(char) {
const params = new URLSearchParams({
region: char.region,
realm: slugifyRealm(char.realm),
name: char.name,
fields: 'gear,mythic_plus_scores,mythic_plus_best_runs,raid_progression'
});
const url = `${BASE}?${params.toString()}`;
const res = await fetch(url, { headers: { 'User-Agent': 'wycz-app/1.0' } });
if (res.status === 404) {
const err = new Error('Personnage introuvable sur Raider.io (pas de donnée M+/raid)');
err.code = 'NOT_FOUND';
throw err;
}
if (!res.ok) {
const err = new Error(`Raider.io HTTP ${res.status}`);
err.code = 'HTTP_ERROR';
throw err;
}
const data = await res.json();
return transform(data);
}
function transform(d) {
const gear = d.gear || {};
const mps = d.mythic_plus_scores || {};
const bestRuns = (d.mythic_plus_best_runs || []).map((r) => ({
dungeon: r.dungeon,
level: r.mythic_level,
score: r.score,
cleared: r.clear_time_ms
}));
const raidProgression = {};
for (const [key, raid] of Object.entries(d.raid_progression || {})) {
raidProgression[key] = {
name: raid.name,
mythic: raid.mythic ? { killed: raid.mythic.bosses_killed, total: raid.mythic.total_bosses } : null,
heroic: raid.heroic ? { killed: raid.heroic.bosses_killed, total: raid.heroic.total_bosses } : null
};
}
const items = [];
for (const [slotName, it] of Object.entries(gear.items || {})) {
if (!it || !it.item_id) continue;
items.push({
slot: raiderSlotToInventoryType[slotName] ?? null,
slot_name: slotName,
item_id: it.item_id,
item_level: it.item_level,
name: it.name,
quality: it.item_quality,
icon: it.icon
});
}
return {
name: d.name,
realm: d.realm,
region: d.region,
class: d.class,
spec: d.active_spec_name,
role: d.active_spec_role,
race: d.race,
race_id: RACE_IDS[d.race] || null,
faction: d.faction,
gender: d.gender === 'female' ? 1 : 0,
level: d.level || null,
achievement_points: d.achievement_points,
thumbnail_url: d.thumbnail_url,
profile_url: d.profile_url,
ilvl: gear.item_level_equipped || 0,
mythic_plus_scores: {
all: mps.all || 0,
dps: mps.dps || 0,
healer: mps.healer || 0,
tank: mps.tank || 0
},
mythic_plus_best_runs: bestRuns,
raid_progression: raidProgression,
gear: items
};
}
+88
View File
@@ -0,0 +1,88 @@
import { db, getSetting } from '../db.js';
import { config } from '../config.js';
const ARMOR_SLOTS = new Set([1, 3, 4, 5, 6, 7, 8, 9, 10, 16, 19, 20]);
export function isVisibleSlot(slot) {
if (!slot) return false;
if (slot === 2 || slot === 11 || slot === 12) return false;
return true;
}
async function wagoFind(table, field, value) {
const build = getSetting('wow_tools_build') || config.wowToolsBuild;
const params = new URLSearchParams();
params.append('build', build);
params.append(`filter[${field}]`, `exact:${value}`);
const url = `https://wago.tools/api/db2-find/${table}?${params.toString()}`;
const res = await fetch(url, { headers: { 'User-Agent': 'wycz-app/1.0' } });
if (!res.ok) throw new Error(`wago.tools HTTP ${res.status}`);
const data = await res.json();
return data?.data || [];
}
export async function resolveItem(itemId) {
const cached = db.prepare('SELECT display_id, display_type FROM item_cache WHERE item_id = ?').get(itemId);
if (cached && cached.display_id) return { item_id: itemId, display_id: cached.display_id, display_type: cached.display_type };
let displayId = null;
let displayType = null;
try {
const ima = await wagoFind('ItemModifiedAppearance', 'ItemID', itemId);
if (ima.length) {
const ia = await wagoFind('ItemAppearance', 'ID', ima[0].ItemAppearanceID);
if (ia.length) {
displayId = ia[0].ItemDisplayInfoID ?? null;
displayType = ia[0].DisplayType ?? null;
}
}
} catch {
displayId = null;
}
if (displayId) {
db.prepare('INSERT INTO item_cache (item_id, display_id, display_type) VALUES (?, ?, ?)')
.run(itemId, displayId, displayType);
}
return { item_id: itemId, display_id: displayId, display_type: displayType };
}
export async function resolveItems(itemIds) {
const results = [];
for (const id of itemIds) {
if (!id) continue;
results.push(await resolveItem(id));
}
return results;
}
export async function buildModel3d(equipmentItems) {
const model = { items: [] };
const toResolve = [];
const displayed = [];
for (const it of equipmentItems || []) {
if (!isVisibleSlot(it.slot) || !it.item_id) continue;
const effectiveId = it.transmog_id || it.item_id;
displayed.push({ slot: it.slot, effectiveId });
toResolve.push(effectiveId);
}
const uniqueIds = [...new Set(toResolve)];
const resolved = await resolveItems(uniqueIds);
const map = new Map(resolved.map((r) => [r.item_id, r]));
for (const d of displayed) {
const r = map.get(d.effectiveId);
if (r && r.display_id) {
model.items.push([d.slot, r.display_id]);
}
}
return model;
}
export function armorSlotForDisplayType(displayType) {
if (displayType === null) return null;
if (ARMOR_SLOTS.has(displayType)) return displayType;
return null;
}
Executable
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
SERVER="debian@51.195.5.147"
REMOTE_DIR="/home/debian/wycz"
LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "==> Synchronisation des fichiers vers le serveur"
rsync -az --delete \
--exclude node_modules --exclude data --exclude .env \
"$LOCAL_DIR/" "$SERVER:$REMOTE_DIR/"
echo "==> (Re)construction du conteneur Docker"
ssh "$SERVER" "cd $REMOTE_DIR && docker compose up -d --build"
echo "==> Vérification"
ssh "$SERVER" "sleep 2 && docker ps --filter name=wycz --format '{{.Names}} {{.Status}} {{.Ports}}' && curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://localhost:3004/"
echo "==> Logs récents"
ssh "$SERVER" "docker logs --tail 20 wycz"
+17
View File
@@ -0,0 +1,17 @@
services:
wycz:
build: ./backend
restart: unless-stopped
container_name: wycz
environment:
PORT: 3004
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-now}
SESSION_SECRET: ${SESSION_SECRET:-change-me-session-secret}
DATA_DIR: /data
volumes:
- wycz_data:/data
ports:
- "3004:3004"
volumes:
wycz_data: