Initial commit: Wycz WoW Portfolio
@@ -0,0 +1,2 @@
|
||||
ADMIN_PASSWORD=268bc591ce7e
|
||||
SESSION_SECRET=72df71ef40d021cc1da8ddaf7ca7d5408552b68f058cc62fc9d49588478f3e8a
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
/data/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
data
|
||||
.git
|
||||
*.log
|
||||
@@ -0,0 +1,4 @@
|
||||
PORT=3004
|
||||
ADMIN_PASSWORD=change-me-now
|
||||
SESSION_SECRET=change-me-session-secret
|
||||
DATA_DIR=./data
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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">×</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>
|
||||
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 738 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
@@ -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">×</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">×</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>
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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">×</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();
|
||||
})();
|
||||
@@ -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); }
|
||||
};
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 & 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();
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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:
|
||||