Files

677 lines
30 KiB
JavaScript

(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();
})();