Initial commit: Wycz WoW Portfolio

This commit is contained in:
2026-08-23 09:06:58 +00:00
commit cc5be652b8
93 changed files with 6502 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Wycz — Administration</title>
<link rel="icon" type="image/png" href="/images/logo5.png">
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="admin-body">
<header class="site-header">
<div class="header-inner">
<div class="brand">
<img class="brand-logo" src="/images/logo5.png" alt="Logo Wycz">
<div class="brand-text">
<h1>ADMIN</h1>
<p>Gestion des personnages et objectifs</p>
</div>
</div>
<div class="header-right">
<div class="socials" id="socials"></div>
<div class="header-status">
<div id="header-live"></div>
<a href="/" class="btn btn-ghost">Voir le site</a>
<button class="btn btn-ghost" id="logout-btn" hidden>Déconnexion</button>
</div>
</div>
</div>
</header>
<main class="container">
<section id="login-view" hidden>
<div class="login-box">
<h2>Connexion administrateur</h2>
<form id="login-form">
<input type="password" id="login-password" placeholder="Mot de passe" autocomplete="current-password" required>
<button type="submit" class="btn btn-primary">Se connecter</button>
<div class="form-error" id="login-error" hidden></div>
</form>
</div>
</section>
<section id="admin-view" hidden>
<div class="tabs">
<button class="tab-btn active" data-tab="chars">Personnages</button>
<button class="tab-btn" data-tab="goals">Objectifs</button>
<button class="tab-btn" data-tab="sessions">Suivi</button>
<button class="tab-btn" data-tab="settings">Paramètres</button>
<button class="tab-btn" data-tab="logs">Journal</button>
</div>
<div class="tab-panel active" id="tab-chars">
<div class="panel-toolbar">
<h3>Personnages suivis</h3>
<div class="toolbar-actions">
<span class="hint" id="fetch-status"></span>
<button class="btn btn-primary" id="refresh-all-btn">Tout actualiser</button>
<button class="btn btn-primary" id="add-char-btn">+ Ajouter un personnage</button>
</div>
</div>
<div class="form-row hidden" id="add-char-form">
<input type="text" id="nc-name" placeholder="Nom (ex: Wycz)" required>
<input type="text" id="nc-realm" placeholder="Royaume (ex: Cho'gall)" required>
<input type="text" id="nc-realm-slug" placeholder="Slug Blizzard (optionnel)">
<select id="nc-region">
<option value="eu">EU</option>
<option value="us">US</option>
</select>
<select id="nc-class" aria-label="Classe">
<option value="">Classe (optionnel)</option>
<option>Guerrier</option><option>Paladin</option><option>Chasseur</option><option>Voleur</option>
<option>Prêtre</option><option>Chevalier de la mort</option><option>Chaman</option><option>Mage</option>
<option>Démoniste</option><option>Moine</option><option>Druide</option><option>Chasseur de démons</option>
<option>Évocateur</option>
</select>
<input type="text" id="nc-note" placeholder="Note (ex: Chaman main)">
<button class="btn btn-primary" id="nc-save">Ajouter</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Perso</th><th>Classe</th><th>Niveau</th><th>Royaume</th><th>Check-pvp</th><th>Sets PvP</th><th>Sets PvE</th><th>Ratings PvP</th><th>Titres</th><th>Actif</th><th>Maj</th><th></th>
</tr>
</thead>
<tbody id="chars-tbody"></tbody>
</table>
</div>
<div class="modal-backdrop" id="pvp-modal-backdrop" hidden>
<div class="modal pvp-modal">
<button class="modal-close" id="pvp-modal-close" aria-label="Fermer">&times;</button>
<div class="modal-head">
<div>
<h3 id="pvp-modal-title">Ratings PvP</h3>
<p id="pvp-modal-sub">Saison, maximum (xp), victoires et défaites par bracket</p>
</div>
</div>
<div class="pvp-form">
<div class="pvp-section">
<h4>Général</h4>
<table class="admin-table">
<thead><tr><th>Bracket</th><th>Cette saison</th><th>Max (xp)</th><th>Victoires</th><th>Défaites</th></tr></thead>
<tbody id="pvp-general-body"></tbody>
</table>
</div>
<div id="pvp-specs"></div>
<div class="pvp-actions">
<button class="btn btn-primary" id="pvp-save">Enregistrer</button>
<span class="hint" id="pvp-save-msg"></span>
</div>
</div>
</div>
</div>
<div class="tab-panel" id="tab-goals">
<div class="panel-toolbar"><h3>Objectifs et cibles</h3></div>
<div class="goals-grid admin-goals" id="admin-goals"></div>
</div>
<div class="tab-panel" id="tab-sessions">
<div class="panel-toolbar">
<h3>Suivi de session</h3>
<button class="btn btn-ghost" id="session-undo" hidden title="Annuler le dernier match">Annuler le dernier match</button>
</div>
<p class="hint" style="margin-bottom:12px">Clique à chaque match joué : Victoire / Défaite par bracket. Les totaux du jour et des 7 derniers jours sont calculés automatiquement.</p>
<div class="session-grid" id="session-grid"></div>
</div>
<div class="tab-panel" id="tab-settings">
<h3>API Blizzard</h3>
<div class="settings-card">
<p class="hint">Créez un client sur <a href="https://develop.battle.net/" target="_blank" rel="noopener">developers.battle.net</a> puis renseignez le Client ID et le Secret ci-dessous pour activer les ratings PvP, les hauts faits, les titres et l'apparence complète des personnages.</p>
<div class="form-grid">
<div><label>Client ID</label><input type="text" id="st-blizz-id"></div>
<div><label>Client Secret</label><input type="password" id="st-blizz-secret" autocomplete="off"></div>
</div>
<button class="btn btn-primary" id="st-save-blizz">Enregistrer les identifiants</button>
</div>
<h3>Vue d'ensemble (saisie manuelle)</h3>
<div class="settings-card">
<p class="hint">Laissez vide pour utiliser les données automatiques (Raider.io / Blizzard), sinon renseignez vos propres valeurs.</p>
<div class="form-grid">
<div class="span-2"><label>Classes niveau 90 <span class="hint">(sélectionnez les classes à 90)</span></label><div class="class-select" id="st-manual-class90-select"></div></div>
<div class="span-2"><label>Toutes les classes à 2300 en soloshuffle <span class="hint">(sélectionnez les classes)</span></label><div class="class-select" id="st-manual-ss2300-select"></div></div>
<div><label>Victoires Gladiateur au-dessus de 2300 en 3v3 (sur 50)</label><input type="number" id="st-manual-glad-wins" min="0" max="50" placeholder="0"></div>
<div class="span-2"><label>Classe en cours pour le farm Gladiateur <span class="hint">(cliquez sur la classe active)</span></label><div class="class-select" id="st-manual-glad-class-select"></div></div>
<div><label>Boss tués sur le dernier raid mythique</label><input type="number" id="st-manual-bosses" min="0" max="99" placeholder="(auto)"></div>
<div><label>Points de hauts faits</label><input type="number" id="st-manual-ach" min="0" placeholder="2258"></div>
<div><label>Total de hauts faits</label><input type="number" id="st-manual-ach-total" min="0" placeholder="5843"></div>
<div><label>Nom du dernier raid mythique</label><input type="text" id="st-raid-name" placeholder="L'Abîme Venimeux"></div>
<div><label>Nombre de boss du raid</label><input type="number" id="st-raid-total" min="1"></div>
<div><label>Lien Twitch</label><input type="text" id="st-twitch-url" placeholder="https://twitch.tv/wycz"></div>
<div><label>Lien YouTube</label><input type="text" id="st-youtube-url" placeholder="https://youtube.com/@..."></div>
<div><label>Lien TikTok</label><input type="text" id="st-tiktok-url" placeholder="https://tiktok.com/@..."></div>
<div><label>Saison du titre Gladiateur <span class="hint">(affichée à côté du titre)</span></label><input type="text" id="st-glad-season" placeholder="S1 Midnight"></div>
<div><label>Saison du titre Légende <span class="hint">(affichée à côté du titre)</span></label><input type="text" id="st-legend-season" placeholder="S1 Midnight"></div>
</div>
</div>
<h3>Mises à jour</h3>
<div class="settings-card">
<div class="form-grid">
<div><label>Fréquence (heures)</label><input type="number" id="st-interval" min="1"></div>
<div><label>Build wago.tools</label><input type="text" id="st-build" placeholder="12.1.0.69111"></div>
</div>
<button class="btn btn-primary" id="st-save">Enregistrer</button>
<button class="btn" id="st-refresh">Lancer une mise à jour complète</button>
<span class="hint" id="st-last-fetch"></span>
</div>
<h3>Mot de passe administrateur</h3>
<div class="settings-card">
<div class="form-grid">
<div><label>Mot de passe actuel</label><input type="password" id="pw-current"></div>
<div><label>Nouveau mot de passe</label><input type="password" id="pw-next"></div>
</div>
<button class="btn btn-primary" id="pw-save">Changer le mot de passe</button>
</div>
<div class="form-error" id="settings-error" hidden></div>
</div>
<div class="tab-panel" id="tab-logs">
<h3>Journal des mises à jour</h3>
<table class="admin-table">
<thead><tr><th>Heure</th><th>Personnage</th><th>Source</th><th>Statut</th><th>Détail</th></tr></thead>
<tbody id="logs-tbody"></tbody>
</table>
</div>
</section>
</main>
<script src="/js/api.js"></script>
<script src="/js/admin.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wycz - wow</title>
<link rel="icon" type="image/png" href="/images/logo5.png">
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0c0e14">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Wycz">
<link rel="apple-touch-icon" href="/images/icons/icon-180.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div id="starfield" aria-hidden="true"></div>
<header class="site-header">
<div class="header-inner">
<div class="brand">
<img class="brand-logo" src="/images/logo5.png" alt="Logo Wycz">
</div>
<div class="header-classes" id="header-classes"></div>
<div class="header-right">
<div class="socials" id="socials"></div>
<div class="header-status" id="header-status"></div>
</div>
</div>
</header>
<main class="container">
<section id="overview-section">
<h2 class="section-title">Objectifs de la Saison 2 Midnight</h2>
<div class="overview-grid" id="overview-grid">
<div class="loading">Chargement…</div>
</div>
</section>
</main>
<div class="modal-backdrop" id="class-modal-backdrop" hidden>
<div class="modal class-modal">
<button class="modal-close" id="class-modal-close" aria-label="Fermer">&times;</button>
<div class="class-modal-head">
<img id="class-modal-logo" class="class-modal-logo" alt="">
</div>
<div class="class-modal-chars" id="class-modal-chars"></div>
</div>
</div>
<div class="modal-backdrop" id="modal-backdrop" hidden>
<div class="modal char-detail-modal">
<button class="modal-close" id="modal-close" aria-label="Fermer">&times;</button>
<div class="modal-head">
<img id="modal-avatar" class="modal-avatar" alt="">
<div>
<h3 id="modal-title"></h3>
<p id="modal-subtitle"></p>
</div>
</div>
<div id="modal-art" class="modal-art">
<div class="loading">Chargement…</div>
</div>
<div id="modal-error" class="modal-error" hidden></div>
<div class="modal-stats" id="modal-stats"></div>
<div class="modal-section">
<h4 class="modal-section-title">Équipement</h4>
<div class="modal-gear" id="modal-gear"><div class="loading">Chargement de l'équipement…</div></div>
</div>
<div class="modal-section">
<h4 class="modal-section-title">Talents</h4>
<div class="modal-talents" id="modal-talents"><div class="loading">Chargement des talents…</div></div>
</div>
</div>
</div>
<script src="https://wow.zamimg.com/js/tooltips.js"></script>
<script src="/js/api.js"></script>
<script src="/js/app.js"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body>
</html>
+676
View File
@@ -0,0 +1,676 @@
(function () {
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const FR_CLASS_TO_EN = {
'Guerrier': 'Warrior', 'Paladin': 'Paladin', 'Chasseur': 'Hunter', 'Voleur': 'Rogue',
'Prêtre': 'Priest', 'Chevalier de la mort': 'Death Knight', 'Chaman': 'Shaman', 'Mage': 'Mage',
'Démoniste': 'Warlock', 'Moine': 'Monk', 'Druide': 'Druid', 'Chasseur de démons': 'Demon Hunter',
'Évocateur': 'Evoker'
};
const PVP_GENERAL = [['ARENA_2v2', '2v2'], ['ARENA_3v3', '3v3'], ['RATED_BG', 'RBG']];
const PVP_PER_SPEC = [['SOLO_SHUFFLE', 'Solo Shuffle'], ['BLITZ', 'Blitz']];
const SPECS_BY_CLASS = {
'Death Knight': [
{ key: 'Blood', fr: 'Sang', icon: 'spell_deathknight_bloodpresence.jpg' },
{ key: 'Frost', fr: 'Givre', icon: 'spell_deathknight_frostpresence.jpg' },
{ key: 'Unholy', fr: 'Impie', icon: 'spell_deathknight_unholypresence.jpg' }
],
'Demon Hunter': [
{ key: 'Havoc', fr: 'Dévastation', icon: 'ability_demonhunter_specdps.jpg' },
{ key: 'Vengeance', fr: 'Vengeance', icon: 'ability_demonhunter_spectank.jpg' },
{ key: 'Devoration', fr: 'Dévoration', icon: 'classicon_demonhunter_void.jpg' }
],
'Druid': [
{ key: 'Balance', fr: 'Équilibre', icon: 'spell_nature_starfall.jpg' },
{ key: 'Feral', fr: 'Farouche', icon: 'ability_druid_catform.jpg' },
{ key: 'Guardian', fr: 'Gardien', icon: 'ability_racial_bearform.jpg' },
{ key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_healingtouch.jpg' }
],
'Evoker': [
{ key: 'Devastation', fr: 'Dévastation', icon: 'classicon_evoker_devastation.jpg' },
{ key: 'Preservation', fr: 'Préservation', icon: 'classicon_evoker_preservation.jpg' },
{ key: 'Augmentation', fr: 'Augmentation', icon: 'classicon_evoker_augmentation.jpg' }
],
'Hunter': [
{ key: 'Beast Mastery', fr: 'Maîtrise des bêtes', icon: 'ability_hunter_bestialdiscipline.jpg' },
{ key: 'Marksmanship', fr: 'Précision', icon: 'ability_hunter_focusedaim.jpg' },
{ key: 'Survival', fr: 'Survie', icon: 'ability_hunter_camouflage.jpg' }
],
'Mage': [
{ key: 'Arcane', fr: 'Arcanes', icon: 'spell_nature_magicimmunity.jpg' },
{ key: 'Fire', fr: 'Feu', icon: 'spell_fire_firebolt02.jpg' },
{ key: 'Frost', fr: 'Givre', icon: 'spell_frost_frostbolt02.jpg' }
],
'Monk': [
{ key: 'Brewmaster', fr: 'Maître brasseur', icon: 'spell_monk_brewmaster_spec.jpg' },
{ key: 'Mistweaver', fr: 'Tisse-brume', icon: 'spell_monk_mistweaver_spec.jpg' },
{ key: 'Windwalker', fr: 'Marche-vent', icon: 'spell_monk_windwalker_spec.jpg' }
],
'Paladin': [
{ key: 'Holy', fr: 'Sacré', icon: 'spell_holy_holybolt.jpg' },
{ key: 'Protection', fr: 'Protection', icon: 'ability_paladin_shieldofthetemplar.jpg' },
{ key: 'Retribution', fr: 'Vindicte', icon: 'spell_holy_auraoflight.jpg' }
],
'Priest': [
{ key: 'Discipline', fr: 'Discipline', icon: 'spell_holy_powerwordshield.jpg' },
{ key: 'Holy', fr: 'Sacré', icon: 'spell_holy_guardianspirit.jpg' },
{ key: 'Shadow', fr: 'Ombre', icon: 'spell_shadow_shadowwordpain.jpg' }
],
'Rogue': [
{ key: 'Assassination', fr: 'Assassinat', icon: 'ability_rogue_deadlybrew.jpg' },
{ key: 'Outlaw', fr: 'Hors-la-loi', icon: 'ability_rogue_waylay.jpg' },
{ key: 'Subtlety', fr: 'Finesse', icon: 'ability_stealth.jpg' }
],
'Shaman': [
{ key: 'Elemental', fr: 'Élémentaire', icon: 'spell_nature_lightning.jpg' },
{ key: 'Enhancement', fr: 'Amélioration', icon: 'spell_shaman_improvedstormstrike.jpg' },
{ key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_healingtouch.jpg' }
],
'Warlock': [
{ key: 'Affliction', fr: 'Affliction', icon: 'spell_shadow_deathcoil.jpg' },
{ key: 'Demonology', fr: 'Démonologie', icon: 'spell_shadow_metamorphosis.jpg' },
{ key: 'Destruction', fr: 'Destruction', icon: 'spell_shadow_rainoffire.jpg' }
],
'Warrior': [
{ key: 'Arms', fr: 'Armes', icon: 'ability_warrior_innerrage.jpg' },
{ key: 'Fury', fr: 'Fureur', icon: 'ability_warrior_savageblow.jpg' },
{ key: 'Protection', fr: 'Protection', icon: 'ability_warrior_defensivestance.jpg' }
]
};
const ADMIN_CLASSES = [
{ name: 'Warrior', fr: 'Guerrier', logo: 'War.png' },
{ name: 'Paladin', fr: 'Paladin', logo: 'Pal.png' },
{ name: 'Hunter', fr: 'Chasseur', logo: 'Hunt.png' },
{ name: 'Rogue', fr: 'Voleur', logo: 'Rogue.png' },
{ name: 'Priest', fr: 'Prêtre', logo: 'Priest.png' },
{ name: 'Death Knight', fr: 'Chevalier de la mort', logo: 'DK.png' },
{ name: 'Shaman', fr: 'Chaman', logo: 'Sham.png' },
{ name: 'Mage', fr: 'Mage', logo: 'Mage.png' },
{ name: 'Warlock', fr: 'Démoniste', logo: 'Lock.png' },
{ name: 'Monk', fr: 'Moine', logo: 'monk.png' },
{ name: 'Druid', fr: 'Druide', logo: 'Druid.png' },
{ name: 'Demon Hunter', fr: 'Chasseur de démons', logo: 'dh.png' },
{ name: 'Evoker', fr: 'Évocateur', logo: 'evoker.png' }
];
let pvpEditId = null;
let state = { logged: false };
async function checkLogin() {
try {
await api.get('/api/admin/me');
state.logged = true;
} catch {
state.logged = false;
}
render();
}
function render() {
$('#login-view').hidden = state.logged;
$('#admin-view').hidden = !state.logged;
$('#logout-btn').hidden = !state.logged;
if (state.logged) {
loadChars();
loadGoals();
loadSettings();
loadSessions();
loadLogs();
}
}
const SESSION_BRACKETS = [['SOLO_SHUFFLE', 'Solo Shuffle'], ['BLITZ', 'Blitz'], ['ARENA_2v2', '2v2'], ['ARENA_3v3', '3v3'], ['RATED_BG', 'RBG']];
async function loadSessions() {
const grid = $('#session-grid');
if (!grid) return;
try {
const data = await api.get('/api/admin/sessions');
const today = {};
const week = {};
(data.today || []).forEach((x) => { today[x.bracket] = x; });
(data.week || []).forEach((x) => { week[x.bracket] = x; });
grid.innerHTML = SESSION_BRACKETS.map(([key, label]) => {
const t = today[key] || { wins: 0, losses: 0 };
const w = week[key] || { wins: 0, losses: 0 };
const total = (t.wins || 0) + (t.losses || 0);
const winrate = total > 0 ? `${Math.round((t.wins / total) * 100)}%` : '—';
return `<div class="session-card">
<div class="session-head">${label}
<span class="session-today">${t.wins}V · ${t.losses}D <em>${winrate}</em></span>
</div>
<div class="session-btns">
<button class="btn session-win" data-bracket="${key}" data-won="1">Victoire</button>
<button class="btn session-loss" data-bracket="${key}" data-won="0">Défaite</button>
</div>
<div class="session-week hint">7 derniers jours : ${w.wins}V · ${w.losses}D</div>
</div>`;
}).join('');
grid.querySelectorAll('.session-win, .session-loss').forEach((b) => {
b.addEventListener('click', async () => {
await api.post('/api/admin/sessions', { bracket: b.dataset.bracket, won: Number(b.dataset.won) });
loadSessions();
});
});
$('#session-undo').hidden = !(data.last && data.last.length);
} catch {
grid.innerHTML = '<p class="hint">Impossible de charger le suivi de session.</p>';
}
}
async function sessionUndo() {
try {
await api.post('/api/admin/sessions/undo');
loadSessions();
} catch {}
}
$('#login-form').addEventListener('submit', async (e) => {
e.preventDefault();
$('#login-error').hidden = true;
try {
await api.post('/api/admin/login', { password: $('#login-password').value });
state.logged = true;
$('#login-password').value = '';
render();
} catch (err) {
$('#login-error').textContent = err.message;
$('#login-error').hidden = false;
}
});
$('#logout-btn').addEventListener('click', async () => {
await api.post('/api/admin/logout');
state.logged = false;
render();
});
// Tabs
$$('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
$$('.tab-btn').forEach((b) => b.classList.remove('active'));
$$('.tab-panel').forEach((p) => p.classList.remove('active'));
btn.classList.add('active');
$(`#tab-${btn.dataset.tab}`).classList.add('active');
});
});
// ---- Characters ----
async function loadChars() {
const chars = await api.get('/api/admin/characters');
window.__adminChars = chars;
$('#chars-tbody').innerHTML = chars.map((c) => `
<tr data-id="${c.id}">
<td class="char-cell-name"><b>${esc(c.name)}</b>${c.note ? `<br><span class="hint">${esc(c.note)}</span>` : ''}</td>
<td>
<select class="mini-select" data-field="class">
<option value="">—</option>
${Object.entries(FR_CLASS_TO_EN).map(([fr, en]) => `<option value="${en}" ${c.class === en ? 'selected' : ''}>${fr}</option>`).join('')}
</select>
</td>
<td><input type="text" class="mini-num" data-field="level" value="${c.level ?? ''}" placeholder="—"></td>
<td>${esc(c.realm)} <span class="hint">(${c.region})</span></td>
<td><input type="text" class="mini-url" data-field="check_pvp_url" value="${esc(c.check_pvp_url ?? '')}" placeholder="https://check-pvp.fr/eu/…"></td>
<td><input type="text" class="mini-num" data-field="pvp_sets" value="${esc(c.pvp_sets ?? '')}" placeholder="0/4"></td>
<td><input type="text" class="mini-num" data-field="pve_sets" value="${esc(c.pve_sets ?? '')}" placeholder="0/4"></td>
<td><button class="btn mini-btn" data-action="pvp">Ratings</button></td>
<td>
<div class="admin-titles">
${(c.titles || []).map((t) => `
<span class="at-entry at-${t.title}">
<img src="/images/titles/${t.title === 'glad' ? 'gladiateur.jpg' : 'legende.webp'}" alt="${t.title === 'glad' ? 'Gladiateur' : 'Légende'}">
<b>${t.title === 'glad' ? 'Glad' : 'Leg'}</b>
${t.season ? `<span class="hint at-season-label">${esc(t.season)}</span>` : ''}
<button type="button" class="at-del" data-action="del-title" data-title-id="${t.id}" title="Retirer le titre">&times;</button>
</span>`).join('') || '<span class="hint">Aucun</span>'}
<span class="at-add">
<select class="mini-select" data-title-kind title="Type de titre">
<option value="glad">Gladiateur</option>
<option value="legend">Légende</option>
</select>
<input type="text" class="mini-num at-season" data-title-season placeholder="Saison" title="Saison du titre">
<button type="button" class="btn mini-btn" data-action="add-title" title="Ajouter le titre">+</button>
</span>
</div>
</td>
<td><input type="checkbox" data-field="active" ${c.active ? 'checked' : ''}></td>
<td class="hint">${esc(fmtDate(c.last_updated))}</td>
<td class="table-actions">
<button class="btn mini-btn" data-action="refresh">Maj</button>
<button class="del-btn" data-action="delete">Suppr</button>
</td>
</tr>`).join('');
}
$('#add-char-btn').addEventListener('click', () => {
$('#add-char-form').classList.toggle('hidden');
});
$('#nc-save').addEventListener('click', async () => {
try {
await api.post('/api/admin/characters', {
region: $('#nc-region').value,
realm: $('#nc-realm').value,
name: $('#nc-name').value,
realm_slug: $('#nc-realm-slug').value || null,
class: FR_CLASS_TO_EN[$('#nc-class').value] || null,
note: $('#nc-note').value
});
$('#nc-name').value = ''; $('#nc-realm').value = ''; $('#nc-realm-slug').value = ''; $('#nc-class').value = ''; $('#nc-note').value = '';
$('#add-char-form').classList.add('hidden');
await loadChars();
await refreshAll();
} catch (e) {
alert(e.message);
}
});
$('#chars-tbody').addEventListener('change', async (e) => {
const tr = e.target.closest('tr');
if (!tr) return;
if (e.target.matches('[data-field="active"]')) {
await api.put(`/api/admin/characters/${tr.dataset.id}`, { active: e.target.checked ? 1 : 0 });
} else if (e.target.matches('[data-field="class"]')) {
await api.put(`/api/admin/characters/${tr.dataset.id}`, { class: e.target.value || null });
} else if (e.target.matches('[data-field="level"]')) {
const v = e.target.value.trim();
await api.put(`/api/admin/characters/${tr.dataset.id}`, { level: v === '' ? null : Number(v) });
} else if (e.target.matches('[data-field="check_pvp_url"]')) {
await api.put(`/api/admin/characters/${tr.dataset.id}`, { check_pvp_url: e.target.value });
} else if (e.target.matches('[data-field="pvp_sets"]')) {
await api.put(`/api/admin/characters/${tr.dataset.id}`, { pvp_sets: e.target.value });
} else if (e.target.matches('[data-field="pve_sets"]')) {
await api.put(`/api/admin/characters/${tr.dataset.id}`, { pve_sets: e.target.value });
}
});
$('#chars-tbody').addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const tr = btn.closest('tr');
const id = tr.dataset.id;
if (btn.dataset.action === 'delete') {
if (!confirm('Supprimer ce personnage ?')) return;
await api.del(`/api/admin/characters/${id}`);
await loadChars();
} else if (btn.dataset.action === 'refresh') {
btn.textContent = '…';
await api.post('/api/admin/refresh', { id });
btn.textContent = 'Maj';
await loadChars();
await loadGoals();
await loadLogs();
} else if (btn.dataset.action === 'pvp') {
openPvpModal(id, tr.querySelector('.char-cell-name')?.textContent || '');
} else if (btn.dataset.action === 'add-title') {
const kind = tr.querySelector('[data-title-kind]')?.value || 'glad';
const season = (tr.querySelector('[data-title-season]')?.value || '').trim();
await api.post(`/api/admin/characters/${id}/titles`, { title: kind, season });
await loadChars();
} else if (btn.dataset.action === 'del-title') {
if (!confirm('Retirer ce titre ?')) return;
await api.del(`/api/admin/characters/${id}/titles/${btn.dataset.titleId}`);
await loadChars();
}
});
// ---- Ratings PvP modal ----
function openPvpModal(id, name) {
const chars = window.__adminChars || [];
const c = chars.find((x) => String(x.id) === String(id));
pvpEditId = id;
$('#pvp-modal-title').textContent = `Ratings PvP — ${name}`;
let pm = {};
try { pm = c && c.pvp_manual ? JSON.parse(c.pvp_manual || '{}') : {}; } catch {}
const classSpecs = (c && SPECS_BY_CLASS[c.class]) || [];
const cell = (spec, key, slot, val) => {
const ds = spec ? `data-spec="${spec}" ` : '';
return `<input type="number" class="mini-num" ${ds}data-bracket="${key}" data-slot="${slot}" value="${val ?? ''}" min="0">`;
};
$('#pvp-general-body').innerHTML = PVP_GENERAL.map(([key, label]) => `
<tr>
<td><b>${label}</b></td>
<td>${cell(null, key, 'season', pm[key]?.season)}</td>
<td>${cell(null, key, 'max', pm[key]?.max)}</td>
<td>${cell(null, key, 'won', pm[key]?.won)}</td>
<td>${cell(null, key, 'lost', pm[key]?.lost)}</td>
</tr>`).join('');
$('#pvp-specs').innerHTML = classSpecs.map((s) => {
const sp = (pm.specs && pm.specs[s.key]) || {};
return `
<div class="pvp-section">
<h4>${esc(s.fr)}</h4>
<table class="admin-table">
<thead><tr><th>Bracket</th><th>Cette saison</th><th>Max (xp)</th><th>Victoires</th><th>Défaites</th></tr></thead>
<tbody>${PVP_PER_SPEC.map(([key, label]) => `
<tr>
<td><b>${label}</b></td>
<td>${cell(s.key, key, 'season', sp[key]?.season)}</td>
<td>${cell(s.key, key, 'max', sp[key]?.max)}</td>
<td>${cell(s.key, key, 'won', sp[key]?.won)}</td>
<td>${cell(s.key, key, 'lost', sp[key]?.lost)}</td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
}).join('');
$('#pvp-modal-backdrop').hidden = false;
$('#pvp-save-msg').textContent = '';
document.body.style.overflow = 'hidden';
}
$('#pvp-save').addEventListener('click', async () => {
const out = { specs: {} };
const apply = (input) => {
const key = input.dataset.bracket;
const slot = input.dataset.slot;
const v = input.value.trim();
return { key, slot, v: v === '' ? 0 : Number(v) };
};
for (const input of $$('#pvp-general-body input')) {
const { key, slot, v } = apply(input);
if (!out[key]) out[key] = {};
out[key][slot] = v;
}
for (const input of $$('#pvp-specs input')) {
const spec = input.dataset.spec;
const { key, slot, v } = apply(input);
if (!out.specs[spec]) out.specs[spec] = {};
if (!out.specs[spec][key]) out.specs[spec][key] = {};
out.specs[spec][key][slot] = v;
}
try {
await api.put(`/api/admin/characters/${pvpEditId}`, { pvp_manual: JSON.stringify(out) });
$('#pvp-save-msg').textContent = 'Enregistré.';
$('#pvp-save-msg').style.color = 'var(--green)';
setTimeout(() => closePvpModal(), 600);
} catch (err) {
$('#pvp-save-msg').textContent = err.message;
$('#pvp-save-msg').style.color = 'var(--red)';
}
});
function closePvpModal() {
$('#pvp-modal-backdrop').hidden = true;
document.body.style.overflow = '';
}
$('#pvp-modal-close').addEventListener('click', closePvpModal);
$('#pvp-modal-backdrop').addEventListener('click', (e) => { if (e.target === e.currentTarget) closePvpModal(); });
// ---- Goals ----
async function loadGoals() {
const goals = await api.get('/api/admin/goals');
$('#admin-goals').innerHTML = goals.map((g) => `
<div class="goal-card" data-key="${esc(g.key)}">
<div class="goal-top">
<h3>${esc(g.title)}</h3>
<label class="badge ok" style="cursor:pointer"><input type="checkbox" data-gfield="enabled" ${g.enabled ? 'checked' : ''}> actif</label>
</div>
<div>
<label>Cible</label>
<input type="number" data-gfield="target" value="${g.target ?? ''}" placeholder="(max)">
</div>
<div>
<label>Barre max (affichage)</label>
<input type="number" data-gfield="display_max" value="${g.display_max ?? ''}">
</div>
<div>
<label>Unité</label>
<input type="text" data-gfield="unit" value="${esc(g.unit || '')}">
</div>
<div>
<label>Description</label>
<input type="text" data-gfield="description" value="${esc(g.description || '')}">
</div>
</div>`).join('');
}
$('#admin-goals').addEventListener('change', async (e) => {
const field = e.target.dataset.gfield;
if (!field) return;
const card = e.target.closest('.goal-card');
const key = card.dataset.key;
let value;
if (field === 'enabled') value = e.target.checked ? 1 : 0;
else if (field === 'target' || field === 'display_max') value = e.target.value === '' ? null : Number(e.target.value);
else value = e.target.value;
try {
await api.put(`/api/admin/goals/${key}`, { [field]: value });
} catch (err) {
alert(err.message);
}
});
// ---- Settings ----
function renderClassSelect(el, selected, onSave) {
const sel = new Set((selected || '').split(',').map((s) => s.trim()).filter(Boolean));
el.innerHTML = ADMIN_CLASSES.map((c) => `
<label class="class-chip${sel.has(c.name) ? ' on' : ''}" data-name="${c.name}">
<img src="/images/classes/${c.logo}" alt="${esc(c.fr)}" loading="lazy">
<span>${esc(c.fr)}</span>
</label>`).join('');
el.querySelectorAll('.class-chip').forEach((chip) => {
chip.addEventListener('click', () => {
const name = chip.dataset.name;
if (chip.classList.toggle('on')) sel.add(name); else sel.delete(name);
onSave([...sel].join(','));
});
});
}
function renderSingleClassSelect(el, selected, onSave) {
let current = (selected || '').trim();
el.innerHTML = ADMIN_CLASSES.map((c) => `
<label class="class-chip${current === c.name ? ' on' : ''}" data-name="${c.name}">
<img src="/images/classes/${c.logo}" alt="${esc(c.fr)}" loading="lazy">
<span>${esc(c.fr)}</span>
</label>`).join('');
el.querySelectorAll('.class-chip').forEach((chip) => {
chip.addEventListener('click', () => {
const name = chip.dataset.name;
current = current === name ? '' : name;
el.querySelectorAll('.class-chip').forEach((x) => x.classList.toggle('on', x.dataset.name === current));
onSave(current);
});
});
}
async function loadSettings() {
const s = await api.get('/api/admin/settings');
const fetchEl = $('#fetch-status');
if (fetchEl) {
const interval = s.fetch_interval_hours ? `${s.fetch_interval_hours} h` : '6 h';
fetchEl.innerHTML = s.last_fetch_at
? `Maj auto toutes les <b>${esc(interval)}</b> · Dernière : <b>${esc(fmtDate(s.last_fetch_at))}</b>`
: 'Aucune mise à jour automatique';
}
$('#st-interval').value = s.fetch_interval_hours || '';
$('#st-blizz-id').value = s.blizzard_client_id || '';
$('#st-blizz-secret').value = s.blizzard_client_secret || '';
$('#st-build').value = s.wow_tools_build || '';
$('#st-manual-bosses').value = s.manual_mythic_bosses || '';
$('#st-manual-ach').value = s.manual_achievements || '';
$('#st-manual-ach-total').value = s.manual_achievements_total || '';
$('#st-manual-glad-wins').value = s.manual_glad_wins || '';
renderSingleClassSelect($('#st-manual-glad-class-select'), s.manual_glad_class, async (v) => {
await api.put('/api/admin/settings', { manual_glad_class: v });
});
$('#st-raid-name').value = s.mythic_raid_name || '';
$('#st-raid-total').value = s.mythic_raid_total || '';
$('#st-twitch-url').value = s.twitch_url || '';
$('#st-youtube-url').value = s.youtube_url || '';
$('#st-tiktok-url').value = s.tiktok_url || '';
$('#st-glad-season').value = s.glad_title_season || '';
$('#st-legend-season').value = s.legend_title_season || '';
renderClassSelect($('#st-manual-class90-select'), s.manual_class90_classes, async (v) => {
await api.put('/api/admin/settings', { manual_class90_classes: v });
});
renderClassSelect($('#st-manual-ss2300-select'), s.manual_ss2300_classes, async (v) => {
await api.put('/api/admin/settings', { manual_ss2300_classes: v });
});
const gladWinsInput = $('#st-manual-glad-wins');
gladWinsInput.dataset.savedValue = gladWinsInput.value;
gladWinsInput.addEventListener('change', async () => {
const v = gladWinsInput.value;
try {
await api.put('/api/admin/settings', { manual_glad_wins: v });
gladWinsInput.dataset.savedValue = v;
$('#settings-error').textContent = 'Victoires gladiateur enregistrées.';
$('#settings-error').style.color = 'var(--green)';
} catch (e) {
gladWinsInput.value = gladWinsInput.dataset.savedValue;
$('#settings-error').textContent = e.message;
$('#settings-error').style.color = '';
}
$('#settings-error').hidden = false;
});
$('#st-last-fetch').textContent = s.last_fetch_at ? `Dernière mise à jour : ${fmtDate(s.last_fetch_at)}` : 'Aucune mise à jour effectuée.';
$('#settings-error').hidden = true;
}
async function saveSettings() {
$('#settings-error').hidden = true;
try {
await api.put('/api/admin/settings', {
fetch_interval_hours: $('#st-interval').value,
blizzard_client_id: $('#st-blizz-id').value,
blizzard_client_secret: $('#st-blizz-secret').value,
wow_tools_build: $('#st-build').value,
manual_mythic_bosses: $('#st-manual-bosses').value,
manual_achievements: $('#st-manual-ach').value,
manual_achievements_total: $('#st-manual-ach-total').value,
manual_glad_wins: $('#st-manual-glad-wins').value,
mythic_raid_name: $('#st-raid-name').value,
mythic_raid_total: $('#st-raid-total').value,
twitch_url: $('#st-twitch-url').value,
youtube_url: $('#st-youtube-url').value,
tiktok_url: $('#st-tiktok-url').value,
glad_title_season: $('#st-glad-season').value,
legend_title_season: $('#st-legend-season').value
});
$('#settings-error').textContent = 'Paramètres enregistrés.';
$('#settings-error').hidden = false;
$('#settings-error').style.color = 'var(--green)';
await loadSettings();
} catch (e) {
$('#settings-error').textContent = e.message;
$('#settings-error').hidden = false;
}
}
$('#st-save').addEventListener('click', saveSettings);
$('#st-save-blizz').addEventListener('click', saveSettings);
$('#st-refresh').addEventListener('click', async () => {
$('#st-refresh').textContent = 'Mise à jour en cours…';
await api.post('/api/admin/refresh', {});
$('#st-refresh').textContent = 'Lancer une mise à jour complète';
await loadSettings();
await loadLogs();
await loadGoals();
});
$('#pw-save').addEventListener('click', async () => {
try {
await api.post('/api/admin/password', { current: $('#pw-current').value, next: $('#pw-next').value });
$('#pw-current').value = ''; $('#pw-next').value = '';
alert('Mot de passe changé.');
} catch (e) {
alert(e.message);
}
});
async function refreshAll() {
await api.post('/api/admin/refresh', {});
await loadChars();
await loadSettings();
await loadLogs();
await loadGoals();
}
$('#refresh-all-btn').addEventListener('click', async () => {
const btn = $('#refresh-all-btn');
btn.disabled = true;
btn.textContent = 'Mise à jour en cours…';
try {
await refreshAll();
} finally {
btn.disabled = false;
btn.textContent = 'Tout actualiser';
}
});
// ---- Logs ----
async function loadLogs() {
const logs = await api.get('/api/admin/logs');
$('#logs-tbody').innerHTML = logs.map((l) => `
<tr>
<td class="hint">${esc(fmtDate(l.at))}</td>
<td>#${l.character_id ?? '—'}</td>
<td>${esc(l.source)}</td>
<td><span class="status-dot ${l.status === 'ok' ? 'ok' : 'error'}"></span>${esc(l.status)}</td>
<td class="hint">${esc(shortDetail(l.detail))}</td>
</tr>`).join('') || '<tr><td colspan="5" class="hint">Aucun log.</td></tr>';
}
function shortDetail(d) {
try {
const j = JSON.parse(d || '{}');
return Object.entries(j).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`).join(' · ');
} catch {
return d || '';
}
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
const SOCIAL_SVG = {
youtube: '<svg viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>',
tiktok: '<svg viewBox="0 0 24 24"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>'
};
function renderSocials(el, socials) {
const buttons = [];
if (socials.youtube) buttons.push(`<a class="social-btn" href="${esc(socials.youtube)}" target="_blank" rel="noopener" title="YouTube">${SOCIAL_SVG.youtube}</a>`);
if (socials.tiktok) buttons.push(`<a class="social-btn" href="${esc(socials.tiktok)}" target="_blank" rel="noopener" title="TikTok">${SOCIAL_SVG.tiktok}</a>`);
el.innerHTML = buttons.join('');
}
async function updateLiveStatus(statusEl, twitchUrl) {
const channel = twitchUrl.replace(/\/+$/, '').split('/').pop() || 'wycz';
const link = `<a class="live-status" href="${esc(twitchUrl)}" target="_blank" rel="noopener">`;
try {
const res = await fetch(`https://decapi.me/twitch/uptime/${encodeURIComponent(channel)}`, { mode: 'cors' });
const text = (await res.text()).trim();
const isLive = text && !/offline|hors ligne|hors-ligne|not live/i.test(text);
statusEl.innerHTML = isLive
? `${link}<span class="live-dot on"></span> Live en cours</a>`
: `${link}<span class="live-dot"></span> Hors ligne</a>`;
} catch {
statusEl.innerHTML = `${link}<span class="live-dot"></span> Twitch</a>`;
}
}
async function loadHeader() {
try {
const overview = await api.get('/api/overview');
renderSocials($('#socials'), { youtube: (overview.youtube && overview.youtube.url) || '', tiktok: (overview.tiktok && overview.tiktok.url) || '' });
updateLiveStatus($('#header-live'), (overview.twitch && overview.twitch.url) || 'https://twitch.tv/wycz');
} catch {
$('#socials').innerHTML = '';
}
}
const sessionUndoBtn = $('#session-undo');
if (sessionUndoBtn) sessionUndoBtn.addEventListener('click', sessionUndo);
loadHeader();
checkLogin();
})();
+27
View File
@@ -0,0 +1,27 @@
window.api = {
async get(path) {
const r = await fetch(path);
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try { const j = await r.json(); msg = j.error || msg; } catch {}
throw new Error(msg);
}
return r.json();
},
async send(method, path, body) {
const r = await fetch(path, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try { const j = await r.json(); msg = j.error || msg; } catch {}
throw new Error(msg);
}
return r.json();
},
post(path, body) { return this.send('POST', path, body); },
put(path, body) { return this.send('PUT', path, body); },
del(path) { return this.send('DELETE', path); }
};
+772
View File
@@ -0,0 +1,772 @@
(function () {
const CLASSES = [
{ name: 'Warrior', fr: 'Guerrier', logo: 'War.png', color: '#C79C6E' },
{ name: 'Paladin', fr: 'Paladin', logo: 'Pal.png', color: '#F58CBA' },
{ name: 'Hunter', fr: 'Chasseur', logo: 'Hunt.png', color: '#ABD473' },
{ name: 'Rogue', fr: 'Voleur', logo: 'Rogue.png', color: '#FFF569' },
{ name: 'Priest', fr: 'Prêtre', logo: 'Priest.png', color: '#FFFFFF' },
{ name: 'Death Knight', fr: 'Chevalier de la mort', logo: 'DK.png', color: '#C41F3B' },
{ name: 'Shaman', fr: 'Chaman', logo: 'Sham.png', color: '#0070DE' },
{ name: 'Mage', fr: 'Mage', logo: 'Mage.png', color: '#40C7EB' },
{ name: 'Warlock', fr: 'Démoniste', logo: 'Lock.png', color: '#8787ED' },
{ name: 'Monk', fr: 'Moine', logo: 'monk.png', color: '#00FF96' },
{ name: 'Druid', fr: 'Druide', logo: 'Druid.png', color: '#FF7D0A' },
{ name: 'Demon Hunter', fr: 'Chasseur de démons', logo: 'dh.png', color: '#A330C9' },
{ name: 'Evoker', fr: 'Évocateur', logo: 'evoker.png', color: '#33937F' }
];
const CLASS_BY_NAME = Object.fromEntries(CLASSES.map((c) => [c.name, c]));
const classInfo = (name) => CLASS_BY_NAME[name] || { name: name || '?', fr: name || 'Inconnu', logo: null, color: '#8b94a7' };
const SPECS_FR = {
'Elemental': 'Élémentaire', 'Enhancement': 'Amélioration', 'Restoration': 'Restauration',
'Holy': 'Sacré', 'Protection': 'Protection', 'Retribution': 'Rétribution',
'Discipline': 'Discipline', 'Shadow': 'Ombre', 'Blood': 'Sang', 'Frost': 'Givre', 'Unholy': 'Impiété',
'Havoc': 'Dévastation', 'Vengeance': 'Vengeance', 'Devastation': 'Dévastation', 'Preservation': 'Préservation',
'Augmentation': 'Augmentation', 'Arcane': 'Arcane', 'Fire': 'Feu', 'Assassination': 'Assassinat',
'Outlaw': 'Hors-la-loi', 'Subtlety': 'Subtilité', 'Beast Mastery': 'Maîtrise des bêtes',
'Marksmanship': 'Précision', 'Survival': 'Survie', 'Arms': 'Armes', 'Fury': 'Fureur',
'Balance': 'Équilibre', 'Feral': 'Farouche', 'Guardian': 'Gardien', 'Brewmaster': 'Maître brasseur',
'Mistweaver': 'Tisse-brume', 'Windwalker': 'Marche-vent', 'Affliction': 'Affliction',
'Demonology': 'Démonologie', 'Destruction': 'Destruction'
};
const specFr = (s) => (s ? (SPECS_FR[s] || s) : null);
const SPECS_BY_CLASS = {
'Death Knight': [
{ key: 'Blood', fr: 'Sang', icon: 'spell_deathknight_bloodpresence.jpg' },
{ key: 'Frost', fr: 'Givre', icon: 'spell_deathknight_frostpresence.jpg' },
{ key: 'Unholy', fr: 'Impie', icon: 'spell_deathknight_unholypresence.jpg' }
],
'Demon Hunter': [
{ key: 'Havoc', fr: 'Dévastation', icon: 'ability_demonhunter_specdps.jpg' },
{ key: 'Vengeance', fr: 'Vengeance', icon: 'ability_demonhunter_spectank.jpg' },
{ key: 'Devoration', fr: 'Dévoration', icon: 'classicon_demonhunter_void.jpg' }
],
'Druid': [
{ key: 'Balance', fr: 'Équilibre', icon: 'spell_nature_starfall.jpg' },
{ key: 'Feral', fr: 'Farouche', icon: 'ability_druid_catform.jpg' },
{ key: 'Guardian', fr: 'Gardien', icon: 'ability_racial_bearform.jpg' },
{ key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_healingtouch.jpg' }
],
'Evoker': [
{ key: 'Devastation', fr: 'Dévastation', icon: 'classicon_evoker_devastation.jpg' },
{ key: 'Preservation', fr: 'Préservation', icon: 'classicon_evoker_preservation.jpg' },
{ key: 'Augmentation', fr: 'Augmentation', icon: 'classicon_evoker_augmentation.jpg' }
],
'Hunter': [
{ key: 'Beast Mastery', fr: 'Maîtrise des bêtes', icon: 'ability_hunter_bestialdiscipline.jpg' },
{ key: 'Marksmanship', fr: 'Précision', icon: 'ability_hunter_focusedaim.jpg' },
{ key: 'Survival', fr: 'Survie', icon: 'ability_hunter_camouflage.jpg' }
],
'Mage': [
{ key: 'Arcane', fr: 'Arcanes', icon: 'spell_holy_magicalsentry.jpg' },
{ key: 'Fire', fr: 'Feu', icon: 'spell_fire_firebolt02.jpg' },
{ key: 'Frost', fr: 'Givre', icon: 'spell_frost_frostbolt02.jpg' }
],
'Monk': [
{ key: 'Brewmaster', fr: 'Maître brasseur', icon: 'spell_monk_brewmaster_spec.jpg' },
{ key: 'Mistweaver', fr: 'Tisse-brume', icon: 'spell_monk_mistweaver_spec.jpg' },
{ key: 'Windwalker', fr: 'Marche-vent', icon: 'spell_monk_windwalker_spec.jpg' }
],
'Paladin': [
{ key: 'Holy', fr: 'Sacré', icon: 'spell_holy_holybolt.jpg' },
{ key: 'Protection', fr: 'Protection', icon: 'ability_paladin_shieldofthetemplar.jpg' },
{ key: 'Retribution', fr: 'Vindicte', icon: 'spell_holy_auraoflight.jpg' }
],
'Priest': [
{ key: 'Discipline', fr: 'Discipline', icon: 'spell_holy_powerwordshield.jpg' },
{ key: 'Holy', fr: 'Sacré', icon: 'spell_holy_guardianspirit.jpg' },
{ key: 'Shadow', fr: 'Ombre', icon: 'spell_shadow_shadowwordpain.jpg' }
],
'Rogue': [
{ key: 'Assassination', fr: 'Assassinat', icon: 'ability_rogue_deadlybrew.jpg' },
{ key: 'Outlaw', fr: 'Hors-la-loi', icon: 'ability_rogue_waylay.jpg' },
{ key: 'Subtlety', fr: 'Finesse', icon: 'ability_stealth.jpg' }
],
'Shaman': [
{ key: 'Elemental', fr: 'Élémentaire', icon: 'spell_nature_lightning.jpg' },
{ key: 'Enhancement', fr: 'Amélioration', icon: 'spell_shaman_improvedstormstrike.jpg' },
{ key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_magicimmunity.jpg' }
],
'Warlock': [
{ key: 'Affliction', fr: 'Affliction', icon: 'spell_shadow_deathcoil.jpg' },
{ key: 'Demonology', fr: 'Démonologie', icon: 'spell_shadow_metamorphosis.jpg' },
{ key: 'Destruction', fr: 'Destruction', icon: 'spell_shadow_rainoffire.jpg' }
],
'Warrior': [
{ key: 'Arms', fr: 'Armes', icon: 'ability_warrior_innerrage.jpg' },
{ key: 'Fury', fr: 'Fureur', icon: 'ability_warrior_savageblow.jpg' },
{ key: 'Protection', fr: 'Protection', icon: 'ability_warrior_defensivestance.jpg' }
]
};
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
let classGroups = new Map();
let classTitles = { glad: [], legend: [] };
function fmt(n) {
if (n === null || n === undefined) return '—';
return n.toLocaleString('fr-FR');
}
function fmtSet(v) {
if (v === null || v === undefined || v === '' || v === 0) return '—';
return String(v);
}
function pct(n, d) {
return d > 0 ? Math.max(0, Math.min(100, Math.round((n / d) * 100))) : 0;
}
function charTopRating(c) {
let best = 0;
const pm = c.pvp_manual || {};
for (const k of ['ARENA_2v2', 'ARENA_3v3', 'RATED_BG']) {
const m = pm[k] || {};
const v = Math.max(m.max || 0, m.season || 0);
if (v > best) best = v;
}
const specs = pm.specs || {};
for (const k of Object.keys(specs)) {
for (const br of ['SOLO_SHUFFLE', 'BLITZ']) {
const m = specs[k][br] || {};
const v = Math.max(m.max || 0, m.season || 0);
if (v > best) best = v;
}
}
if (best) return best;
const auto = (c.data && (c.data.blizzard || c.data.raiderio || {}).pvp) || {};
return Math.max(0, ...Object.values(auto).map((x) => (x && typeof x.rating === 'number' ? x.rating : 0)));
}
function armoryImage(thumbnail) {
if (!thumbnail) return null;
return thumbnail.replace(/-avatar\.jpg/i, '-inset.jpg');
}
function overviewBadges(classes) {
const covered = new Set(classes || []);
const items = CLASSES.map((cls) => {
const done = covered.has(cls.name);
const img = cls.logo
? `<img class="overview-badge" src="/images/classes/${esc(cls.logo)}" alt="${esc(cls.fr)}" title="${esc(cls.fr)}" loading="lazy">`
: `<span class="overview-badge overview-badge-txt" title="${esc(cls.fr)}">${esc(cls.fr[0])}</span>`;
return `<span class="goal-class${done ? ' done' : ''}" title="${esc(cls.fr)}">${img}${done ? '<span class="goal-check">✓</span>' : ''}</span>`;
}).join('');
return `<div class="overview-badges">${items}</div>`;
}
const BEST_ORDER = [['ARENA_2v2', '2v2'], ['ARENA_3v3', '3v3'], ['SOLO_SHUFFLE', 'Solo Shuffle'], ['BLITZ', 'Blitz'], ['RATED_BG', 'RBG']];
const BEST_COLOR = {
ARENA_2v2: '#5aa9ff',
ARENA_3v3: '#ff5c5c',
SOLO_SHUFFLE: '#37e0ff',
BLITZ: '#ffb347',
RATED_BG: '#7ed957'
};
function bestsTable(bests) {
const rows = BEST_ORDER.map(([key, label]) => {
const b = bests[key] || null;
if (!b) {
return `<div class="best-panel" style="--brk:${BEST_COLOR[key] || '#5aa9ff'}">
<div class="best-head"><span class="best-brk">${label}</span></div>
<div class="best-empty">—</div>
</div>`;
}
const info = classInfo(b.class);
const spec = b.spec ? specFr(b.spec) : null;
const specEntry = (SPECS_BY_CLASS[info.name] || []).find((s) => s.key === b.spec);
const avatar = specEntry && specEntry.icon
? `<img class="best-avatar" src="/images/specs/${esc(specEntry.icon)}" alt="" loading="lazy">`
: (info.logo ? `<img class="best-avatar" src="/images/classes/${esc(info.logo)}" alt="" loading="lazy">` : '<span class="best-avatar best-avatar-empty"></span>');
const tier = tierFor(b.rating);
return `<div class="best-panel" style="--brk:${BEST_COLOR[key] || '#5aa9ff'}">
<div class="best-head">
<span class="best-brk">${label}</span>
${tier ? `<span class="brk-tier" style="color:${tier.color};border-color:${tier.color}">${tier.name}</span>` : ''}
</div>
<div class="best-char">
${avatar}
<div class="best-cname">${esc(info.fr)}${spec ? `<span class="muted"> · ${esc(spec)}</span>` : ''}</div>
<div class="best-csub">${esc(b.name || '')}</div>
</div>
<div class="best-rating">${fmt(b.rating)}</div>
</div>`;
}).join('');
return `<div class="overview-card bests-card">
<div class="overview-head"><h3>Meilleure classe &amp; spé par bracket</h3></div>
<div class="bests-grid">${rows}</div>
</div>`;
}
function overviewCard(g) {
const manualBadge = g.manual && g.key !== 'achievements' ? '<span class="badge muted">manuel</span>' : '';
const done = !!g.achieved;
const doneBadge = done ? '<span class="badge done">✓ Atteint</span>' : '';
let value;
let sub;
let progress = 0;
let badges = '';
if (g.key === 'class90' || g.key === 'ss2300') {
value = `<b>${fmt(g.covered)}</b> / ${g.total} classes`;
sub = g.total > 0 ? `${pct(g.covered, g.total)}%` : '—';
progress = g.total > 0 ? g.covered / g.total : 0;
badges = overviewBadges(g.classes);
} else if (g.key === 'gladiator_s2') {
const w = g.wins || 0;
const t = g.total || 50;
value = g.obtained ? '<b style="color:var(--green)">Obtenu</b>' : `<b>${fmt(w)}</b> / ${fmt(t)} victoires`;
sub = '';
progress = t > 0 ? Math.min(1, w / t) : 0;
badges = overviewBadges(g.classes || []);
} else if (g.key === 'achievements') {
const pc = g.total > 0 ? pct(g.points, g.total) : 0;
value = `<b>${fmt(g.points)}</b> / ${fmt(g.total)}`;
sub = `${pc}% · Points de hauts faits`;
progress = g.total > 0 ? Math.min(1, g.points / g.total) : 0;
}
return `
<div class="overview-card${done ? ' achieved' : ''}">
<div class="overview-head">
<h3>${esc(g.title)}</h3>
${manualBadge}
${doneBadge}
</div>
<div class="overview-value">${value}</div>
${sub ? `<div class="overview-sub">${esc(sub)}</div>` : ''}
<div class="progress-bar"><div class="fill" style="width:${pct(progress * 100, 100)}%"></div></div>
${badges}
</div>`;
}
const TIERS = [
{ min: 2400, name: 'Elite', color: '#ff8000' },
{ min: 2100, name: 'Duelist', color: '#a335ee' },
{ min: 1800, name: 'Rival', color: '#0070dd' },
{ min: 1400, name: 'Challenger', color: '#1eff00' },
{ min: 0, name: 'Combatant', color: '#9d9d9d' }
];
const tierFor = (rating) => {
if (!rating || rating <= 0) return null;
return TIERS.find((t) => rating >= t.min) || null;
};
const brkBlock = (label, color, b) => {
const current = b.season || null;
const max = b.max || null;
const tier = tierFor(current);
const total = (b.won || 0) + (b.lost || 0);
const wr = total > 0 ? Math.round((b.won / total) * 100) : null;
return `
<div class="brk-block" style="--brk:${color}">
<div class="brk-head">
<span class="brk-name">${label}</span>
${tier ? `<span class="brk-tier" style="color:${tier.color};border-color:${tier.color}">${tier.name}</span>` : '<span class="brk-tier muted">Non classé</span>'}
${b.isRecord ? '<span class="brk-record">★ Record</span>' : ''}
</div>
<div class="brk-rating">
<span class="brk-current">${current ? fmt(current) : '—'}</span>
${max != null ? `<span class="brk-maxsep">/</span><span class="brk-max" title="Max XP (saison en cours)">${fmt(max)}</span>` : ''}
</div>
<div class="brk-sub">${current != null ? `Côte actuelle · ` : ''}${max != null ? `Max XP ${fmt(max)}` : (current != null ? 'Aucun max enregistré' : 'Aucune côte')}</div>
<div class="brk-bar${total ? '' : ' empty'}"><div class="brk-bar-fill" style="width:${wr || 0}%"></div></div>
<div class="brk-wl">${fmt(b.won)}V · ${fmt(b.lost)}D${wr != null ? ` <b>${wr}%</b>` : ''}</div>
<div class="brk-games">${total ? `${fmt(total)} games` : 'Aucune game cette saison'}</div>
</div>`;
};
function bracketValues(c) {
const pm = c.pvp_manual || {};
const auto = c.pvp || {};
const autoOf = (key) => (auto[key] && typeof auto[key].rating === 'number' ? auto[key] : null);
const resolve = (key, m) => {
const a = autoOf(key) || {};
const season = m.season || (a.rating || null);
const max = m.max || (c.pvp_best && c.pvp_best[key]) || null;
return {
season,
max,
won: m.won || a.won || 0,
lost: m.lost || a.lost || 0,
weekly_won: a.weekly_won || 0,
weekly_lost: a.weekly_lost || 0,
tier: a.tier || null,
isRecord: !!(c.pvp_records && c.pvp_records[key])
};
};
return {
generalVal: (key) => resolve(key, pm[key] || {}),
specVal: (spec, key) => {
const s = (pm.specs && pm.specs[spec]) || {};
return resolve(key, s[key] || {});
}
};
}
function bracketsGrid(c, spec) {
const { generalVal, specVal } = bracketValues(c);
const PANELS = [
['2v2', '#5aa9ff', generalVal('ARENA_2v2')],
['3v3', '#ff5c5c', generalVal('ARENA_3v3')],
['Solo', '#37e0ff', spec ? specVal(spec, 'SOLO_SHUFFLE') : null],
['Blitz', '#ffb347', spec ? specVal(spec, 'BLITZ') : null],
['RBG', '#7ed957', generalVal('RATED_BG')]
];
return `<div class="brk-grid">${PANELS.map(([label, color, b]) => brkBlock(label, color, b)).join('')}</div>`;
}
let modalChars = [];
let modalSpecByChar = {};
const fullCache = {};
function getFull(id) {
if (!fullCache[id]) fullCache[id] = api.get(`/api/characters/${id}`);
return fullCache[id];
}
function charRow(c) {
const info = classInfo(c.class);
const frozen = (c.media && (c.media.main || c.media.inset || c.media.avatar)) || renderImage(c.thumbnail_url) || c.thumbnail_url || null;
const ilvl = c.ilvl || null;
const specs = SPECS_BY_CLASS[info.name] || [];
const sel = modalSpecByChar[c.id] || null;
const specPanels = specs.map((s) => `
<button type="button" class="wm-spec-panel${sel === s.key ? ' on' : ''}" data-char="${c.id}" data-spec="${esc(s.key)}" title="${esc(s.fr)}">
<img class="spec-icon" src="/images/specs/${esc(s.icon)}" alt="" loading="lazy">
<span>${esc(s.fr)}</span>
</button>`).join('');
const metaBits = [];
if (c.realm) metaBits.push(esc(c.realm));
if (c.race) metaBits.push(esc(c.race));
const faction = c.faction === 'ALLIANCE' || c.faction === 'Alliance' ? 'Alliance' : c.faction === 'HORDE' || c.faction === 'Horde' ? 'Horde' : null;
if (faction) metaBits.push(faction);
const metaText = metaBits.filter(Boolean).join(' · ');
let detail = '';
if (sel) {
detail = `
<div class="wm-detail">
${bracketsGrid(c, sel)}
<div class="modal-section wm-section">
<h4 class="modal-section-title">Équipement</h4>
<div class="modal-gear" data-gear="${c.id}"><div class="loading">Chargement…</div></div>
</div>
<div class="modal-section wm-section">
<h4 class="modal-section-title">Spécialisation</h4>
<div class="modal-talents" data-talents="${c.id}"><div class="loading">Chargement…</div></div>
</div>
</div>`;
}
return `
<div class="class-char wm-card" data-id="${c.id}" style="--class-color:${info.color}">
<div class="wm-specs wm-specs-top">${specPanels}</div>
<div class="wm-img" aria-hidden="true">
${frozen ? `<img src="${esc(frozen)}" alt="" loading="lazy">` : `<span class="wm-img-empty">${esc(c.name[0])}</span>`}
</div>
<div class="wm-info">
<div class="wm-name">${c.check_pvp_url ? `<a class="char-link" href="${esc(c.check_pvp_url)}" target="_blank" rel="noopener">${esc(c.name)}</a>` : esc(c.name)}</div>
<div class="wm-meta">${metaText}${ilvl != null ? `<span class="wm-ilvl">ILVL ${fmt(ilvl)}</span>` : ''}</div>
</div>
<div class="wm-titles">${classTitleEntries(info, classTitles)}</div>
${detail}
</div>`;
}
function titleTags(cls, titles) {
const parts = [];
if ((titles.glad || []).includes(cls.name)) parts.push('<img class="hdr-vignette" src="/images/titles/gladiateur.jpg" alt="Gladiateur" title="Titre Gladiateur">');
if ((titles.legend || []).includes(cls.name)) parts.push('<img class="hdr-vignette" src="/images/titles/legende.webp" alt="Légende" title="Titre Légende">');
if (!parts.length) return '';
return `<span class="hdr-titles">${parts.join('')}</span>`;
}
function classTitleEntries(cls, titles) {
const items = [];
for (const e of (titles.glad_entries || [])) {
if (e.class === cls.name) items.push(`<span class="wm-title"><img src="/images/titles/gladiateur.jpg" alt="Gladiateur" title="Titre Gladiateur"><b>Gladiateur</b>${e.season ? esc(e.season) : ''}</span>`);
}
for (const e of (titles.legend_entries || [])) {
if (e.class === cls.name) items.push(`<span class="wm-title"><img src="/images/titles/legende.webp" alt="Légende" title="Titre Légende"><b>Légende</b>${e.season ? esc(e.season) : ''}</span>`);
}
if (!items.length) {
if ((titles.glad || []).includes(cls.name)) items.push(`<span class="wm-title"><img src="/images/titles/gladiateur.jpg" alt="Gladiateur" title="Titre Gladiateur"><b>Gladiateur</b>${titles.glad_season ? esc(titles.glad_season) : ''}</span>`);
if ((titles.legend || []).includes(cls.name)) items.push(`<span class="wm-title"><img src="/images/titles/legende.webp" alt="Légende" title="Titre Légende"><b>Légende</b>${titles.legend_season ? esc(titles.legend_season) : ''}</span>`);
}
return items.join('');
}
function renderModalChars() {
if (!modalChars.length) {
$('#class-modal-chars').innerHTML = '<div class="class-empty">Aucun personnage pour cette classe</div>';
return;
}
$('#class-modal-chars').innerHTML = modalChars.map((c) => charRow(c)).join('');
for (const c of modalChars) {
const sel = modalSpecByChar[c.id];
if (!sel) continue;
const gearEl = document.querySelector(`[data-gear="${c.id}"]`);
const talentsEl = document.querySelector(`[data-talents="${c.id}"]`);
if (gearEl) {
getFull(c.id).then((full) => {
if (gearEl) gearEl.innerHTML = gearHTML(full.gear);
}).catch(() => {
if (gearEl) gearEl.innerHTML = '<div class="gear-empty">Équipement indisponible</div>';
});
}
if (talentsEl) {
talentsEl.innerHTML = '<div class="loading">Chargement…</div>';
api.get(`/api/characters/${c.id}/talents?spec=${encodeURIComponent(sel)}`).then((t) => {
if (talentsEl) talentsEl.innerHTML = talentTreeHTML(t);
}).catch(() => {
if (talentsEl) talentsEl.innerHTML = '<div class="gear-empty">Détails indisponibles</div>';
});
}
}
}
function openClassModal(cls, chars, titles) {
modalChars = chars;
modalSpecByChar = {};
$('#class-modal-logo').src = cls.logo ? `/images/classes/${cls.logo}` : '';
$('#class-modal-logo').style.display = cls.logo ? '' : 'none';
renderModalChars();
$('#class-modal-backdrop').hidden = false;
document.body.style.overflow = 'hidden';
}
$('#class-modal-chars').addEventListener('click', (e) => {
const panel = e.target.closest('.wm-spec-panel');
if (!panel) return;
const id = panel.dataset.char;
const spec = panel.dataset.spec;
modalSpecByChar[id] = modalSpecByChar[id] === spec ? null : spec;
renderModalChars();
const selPanel = $('#class-modal-chars').querySelector(`.wm-spec-panel.on[data-char="${CSS.escape(id)}"]`);
if (selPanel) {
selPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const detail = selPanel.closest('.class-char').querySelector('.wm-detail');
if (detail) detail.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
function closeClassModal() {
$('#class-modal-backdrop').hidden = true;
document.body.style.overflow = '';
}
async function load() {
const statusEl = $('#header-status');
const overviewEl = $('#overview-grid');
try {
const [overview, chars] = await Promise.all([api.get('/api/overview'), api.get('/api/characters')]);
const cards = [
{ key: 'class90', title: 'Classes niveau 90', covered: overview.class90.covered, total: overview.class90.total, manual: overview.class90.manual, classes: overview.class90.classes, achieved: overview.class90.total > 0 && overview.class90.covered >= overview.class90.total },
{ key: 'ss2300', title: 'Toutes les classes à 2300 en soloshuffle — Saison 2', covered: overview.ss2300.covered, total: overview.ss2300.total, manual: overview.ss2300.manual, classes: overview.ss2300.classes, achieved: overview.ss2300.total > 0 && overview.ss2300.covered >= overview.ss2300.total },
{ key: 'gladiator_s2', title: 'Gladiateur S2 Midnight', obtained: overview.gladiator_s2.obtained, wins: overview.gladiator_s2.wins, total: overview.gladiator_s2.total, rating: overview.gladiator_s2.rating, class: overview.gladiator_s2.class, classes: (overview.titles && overview.titles.glad_s2) || [], achieved: !!overview.gladiator_s2.obtained }
];
overviewEl.innerHTML = cards.map(overviewCard).join('') + bestsTable(overview.bests || {});
const grouped = new Map();
for (const c of chars) {
const key = classInfo(c.class).name;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(c);
}
for (const list of grouped.values()) {
list.sort((a, b) => charTopRating(b) - charTopRating(a));
}
classGroups = grouped;
classTitles = overview.titles || { glad: [], legend: [] };
const headerClasses = $('#header-classes');
if (headerClasses) {
const class90 = new Set(overview.class90.classes || []);
headerClasses.innerHTML = CLASSES.map((cls) => `
<button type="button" class="class-icon header-class-icon${class90.has(cls.name) ? '' : ' dim'}" data-class="${esc(cls.name)}" style="--class-color:${cls.color}" title="${esc(cls.fr)}">
${cls.logo ? `<img class="class-icon-img" src="/images/classes/${esc(cls.logo)}" alt="${esc(cls.fr)}" loading="lazy">` : `<span class="class-icon-empty">${esc(cls.fr[0])}</span>`}
${titleTags(cls, classTitles)}
</button>`).join('');
}
const twitchUrl = overview.twitch.url || 'https://twitch.tv/wycz';
updateLiveStatus(statusEl, twitchUrl);
renderSocials($('#socials'), {
youtube: (overview.youtube && overview.youtube.url) || '',
tiktok: (overview.tiktok && overview.tiktok.url) || ''
});
} catch (e) {
overviewEl.innerHTML = `<div class="loading">Erreur de chargement : ${esc(e.message)}</div>`;
}
}
async function updateLiveStatus(statusEl, twitchUrl) {
const channel = twitchUrl.replace(/\/+$/, '').split('/').pop() || 'wycz';
const safeChannel = encodeURIComponent(channel);
const link = `<a class="live-status" href="${esc(twitchUrl)}" target="_blank" rel="noopener">`;
const twitchLogo = `<span class="twitch-logo-img">${SOCIAL_SVG.twitch}</span>`;
try {
const res = await fetch(`https://decapi.me/twitch/uptime/${safeChannel}`, { mode: 'cors' });
const text = (await res.text()).trim();
const isLive = text && !/offline|hors ligne|hors-ligne|not live/i.test(text);
statusEl.innerHTML = `${link}${twitchLogo}<span class="live-dot${isLive ? ' on' : ''}"></span></a>`;
} catch {
statusEl.innerHTML = `${link}${twitchLogo}<span class="live-dot"></span></a>`;
}
}
const SOCIAL_SVG = {
twitch: '<svg viewBox="0 0 24 24"><path d="M4.265 0L.79 3.517v16.966h5.804V24h3.225l3.476-3.517h4.455L23.21 12V0H4.265zm17.55 11.31l-3.225 3.586h-4.586l-3.215 3.379v-3.379H5.526V2.62h16.29v8.69z"/></svg>',
youtube: '<svg viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>',
tiktok: '<svg viewBox="0 0 24 24"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>'
};
function renderSocials(el, socials) {
const buttons = [];
if (socials.youtube) {
buttons.push(`<a class="social-btn" href="${esc(socials.youtube)}" target="_blank" rel="noopener" title="YouTube">${SOCIAL_SVG.youtube}</a>`);
}
if (socials.tiktok) {
buttons.push(`<a class="social-btn" href="${esc(socials.tiktok)}" target="_blank" rel="noopener" title="TikTok">${SOCIAL_SVG.tiktok}</a>`);
}
el.innerHTML = buttons.join('');
}
const backdrop = $('#modal-backdrop');
const modalArt = $('#modal-art');
const modalError = $('#modal-error');
function closeModal() {
backdrop.hidden = true;
document.body.style.overflow = '';
}
document.addEventListener('click', (e) => {
const copyBtn = e.target.closest('[data-copy]');
if (copyBtn) {
navigator.clipboard.writeText(copyBtn.dataset.copy || '').then(() => {
copyBtn.textContent = 'Copié !';
setTimeout(() => { copyBtn.textContent = 'Copier le build'; }, 1600);
}).catch(() => {});
return;
}
const classIcon = e.target.closest('.class-icon');
if (classIcon) {
const name = classIcon.dataset.class;
openClassModal(classInfo(name), classGroups.get(name) || [], classTitles);
return;
}
if (e.target === $('#class-modal-backdrop')) {
closeClassModal();
return;
}
const openBtn = e.target.closest('[data-open3d]');
if (openBtn) {
if (e.target.closest('a')) return;
const id = openBtn.dataset.open3d;
modalError.hidden = true;
modalArt.style.display = 'flex';
modalArt.innerHTML = '<div class="loading">Chargement…</div>';
$('#modal-stats').innerHTML = '';
$('#modal-title').textContent = '…';
$('#modal-subtitle').textContent = '';
backdrop.hidden = false;
document.body.style.overflow = 'hidden';
api.get(`/api/characters/${id}`).then((full) => {
$('#modal-title').textContent = full.name;
const sub = [];
if (full.spec) sub.push(specFr(full.spec));
if (full.race) sub.push(full.race);
if (full.faction) sub.push(full.faction);
sub.push(full.realm);
$('#modal-subtitle').textContent = sub.filter(Boolean).join(' · ');
const thumb = (full.media && full.media.main) || full.media?.avatar || full.thumbnail_url || null;
const img = full.media && full.media.main ? thumb : renderImage(thumb);
if (img) {
modalArt.innerHTML = `<img src="${esc(img)}" alt="">`;
} else {
modalArt.innerHTML = '<div class="loading">Image de l\'armurerie non disponible</div>';
}
renderStats(full);
$('#modal-gear').innerHTML = gearHTML(full.gear);
if (full.spec) {
$('#modal-talents').innerHTML = '<div class="loading">Chargement…</div>';
api.get(`/api/characters/${id}/talents?spec=${encodeURIComponent(full.spec)}`).then((t) => {
$('#modal-talents').innerHTML = talentTreeHTML(t);
}).catch(() => {
$('#modal-talents').innerHTML = talentHTML(full);
});
} else {
$('#modal-talents').innerHTML = talentHTML(full);
}
}).catch((err) => {
modalArt.innerHTML = '';
modalError.textContent = `Impossible d'afficher les détails : ${err.message}`;
modalError.hidden = false;
});
return;
}
if (e.target === backdrop) closeModal();
});
function renderImage(thumbnail) {
if (!thumbnail) return null;
return thumbnail.split('?')[0].replace(/-avatar\.jpg/i, '-main-raw.png');
}
const GEAR_ORDER = [
['head', 'Tête'], ['neck', 'Cou'], ['shoulder', 'Épaules'], ['back', 'Dos'], ['chest', 'Torse'],
['wrist', 'Poignets'], ['hands', 'Mains'], ['waist', 'Taille'], ['legs', 'Jambes'], ['feet', 'Pieds'],
['finger1', 'Doigt 1'], ['finger2', 'Doigt 2'], ['trinket1', 'Bijou 1'], ['trinket2', 'Bijou 2'],
['mainhand', 'Main droite'], ['offhand', 'Main gauche']
];
const QUALITY_COLOR = ['#9d9d9d', '#ffffff', '#1eff00', '#0070dd', '#a335ee', '#ff8000', '#e6cc80'];
function gearHTML(gear) {
if (!gear || !gear.length) return '<div class="gear-empty">Aucun équipement disponible (données Raider.io)</div>';
const bySlot = {};
for (const it of gear) bySlot[it.slot_name] = it;
const items = GEAR_ORDER.map(([slot, label]) => bySlot[slot] || { slot, slot_name: slot, label, empty: true });
const main = (it) => it.empty
? `<span class="gear-item gear-empty-item">
<span class="gear-icon"><span class="gear-slot-letter">${esc((it.label || it.slot_name || '?')[0])}</span></span>
<span class="gear-info"><span class="gear-name">${esc(it.label || it.slot_name)}</span><span class="gear-ilvl">Vide</span></span>
</span>`
: `<span class="gear-item" style="--q:${it.quality >= 0 ? QUALITY_COLOR[Math.min(it.quality, QUALITY_COLOR.length - 1)] : 'var(--text)'}">
<span class="gear-icon">${it.icon
? `<img src="https://wow.zamimg.com/images/wow/icons/large/${esc(it.icon)}.jpg" alt="" loading="lazy" onerror="this.style.display='none'">`
: `<span class="gear-slot-letter">${esc((it.name || '?')[0])}</span>`}</span>
<span class="gear-info">
${it.item_id
? `<a class="gear-name" data-wowhead="item=${it.item_id}${it.item_level ? `&ilvl=${it.item_level}` : ''}&domain=fr" href="https://www.wowhead.com/item=${it.item_id}" target="_blank" rel="noopener">${esc(it.name)}</a>`
: `<span class="gear-name">${esc(it.name)}</span>`}
<span class="gear-ilvl">${it.item_level ? `ILVL ${it.item_level}` : ''}</span>
</span>
</span>`;
return items.map(main).join('');
}
const TALENT_BG = {
'Warrior': { Arms: 'warrior-arms', Fury: 'warrior-fury', Protection: 'warrior-protection' },
'Paladin': { Holy: 'paladin-holy', Protection: 'paladin-protection', Retribution: 'paladin-retribution' },
'Hunter': { 'Beast Mastery': 'hunter-beastmastery', Marksmanship: 'hunter-marksmanship', Survival: 'hunter-survival' },
'Rogue': { Assassination: 'rogue-assassination', Outlaw: 'rogue-outlaw', Subtlety: 'rogue-subtlety' },
'Priest': { Discipline: 'priest-discipline', Holy: 'priest-holy', Shadow: 'priest-shadow' },
'Death Knight': { Blood: 'death-knight-blood', Frost: 'death-knight-frost', Unholy: 'death-knight-unholy' },
'Shaman': { Elemental: 'shaman-elemental', Enhancement: 'shaman-enhancement', Restoration: 'shaman-restoration' },
'Mage': { Arcane: 'mage-arcane', Fire: 'mage-fire', Frost: 'mage-frost' },
'Warlock': { Affliction: 'warlock-affliction', Demonology: 'warlock-demonology', Destruction: 'warlock-destruction' },
'Monk': { Brewmaster: 'monk-brewmaster', Mistweaver: 'monk-mistweaver', Windwalker: 'monk-windwalker' },
'Druid': { Balance: 'druid-balance', Feral: 'druid-feral', Guardian: 'druid-guardian', Restoration: 'druid-restoration' },
'Demon Hunter': { Havoc: 'demon-hunter-havoc', Vengeance: 'demon-hunter-vengeance' },
'Evoker': { Devastation: 'evoker-devastation', Preservation: 'evoker-preservation', Augmentation: 'evoker-augmentation' }
};
function talentHTML(full) {
const cls = full.class || '';
const spec = full.spec || '';
const bg = TALENT_BG[cls] && TALENT_BG[cls][spec] ? `talentbg-${TALENT_BG[cls][spec]}.jpg` : null;
return `<div class="talent-panel${bg ? '' : ' no-bg'}">
${bg ? `<img class="talent-bg" src="https://wow.zamimg.com/images/tools/dragonflight-talent-calc/blizzard/${bg}" alt="" loading="lazy">` : ''}
<div class="talent-overlay">
<span class="talent-title">Arbre de talents — ${esc(specFr(spec) || spec || '?')}</span>
<span class="talent-note">Arbre indisponible pour le moment.</span>
</div>
</div>`;
}
function talentTreeHTML(data) {
if (!data || !data.trees) return '<div class="gear-empty">Arbre de talents indisponible</div>';
const selByTalent = { class: {}, spec: {} };
const selected = data.selected || { class: [], spec: [] };
for (const k of ['class', 'spec']) {
for (const t of (selected[k] || [])) {
const arr = selByTalent[k][t.id] || (selByTalent[k][t.id] = []);
arr.push(t.rank || 1);
}
}
const CELL = 58, PAD = 8;
const renderTree = (kind, title) => {
const tree = data.trees[kind];
if (!tree || !tree.nodes || !tree.nodes.length) return '';
const w = (tree.width ?? 12) + 1;
const h = (tree.height ?? 10) + 1;
const nodes = tree.nodes.map((n) => {
const ranks = selByTalent[kind][n.id] || selByTalent[kind][n.talent_id];
const on = ranks && ranks.length;
const letter = esc((n.talent_name || '?')[0]);
const badge = on ? `<span class="tn-rank">${ranks[0]}${n.max_rank > 1 ? '/' + n.max_rank : ''}</span>` : '';
const tip = n.spell_id
? `data-wowhead="spell=${n.spell_id}&domain=fr" title="${esc(n.talent_name || n.spell_name || '')}"`
: (n.talent_name ? `title="${esc(n.talent_name)}"` : '');
return `<span class="tn${on ? ' on' : ''}" style="left:${PAD + n.x * CELL}px;top:${PAD + n.y * CELL}px" ${tip}>
${n.icon ? `<img src="${esc(n.icon.startsWith('http') ? n.icon : `https://wow.zamimg.com/images/wow/icons/large/${esc(n.icon)}.jpg`)}" alt="" loading="lazy" onerror="this.parentElement.classList.add('no-icon');this.remove()">` : ''}
<span class="tn-letter">${letter}</span>
${badge}
</span>`;
}).join('');
return `<div class="tt-block">
<div class="tt-head"><b>${title}</b>${tree.name ? `<span class="muted">${esc(tree.name)}</span>` : ''}</div>
<div class="tt-scroll"><div class="tt-grid" style="width:${PAD * 2 + w * CELL}px;height:${PAD * 2 + h * CELL}px">${nodes}</div></div>
</div>`;
};
const copyBtn = data.loadout_text
? `<button type="button" class="wm-copy" data-copy="${esc(data.loadout_text)}" title="Copier le build (import en jeu)">Copier le build</button>`
: '';
return `<div class="tt-wrap">
<div class="tt-top"><span class="talent-title">Talents — ${esc(specFr(data.spec) || data.spec)}</span>${copyBtn}</div>
${renderTree('class', 'Arbre de classe')}
${renderTree('spec', 'Arbre de spécialisation')}
</div>`;
}
function renderStats(card) {
const stats = [];
stats.push(['ILVL', fmt(card.ilvl || null)]);
stats.push(['Hauts faits', fmt(card.achievement_points || 0)]);
stats.push(['Sets PvP', fmtSet(card.pvp_sets)]);
stats.push(['Sets PvE', fmtSet(card.pve_sets)]);
for (const [label, key] of [['Blitz', 'BLITZ'], ['Solo Shuffle', 'SOLO_SHUFFLE'], ['2v2', 'ARENA_2v2'], ['3v3', 'ARENA_3v3'], ['RBG', 'RATED_BG']]) {
const rating = card.pvp && card.pvp[key] ? card.pvp[key].rating : null;
const best = card.pvp_best && card.pvp_best[key] || null;
const v = rating != null ? `${fmt(rating)}${best != null ? `<span class="stat-max${best > rating ? ' on' : ''}" title="Max XP">/ ${fmt(best)}</span>` : ''}` : '—';
stats.push([label, v]);
}
$('#modal-stats').innerHTML = stats
.map(([l, v]) => `<span class="stat-chip">${l} <b>${v}</b></span>`)
.join('');
}
$('#modal-close').addEventListener('click', closeModal);
$('#class-modal-close').addEventListener('click', closeClassModal);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!backdrop.hidden) closeModal();
if (!$('#class-modal-backdrop').hidden) closeClassModal();
}
});
function initStarfield() {
const field = $('#starfield');
if (!field) return;
const count = Math.max(26, Math.min(70, Math.round((window.innerWidth * window.innerHeight) / 34000)));
const color = '#f5a623';
let html = '';
for (let i = 0; i < count; i++) {
const size = (Math.random() * 7 + 3.5).toFixed(2);
const left = (Math.random() * 100).toFixed(2);
const fall = (Math.random() * 16 + 12).toFixed(2);
const sway = (Math.random() * 4 + 3.5).toFixed(2);
const delay = (-Math.random() * 30).toFixed(2);
const peak = (Math.random() * 0.25 + 0.12).toFixed(2);
html += `<span class="star" style="left:${left}%;width:${size}px;height:${size}px;background:${color};box-shadow:0 0 ${(parseFloat(size) * 2.2).toFixed(1)}px ${color};animation-duration:${fall}s, ${sway}s;animation-delay:${delay}s, ${delay}s;--peak:${peak}"></span>`;
}
field.innerHTML = html;
}
initStarfield();
load();
})();
+100
View File
@@ -0,0 +1,100 @@
window.model3d = (function () {
const CONTENT_PATH = '/mv/';
let libsLoaded = false;
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.onload = resolve;
s.onerror = () => reject(new Error('Impossible de charger ' + src));
document.head.appendChild(s);
});
}
async function ensureLibs() {
if (libsLoaded && window.jQuery && window.ZamModelViewer) return;
if (!window.jQuery) await loadScript('https://code.jquery.com/jquery-3.7.1.min.js');
if (!window.ZamModelViewer) await loadScript(CONTENT_PATH + 'viewer/viewer.min.js');
if (!window.jQuery) throw new Error('jQuery indisponible');
if (!window.ZamModelViewer) throw new Error('Moteur 3D indisponible');
if (!window.WH) window.WH = {};
window.WH.defaultAnimation = 'Stand';
if (!window.WH.debug) window.WH.debug = function () {};
if (!window.WH.WebP) {
window.WH.WebP = {
getImageExtension: function () { return '.webp'; }
};
}
libsLoaded = true;
}
async function loadCharacterOptions(race, gender) {
const id = race * 2 - 1 + gender;
const res = await fetch(`${CONTENT_PATH}meta/charactercustomization/${id}.json`);
if (!res.ok) return { Options: [] };
return res.json();
}
function buildOptions(model, fullOptions) {
const map = {
'Face': model.appearance && model.appearance.face,
'Skin Color': model.appearance && model.appearance.skin_color,
'Hair Style': model.appearance && model.appearance.hair_variation,
'Hair Color': model.appearance && model.appearance.hair_color,
'Facial Hair': model.appearance && model.appearance.facial_hair
};
const hasAppearance = Object.values(map).some((v) => v !== null && v !== undefined && v !== false);
const options = [];
if (!hasAppearance) return options;
for (const part of fullOptions.Options || []) {
const val = map[part.Name];
if (val === null || val === undefined || val === false) continue;
const choice = part.Choices && (part.Choices[val] || part.Choices.find((c) => String(c.Id) === String(val)));
if (choice) options.push({ optionId: part.Id, choiceId: choice.Id });
}
return options;
}
async function render(container, model) {
await ensureLibs();
const $c = jQuery(container);
$c.empty();
const viewerOptions = {
type: 2,
contentPath: CONTENT_PATH,
container: $c,
aspect: 1,
models: { id: model.race * 2 - 1 + model.gender, type: 16 },
items: model.items || [],
hd: true
};
let viewer = null;
try {
const options = buildOptions(model, await loadCharacterOptions(model.race, model.gender));
if (options.length) viewerOptions.charCustomization = { options };
viewer = await new window.ZamModelViewer(viewerOptions);
} catch (e) {
viewer = null;
}
if (!viewer) {
const options = buildOptions(model, { Options: [] });
viewer = await new window.ZamModelViewer(Object.assign({}, viewerOptions, { charCustomization: { options } }));
}
try { viewer.renderer.distance = 2.1; } catch (e) {}
try {
const actor = viewer.renderer.actors && viewer.renderer.actors[0];
if (actor && actor.setAnimation) actor.setAnimation('Stand');
} catch (e) {}
return viewer;
}
function dispose(viewer) {
try { viewer && viewer.dispose && viewer.dispose(); } catch (e) {}
}
return { render, dispose, ensureLibs };
})();
+18
View File
@@ -0,0 +1,18 @@
{
"name": "Wycz - World of Warcraft",
"short_name": "Wycz",
"description": "Objectifs de saison, classes et cotes PvP de Wycz.",
"lang": "fr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0c0e14",
"theme_color": "#0c0e14",
"icons": [
{ "src": "/images/icons/icon-96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/images/icons/icon-180.png", "sizes": "180x180", "type": "image/png" },
{ "src": "/images/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/images/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+68
View File
@@ -0,0 +1,68 @@
const CACHE = 'wycz-v1';
const CORE = [
'/',
'/css/style.css',
'/js/app.js',
'/manifest.webmanifest',
'/images/logo.png',
'/images/icons/icon-96.png',
'/images/icons/icon-192.png',
'/images/icons/icon-512.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE)
.then((cache) => cache.addAll(CORE))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith('/api/')) {
event.respondWith(fetch(req));
return;
}
if (req.mode === 'navigate') {
event.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put('/', copy));
return res;
})
.catch(() => caches.match('/'))
);
return;
}
event.respondWith(
caches.match(req).then((cached) => {
const network = fetch(req)
.then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return res;
})
.catch(() => cached);
return cached || network;
})
);
});