Initial commit: Wycz WoW Portfolio
@@ -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;
|
||||
})
|
||||
);
|
||||
});
|
||||