(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
? `
`
: `${esc(cls.fr[0])}`;
return `${img}${done ? '✓' : ''}`;
}).join('');
return `
${items}
`;
}
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 ``;
}
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
? `
`
: (info.logo ? `
` : '');
const tier = tierFor(b.rating);
return `
${label}
${tier ? `${tier.name}` : ''}
${avatar}
${esc(info.fr)}${spec ? ` · ${esc(spec)}` : ''}
${esc(b.name || '')}
${fmt(b.rating)}
`;
}).join('');
return `
Meilleure classe & spé par bracket
${rows}
`;
}
function overviewCard(g) {
const manualBadge = g.manual && g.key !== 'achievements' ? 'manuel' : '';
const done = !!g.achieved;
const doneBadge = done ? '✓ Atteint' : '';
let value;
let sub;
let progress = 0;
let badges = '';
if (g.key === 'class90' || g.key === 'ss2300') {
value = `${fmt(g.covered)} / ${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 ? 'Obtenu' : `${fmt(w)} / ${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 = `${fmt(g.points)} / ${fmt(g.total)}`;
sub = `${pc}% · Points de hauts faits`;
progress = g.total > 0 ? Math.min(1, g.points / g.total) : 0;
}
return `
${esc(g.title)}
${manualBadge}
${doneBadge}
${value}
${sub ? `
${esc(sub)}
` : ''}
${badges}
`;
}
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 `
${label}
${tier ? `${tier.name}` : 'Non classé'}
${b.isRecord ? '★ Record' : ''}
${current ? fmt(current) : '—'}
${max != null ? `/${fmt(max)}` : ''}
${current != null ? `Côte actuelle · ` : ''}${max != null ? `Max XP ${fmt(max)}` : (current != null ? 'Aucun max enregistré' : 'Aucune côte')}
${fmt(b.won)}V · ${fmt(b.lost)}D${wr != null ? ` ${wr}%` : ''}
${total ? `${fmt(total)} games` : 'Aucune game cette saison'}
`;
};
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 `${PANELS.map(([label, color, b]) => brkBlock(label, color, b)).join('')}
`;
}
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) => `
`).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 = `
`;
}
return `
${specPanels}
${frozen ? `
})
` : `
${esc(c.name[0])}`}
${metaText}${ilvl != null ? `ILVL ${fmt(ilvl)}` : ''}
${classTitleEntries(info, classTitles)}
${detail}
`;
}
function titleTags(cls, titles) {
const parts = [];
if ((titles.glad || []).includes(cls.name)) parts.push('
');
if ((titles.legend || []).includes(cls.name)) parts.push('
');
if (!parts.length) return '';
return `${parts.join('')}`;
}
function classTitleEntries(cls, titles) {
const items = [];
for (const e of (titles.glad_entries || [])) {
if (e.class === cls.name) items.push(`
Gladiateur${e.season ? esc(e.season) : ''}`);
}
for (const e of (titles.legend_entries || [])) {
if (e.class === cls.name) items.push(`
Légende${e.season ? esc(e.season) : ''}`);
}
if (!items.length) {
if ((titles.glad || []).includes(cls.name)) items.push(`
Gladiateur${titles.glad_season ? esc(titles.glad_season) : ''}`);
if ((titles.legend || []).includes(cls.name)) items.push(`
Légende${titles.legend_season ? esc(titles.legend_season) : ''}`);
}
return items.join('');
}
function renderModalChars() {
if (!modalChars.length) {
$('#class-modal-chars').innerHTML = 'Aucun personnage pour cette classe
';
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 = 'Équipement indisponible
';
});
}
if (talentsEl) {
talentsEl.innerHTML = 'Chargement…
';
api.get(`/api/characters/${c.id}/talents?spec=${encodeURIComponent(sel)}`).then((t) => {
if (talentsEl) talentsEl.innerHTML = talentTreeHTML(t);
}).catch(() => {
if (talentsEl) talentsEl.innerHTML = 'Détails indisponibles
';
});
}
}
}
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) => `
`).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 = `Erreur de chargement : ${esc(e.message)}
`;
}
}
async function updateLiveStatus(statusEl, twitchUrl) {
const channel = twitchUrl.replace(/\/+$/, '').split('/').pop() || 'wycz';
const safeChannel = encodeURIComponent(channel);
const link = ``;
const twitchLogo = `${SOCIAL_SVG.twitch}`;
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}`;
} catch {
statusEl.innerHTML = `${link}${twitchLogo}`;
}
}
const SOCIAL_SVG = {
twitch: '',
youtube: '',
tiktok: ''
};
function renderSocials(el, socials) {
const buttons = [];
if (socials.youtube) {
buttons.push(`${SOCIAL_SVG.youtube}`);
}
if (socials.tiktok) {
buttons.push(`${SOCIAL_SVG.tiktok}`);
}
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 = 'Chargement…
';
$('#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 = `
`;
} else {
modalArt.innerHTML = 'Image de l\'armurerie non disponible
';
}
renderStats(full);
$('#modal-gear').innerHTML = gearHTML(full.gear);
if (full.spec) {
$('#modal-talents').innerHTML = 'Chargement…
';
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 'Aucun équipement disponible (données Raider.io)
';
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
? `
${esc((it.label || it.slot_name || '?')[0])}
${esc(it.label || it.slot_name)}Vide
`
: `
${it.icon
? `
`
: `${esc((it.name || '?')[0])}`}
${it.item_id
? `${esc(it.name)}`
: `${esc(it.name)}`}
${it.item_level ? `ILVL ${it.item_level}` : ''}
`;
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 `
${bg ? `

` : ''}
Arbre de talents — ${esc(specFr(spec) || spec || '?')}
Arbre indisponible pour le moment.
`;
}
function talentTreeHTML(data) {
if (!data || !data.trees) return 'Arbre de talents indisponible
';
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 ? `${ranks[0]}${n.max_rank > 1 ? '/' + n.max_rank : ''}` : '';
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 `
${n.icon ? `
` : ''}
${letter}
${badge}
`;
}).join('');
return `
${title}${tree.name ? `${esc(tree.name)}` : ''}
`;
};
const copyBtn = data.loadout_text
? ``
: '';
return `
Talents — ${esc(specFr(data.spec) || data.spec)}${copyBtn}
${renderTree('class', 'Arbre de classe')}
${renderTree('spec', 'Arbre de spécialisation')}
`;
}
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 ? `/ ${fmt(best)}` : ''}` : '—';
stats.push([label, v]);
}
$('#modal-stats').innerHTML = stats
.map(([l, v]) => `${l} ${v}`)
.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 += ``;
}
field.innerHTML = html;
}
initStarfield();
load();
})();