677 lines
30 KiB
JavaScript
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
|
|
const FR_CLASS_TO_EN = {
|
|
'Guerrier': 'Warrior', 'Paladin': 'Paladin', 'Chasseur': 'Hunter', 'Voleur': 'Rogue',
|
|
'Prêtre': 'Priest', 'Chevalier de la mort': 'Death Knight', 'Chaman': 'Shaman', 'Mage': 'Mage',
|
|
'Démoniste': 'Warlock', 'Moine': 'Monk', 'Druide': 'Druid', 'Chasseur de démons': 'Demon Hunter',
|
|
'Évocateur': 'Evoker'
|
|
};
|
|
const PVP_GENERAL = [['ARENA_2v2', '2v2'], ['ARENA_3v3', '3v3'], ['RATED_BG', 'RBG']];
|
|
const PVP_PER_SPEC = [['SOLO_SHUFFLE', 'Solo Shuffle'], ['BLITZ', 'Blitz']];
|
|
const SPECS_BY_CLASS = {
|
|
'Death Knight': [
|
|
{ key: 'Blood', fr: 'Sang', icon: 'spell_deathknight_bloodpresence.jpg' },
|
|
{ key: 'Frost', fr: 'Givre', icon: 'spell_deathknight_frostpresence.jpg' },
|
|
{ key: 'Unholy', fr: 'Impie', icon: 'spell_deathknight_unholypresence.jpg' }
|
|
],
|
|
'Demon Hunter': [
|
|
{ key: 'Havoc', fr: 'Dévastation', icon: 'ability_demonhunter_specdps.jpg' },
|
|
{ key: 'Vengeance', fr: 'Vengeance', icon: 'ability_demonhunter_spectank.jpg' },
|
|
{ key: 'Devoration', fr: 'Dévoration', icon: 'classicon_demonhunter_void.jpg' }
|
|
],
|
|
'Druid': [
|
|
{ key: 'Balance', fr: 'Équilibre', icon: 'spell_nature_starfall.jpg' },
|
|
{ key: 'Feral', fr: 'Farouche', icon: 'ability_druid_catform.jpg' },
|
|
{ key: 'Guardian', fr: 'Gardien', icon: 'ability_racial_bearform.jpg' },
|
|
{ key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_healingtouch.jpg' }
|
|
],
|
|
'Evoker': [
|
|
{ key: 'Devastation', fr: 'Dévastation', icon: 'classicon_evoker_devastation.jpg' },
|
|
{ key: 'Preservation', fr: 'Préservation', icon: 'classicon_evoker_preservation.jpg' },
|
|
{ key: 'Augmentation', fr: 'Augmentation', icon: 'classicon_evoker_augmentation.jpg' }
|
|
],
|
|
'Hunter': [
|
|
{ key: 'Beast Mastery', fr: 'Maîtrise des bêtes', icon: 'ability_hunter_bestialdiscipline.jpg' },
|
|
{ key: 'Marksmanship', fr: 'Précision', icon: 'ability_hunter_focusedaim.jpg' },
|
|
{ key: 'Survival', fr: 'Survie', icon: 'ability_hunter_camouflage.jpg' }
|
|
],
|
|
'Mage': [
|
|
{ key: 'Arcane', fr: 'Arcanes', icon: 'spell_nature_magicimmunity.jpg' },
|
|
{ key: 'Fire', fr: 'Feu', icon: 'spell_fire_firebolt02.jpg' },
|
|
{ key: 'Frost', fr: 'Givre', icon: 'spell_frost_frostbolt02.jpg' }
|
|
],
|
|
'Monk': [
|
|
{ key: 'Brewmaster', fr: 'Maître brasseur', icon: 'spell_monk_brewmaster_spec.jpg' },
|
|
{ key: 'Mistweaver', fr: 'Tisse-brume', icon: 'spell_monk_mistweaver_spec.jpg' },
|
|
{ key: 'Windwalker', fr: 'Marche-vent', icon: 'spell_monk_windwalker_spec.jpg' }
|
|
],
|
|
'Paladin': [
|
|
{ key: 'Holy', fr: 'Sacré', icon: 'spell_holy_holybolt.jpg' },
|
|
{ key: 'Protection', fr: 'Protection', icon: 'ability_paladin_shieldofthetemplar.jpg' },
|
|
{ key: 'Retribution', fr: 'Vindicte', icon: 'spell_holy_auraoflight.jpg' }
|
|
],
|
|
'Priest': [
|
|
{ key: 'Discipline', fr: 'Discipline', icon: 'spell_holy_powerwordshield.jpg' },
|
|
{ key: 'Holy', fr: 'Sacré', icon: 'spell_holy_guardianspirit.jpg' },
|
|
{ key: 'Shadow', fr: 'Ombre', icon: 'spell_shadow_shadowwordpain.jpg' }
|
|
],
|
|
'Rogue': [
|
|
{ key: 'Assassination', fr: 'Assassinat', icon: 'ability_rogue_deadlybrew.jpg' },
|
|
{ key: 'Outlaw', fr: 'Hors-la-loi', icon: 'ability_rogue_waylay.jpg' },
|
|
{ key: 'Subtlety', fr: 'Finesse', icon: 'ability_stealth.jpg' }
|
|
],
|
|
'Shaman': [
|
|
{ key: 'Elemental', fr: 'Élémentaire', icon: 'spell_nature_lightning.jpg' },
|
|
{ key: 'Enhancement', fr: 'Amélioration', icon: 'spell_shaman_improvedstormstrike.jpg' },
|
|
{ key: 'Restoration', fr: 'Restauration', icon: 'spell_nature_healingtouch.jpg' }
|
|
],
|
|
'Warlock': [
|
|
{ key: 'Affliction', fr: 'Affliction', icon: 'spell_shadow_deathcoil.jpg' },
|
|
{ key: 'Demonology', fr: 'Démonologie', icon: 'spell_shadow_metamorphosis.jpg' },
|
|
{ key: 'Destruction', fr: 'Destruction', icon: 'spell_shadow_rainoffire.jpg' }
|
|
],
|
|
'Warrior': [
|
|
{ key: 'Arms', fr: 'Armes', icon: 'ability_warrior_innerrage.jpg' },
|
|
{ key: 'Fury', fr: 'Fureur', icon: 'ability_warrior_savageblow.jpg' },
|
|
{ key: 'Protection', fr: 'Protection', icon: 'ability_warrior_defensivestance.jpg' }
|
|
]
|
|
};
|
|
const ADMIN_CLASSES = [
|
|
{ name: 'Warrior', fr: 'Guerrier', logo: 'War.png' },
|
|
{ name: 'Paladin', fr: 'Paladin', logo: 'Pal.png' },
|
|
{ name: 'Hunter', fr: 'Chasseur', logo: 'Hunt.png' },
|
|
{ name: 'Rogue', fr: 'Voleur', logo: 'Rogue.png' },
|
|
{ name: 'Priest', fr: 'Prêtre', logo: 'Priest.png' },
|
|
{ name: 'Death Knight', fr: 'Chevalier de la mort', logo: 'DK.png' },
|
|
{ name: 'Shaman', fr: 'Chaman', logo: 'Sham.png' },
|
|
{ name: 'Mage', fr: 'Mage', logo: 'Mage.png' },
|
|
{ name: 'Warlock', fr: 'Démoniste', logo: 'Lock.png' },
|
|
{ name: 'Monk', fr: 'Moine', logo: 'monk.png' },
|
|
{ name: 'Druid', fr: 'Druide', logo: 'Druid.png' },
|
|
{ name: 'Demon Hunter', fr: 'Chasseur de démons', logo: 'dh.png' },
|
|
{ name: 'Evoker', fr: 'Évocateur', logo: 'evoker.png' }
|
|
];
|
|
let pvpEditId = null;
|
|
|
|
let state = { logged: false };
|
|
|
|
async function checkLogin() {
|
|
try {
|
|
await api.get('/api/admin/me');
|
|
state.logged = true;
|
|
} catch {
|
|
state.logged = false;
|
|
}
|
|
render();
|
|
}
|
|
|
|
function render() {
|
|
$('#login-view').hidden = state.logged;
|
|
$('#admin-view').hidden = !state.logged;
|
|
$('#logout-btn').hidden = !state.logged;
|
|
if (state.logged) {
|
|
loadChars();
|
|
loadGoals();
|
|
loadSettings();
|
|
loadSessions();
|
|
loadLogs();
|
|
}
|
|
}
|
|
|
|
const SESSION_BRACKETS = [['SOLO_SHUFFLE', 'Solo Shuffle'], ['BLITZ', 'Blitz'], ['ARENA_2v2', '2v2'], ['ARENA_3v3', '3v3'], ['RATED_BG', 'RBG']];
|
|
|
|
async function loadSessions() {
|
|
const grid = $('#session-grid');
|
|
if (!grid) return;
|
|
try {
|
|
const data = await api.get('/api/admin/sessions');
|
|
const today = {};
|
|
const week = {};
|
|
(data.today || []).forEach((x) => { today[x.bracket] = x; });
|
|
(data.week || []).forEach((x) => { week[x.bracket] = x; });
|
|
grid.innerHTML = SESSION_BRACKETS.map(([key, label]) => {
|
|
const t = today[key] || { wins: 0, losses: 0 };
|
|
const w = week[key] || { wins: 0, losses: 0 };
|
|
const total = (t.wins || 0) + (t.losses || 0);
|
|
const winrate = total > 0 ? `${Math.round((t.wins / total) * 100)}%` : '—';
|
|
return `<div class="session-card">
|
|
<div class="session-head">${label}
|
|
<span class="session-today">${t.wins}V · ${t.losses}D <em>${winrate}</em></span>
|
|
</div>
|
|
<div class="session-btns">
|
|
<button class="btn session-win" data-bracket="${key}" data-won="1">Victoire</button>
|
|
<button class="btn session-loss" data-bracket="${key}" data-won="0">Défaite</button>
|
|
</div>
|
|
<div class="session-week hint">7 derniers jours : ${w.wins}V · ${w.losses}D</div>
|
|
</div>`;
|
|
}).join('');
|
|
grid.querySelectorAll('.session-win, .session-loss').forEach((b) => {
|
|
b.addEventListener('click', async () => {
|
|
await api.post('/api/admin/sessions', { bracket: b.dataset.bracket, won: Number(b.dataset.won) });
|
|
loadSessions();
|
|
});
|
|
});
|
|
$('#session-undo').hidden = !(data.last && data.last.length);
|
|
} catch {
|
|
grid.innerHTML = '<p class="hint">Impossible de charger le suivi de session.</p>';
|
|
}
|
|
}
|
|
|
|
async function sessionUndo() {
|
|
try {
|
|
await api.post('/api/admin/sessions/undo');
|
|
loadSessions();
|
|
} catch {}
|
|
}
|
|
|
|
$('#login-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
$('#login-error').hidden = true;
|
|
try {
|
|
await api.post('/api/admin/login', { password: $('#login-password').value });
|
|
state.logged = true;
|
|
$('#login-password').value = '';
|
|
render();
|
|
} catch (err) {
|
|
$('#login-error').textContent = err.message;
|
|
$('#login-error').hidden = false;
|
|
}
|
|
});
|
|
|
|
$('#logout-btn').addEventListener('click', async () => {
|
|
await api.post('/api/admin/logout');
|
|
state.logged = false;
|
|
render();
|
|
});
|
|
|
|
// Tabs
|
|
$$('.tab-btn').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
$$('.tab-btn').forEach((b) => b.classList.remove('active'));
|
|
$$('.tab-panel').forEach((p) => p.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
$(`#tab-${btn.dataset.tab}`).classList.add('active');
|
|
});
|
|
});
|
|
|
|
// ---- Characters ----
|
|
async function loadChars() {
|
|
const chars = await api.get('/api/admin/characters');
|
|
window.__adminChars = chars;
|
|
$('#chars-tbody').innerHTML = chars.map((c) => `
|
|
<tr data-id="${c.id}">
|
|
<td class="char-cell-name"><b>${esc(c.name)}</b>${c.note ? `<br><span class="hint">${esc(c.note)}</span>` : ''}</td>
|
|
<td>
|
|
<select class="mini-select" data-field="class">
|
|
<option value="">—</option>
|
|
${Object.entries(FR_CLASS_TO_EN).map(([fr, en]) => `<option value="${en}" ${c.class === en ? 'selected' : ''}>${fr}</option>`).join('')}
|
|
</select>
|
|
</td>
|
|
<td><input type="text" class="mini-num" data-field="level" value="${c.level ?? ''}" placeholder="—"></td>
|
|
<td>${esc(c.realm)} <span class="hint">(${c.region})</span></td>
|
|
<td><input type="text" class="mini-url" data-field="check_pvp_url" value="${esc(c.check_pvp_url ?? '')}" placeholder="https://check-pvp.fr/eu/…"></td>
|
|
<td><input type="text" class="mini-num" data-field="pvp_sets" value="${esc(c.pvp_sets ?? '')}" placeholder="0/4"></td>
|
|
<td><input type="text" class="mini-num" data-field="pve_sets" value="${esc(c.pve_sets ?? '')}" placeholder="0/4"></td>
|
|
<td><button class="btn mini-btn" data-action="pvp">Ratings</button></td>
|
|
<td>
|
|
<div class="admin-titles">
|
|
${(c.titles || []).map((t) => `
|
|
<span class="at-entry at-${t.title}">
|
|
<img src="/images/titles/${t.title === 'glad' ? 'gladiateur.jpg' : 'legende.webp'}" alt="${t.title === 'glad' ? 'Gladiateur' : 'Légende'}">
|
|
<b>${t.title === 'glad' ? 'Glad' : 'Leg'}</b>
|
|
${t.season ? `<span class="hint at-season-label">${esc(t.season)}</span>` : ''}
|
|
<button type="button" class="at-del" data-action="del-title" data-title-id="${t.id}" title="Retirer le titre">×</button>
|
|
</span>`).join('') || '<span class="hint">Aucun</span>'}
|
|
<span class="at-add">
|
|
<select class="mini-select" data-title-kind title="Type de titre">
|
|
<option value="glad">Gladiateur</option>
|
|
<option value="legend">Légende</option>
|
|
</select>
|
|
<input type="text" class="mini-num at-season" data-title-season placeholder="Saison" title="Saison du titre">
|
|
<button type="button" class="btn mini-btn" data-action="add-title" title="Ajouter le titre">+</button>
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td><input type="checkbox" data-field="active" ${c.active ? 'checked' : ''}></td>
|
|
<td class="hint">${esc(fmtDate(c.last_updated))}</td>
|
|
<td class="table-actions">
|
|
<button class="btn mini-btn" data-action="refresh">Maj</button>
|
|
<button class="del-btn" data-action="delete">Suppr</button>
|
|
</td>
|
|
</tr>`).join('');
|
|
}
|
|
|
|
$('#add-char-btn').addEventListener('click', () => {
|
|
$('#add-char-form').classList.toggle('hidden');
|
|
});
|
|
|
|
$('#nc-save').addEventListener('click', async () => {
|
|
try {
|
|
await api.post('/api/admin/characters', {
|
|
region: $('#nc-region').value,
|
|
realm: $('#nc-realm').value,
|
|
name: $('#nc-name').value,
|
|
realm_slug: $('#nc-realm-slug').value || null,
|
|
class: FR_CLASS_TO_EN[$('#nc-class').value] || null,
|
|
note: $('#nc-note').value
|
|
});
|
|
$('#nc-name').value = ''; $('#nc-realm').value = ''; $('#nc-realm-slug').value = ''; $('#nc-class').value = ''; $('#nc-note').value = '';
|
|
$('#add-char-form').classList.add('hidden');
|
|
await loadChars();
|
|
await refreshAll();
|
|
} catch (e) {
|
|
alert(e.message);
|
|
}
|
|
});
|
|
|
|
$('#chars-tbody').addEventListener('change', async (e) => {
|
|
const tr = e.target.closest('tr');
|
|
if (!tr) return;
|
|
if (e.target.matches('[data-field="active"]')) {
|
|
await api.put(`/api/admin/characters/${tr.dataset.id}`, { active: e.target.checked ? 1 : 0 });
|
|
} else if (e.target.matches('[data-field="class"]')) {
|
|
await api.put(`/api/admin/characters/${tr.dataset.id}`, { class: e.target.value || null });
|
|
} else if (e.target.matches('[data-field="level"]')) {
|
|
const v = e.target.value.trim();
|
|
await api.put(`/api/admin/characters/${tr.dataset.id}`, { level: v === '' ? null : Number(v) });
|
|
} else if (e.target.matches('[data-field="check_pvp_url"]')) {
|
|
await api.put(`/api/admin/characters/${tr.dataset.id}`, { check_pvp_url: e.target.value });
|
|
} else if (e.target.matches('[data-field="pvp_sets"]')) {
|
|
await api.put(`/api/admin/characters/${tr.dataset.id}`, { pvp_sets: e.target.value });
|
|
} else if (e.target.matches('[data-field="pve_sets"]')) {
|
|
await api.put(`/api/admin/characters/${tr.dataset.id}`, { pve_sets: e.target.value });
|
|
}
|
|
});
|
|
|
|
$('#chars-tbody').addEventListener('click', async (e) => {
|
|
const btn = e.target.closest('[data-action]');
|
|
if (!btn) return;
|
|
const tr = btn.closest('tr');
|
|
const id = tr.dataset.id;
|
|
if (btn.dataset.action === 'delete') {
|
|
if (!confirm('Supprimer ce personnage ?')) return;
|
|
await api.del(`/api/admin/characters/${id}`);
|
|
await loadChars();
|
|
} else if (btn.dataset.action === 'refresh') {
|
|
btn.textContent = '…';
|
|
await api.post('/api/admin/refresh', { id });
|
|
btn.textContent = 'Maj';
|
|
await loadChars();
|
|
await loadGoals();
|
|
await loadLogs();
|
|
} else if (btn.dataset.action === 'pvp') {
|
|
openPvpModal(id, tr.querySelector('.char-cell-name')?.textContent || '');
|
|
} else if (btn.dataset.action === 'add-title') {
|
|
const kind = tr.querySelector('[data-title-kind]')?.value || 'glad';
|
|
const season = (tr.querySelector('[data-title-season]')?.value || '').trim();
|
|
await api.post(`/api/admin/characters/${id}/titles`, { title: kind, season });
|
|
await loadChars();
|
|
} else if (btn.dataset.action === 'del-title') {
|
|
if (!confirm('Retirer ce titre ?')) return;
|
|
await api.del(`/api/admin/characters/${id}/titles/${btn.dataset.titleId}`);
|
|
await loadChars();
|
|
}
|
|
});
|
|
|
|
// ---- Ratings PvP modal ----
|
|
function openPvpModal(id, name) {
|
|
const chars = window.__adminChars || [];
|
|
const c = chars.find((x) => String(x.id) === String(id));
|
|
pvpEditId = id;
|
|
$('#pvp-modal-title').textContent = `Ratings PvP — ${name}`;
|
|
let pm = {};
|
|
try { pm = c && c.pvp_manual ? JSON.parse(c.pvp_manual || '{}') : {}; } catch {}
|
|
const classSpecs = (c && SPECS_BY_CLASS[c.class]) || [];
|
|
const cell = (spec, key, slot, val) => {
|
|
const ds = spec ? `data-spec="${spec}" ` : '';
|
|
return `<input type="number" class="mini-num" ${ds}data-bracket="${key}" data-slot="${slot}" value="${val ?? ''}" min="0">`;
|
|
};
|
|
$('#pvp-general-body').innerHTML = PVP_GENERAL.map(([key, label]) => `
|
|
<tr>
|
|
<td><b>${label}</b></td>
|
|
<td>${cell(null, key, 'season', pm[key]?.season)}</td>
|
|
<td>${cell(null, key, 'max', pm[key]?.max)}</td>
|
|
<td>${cell(null, key, 'won', pm[key]?.won)}</td>
|
|
<td>${cell(null, key, 'lost', pm[key]?.lost)}</td>
|
|
</tr>`).join('');
|
|
$('#pvp-specs').innerHTML = classSpecs.map((s) => {
|
|
const sp = (pm.specs && pm.specs[s.key]) || {};
|
|
return `
|
|
<div class="pvp-section">
|
|
<h4>${esc(s.fr)}</h4>
|
|
<table class="admin-table">
|
|
<thead><tr><th>Bracket</th><th>Cette saison</th><th>Max (xp)</th><th>Victoires</th><th>Défaites</th></tr></thead>
|
|
<tbody>${PVP_PER_SPEC.map(([key, label]) => `
|
|
<tr>
|
|
<td><b>${label}</b></td>
|
|
<td>${cell(s.key, key, 'season', sp[key]?.season)}</td>
|
|
<td>${cell(s.key, key, 'max', sp[key]?.max)}</td>
|
|
<td>${cell(s.key, key, 'won', sp[key]?.won)}</td>
|
|
<td>${cell(s.key, key, 'lost', sp[key]?.lost)}</td>
|
|
</tr>`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>`;
|
|
}).join('');
|
|
$('#pvp-modal-backdrop').hidden = false;
|
|
$('#pvp-save-msg').textContent = '';
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
$('#pvp-save').addEventListener('click', async () => {
|
|
const out = { specs: {} };
|
|
const apply = (input) => {
|
|
const key = input.dataset.bracket;
|
|
const slot = input.dataset.slot;
|
|
const v = input.value.trim();
|
|
return { key, slot, v: v === '' ? 0 : Number(v) };
|
|
};
|
|
for (const input of $$('#pvp-general-body input')) {
|
|
const { key, slot, v } = apply(input);
|
|
if (!out[key]) out[key] = {};
|
|
out[key][slot] = v;
|
|
}
|
|
for (const input of $$('#pvp-specs input')) {
|
|
const spec = input.dataset.spec;
|
|
const { key, slot, v } = apply(input);
|
|
if (!out.specs[spec]) out.specs[spec] = {};
|
|
if (!out.specs[spec][key]) out.specs[spec][key] = {};
|
|
out.specs[spec][key][slot] = v;
|
|
}
|
|
try {
|
|
await api.put(`/api/admin/characters/${pvpEditId}`, { pvp_manual: JSON.stringify(out) });
|
|
$('#pvp-save-msg').textContent = 'Enregistré.';
|
|
$('#pvp-save-msg').style.color = 'var(--green)';
|
|
setTimeout(() => closePvpModal(), 600);
|
|
} catch (err) {
|
|
$('#pvp-save-msg').textContent = err.message;
|
|
$('#pvp-save-msg').style.color = 'var(--red)';
|
|
}
|
|
});
|
|
|
|
function closePvpModal() {
|
|
$('#pvp-modal-backdrop').hidden = true;
|
|
document.body.style.overflow = '';
|
|
}
|
|
$('#pvp-modal-close').addEventListener('click', closePvpModal);
|
|
$('#pvp-modal-backdrop').addEventListener('click', (e) => { if (e.target === e.currentTarget) closePvpModal(); });
|
|
|
|
// ---- Goals ----
|
|
async function loadGoals() {
|
|
const goals = await api.get('/api/admin/goals');
|
|
$('#admin-goals').innerHTML = goals.map((g) => `
|
|
<div class="goal-card" data-key="${esc(g.key)}">
|
|
<div class="goal-top">
|
|
<h3>${esc(g.title)}</h3>
|
|
<label class="badge ok" style="cursor:pointer"><input type="checkbox" data-gfield="enabled" ${g.enabled ? 'checked' : ''}> actif</label>
|
|
</div>
|
|
<div>
|
|
<label>Cible</label>
|
|
<input type="number" data-gfield="target" value="${g.target ?? ''}" placeholder="(max)">
|
|
</div>
|
|
<div>
|
|
<label>Barre max (affichage)</label>
|
|
<input type="number" data-gfield="display_max" value="${g.display_max ?? ''}">
|
|
</div>
|
|
<div>
|
|
<label>Unité</label>
|
|
<input type="text" data-gfield="unit" value="${esc(g.unit || '')}">
|
|
</div>
|
|
<div>
|
|
<label>Description</label>
|
|
<input type="text" data-gfield="description" value="${esc(g.description || '')}">
|
|
</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
$('#admin-goals').addEventListener('change', async (e) => {
|
|
const field = e.target.dataset.gfield;
|
|
if (!field) return;
|
|
const card = e.target.closest('.goal-card');
|
|
const key = card.dataset.key;
|
|
let value;
|
|
if (field === 'enabled') value = e.target.checked ? 1 : 0;
|
|
else if (field === 'target' || field === 'display_max') value = e.target.value === '' ? null : Number(e.target.value);
|
|
else value = e.target.value;
|
|
try {
|
|
await api.put(`/api/admin/goals/${key}`, { [field]: value });
|
|
} catch (err) {
|
|
alert(err.message);
|
|
}
|
|
});
|
|
|
|
// ---- Settings ----
|
|
function renderClassSelect(el, selected, onSave) {
|
|
const sel = new Set((selected || '').split(',').map((s) => s.trim()).filter(Boolean));
|
|
el.innerHTML = ADMIN_CLASSES.map((c) => `
|
|
<label class="class-chip${sel.has(c.name) ? ' on' : ''}" data-name="${c.name}">
|
|
<img src="/images/classes/${c.logo}" alt="${esc(c.fr)}" loading="lazy">
|
|
<span>${esc(c.fr)}</span>
|
|
</label>`).join('');
|
|
el.querySelectorAll('.class-chip').forEach((chip) => {
|
|
chip.addEventListener('click', () => {
|
|
const name = chip.dataset.name;
|
|
if (chip.classList.toggle('on')) sel.add(name); else sel.delete(name);
|
|
onSave([...sel].join(','));
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderSingleClassSelect(el, selected, onSave) {
|
|
let current = (selected || '').trim();
|
|
el.innerHTML = ADMIN_CLASSES.map((c) => `
|
|
<label class="class-chip${current === c.name ? ' on' : ''}" data-name="${c.name}">
|
|
<img src="/images/classes/${c.logo}" alt="${esc(c.fr)}" loading="lazy">
|
|
<span>${esc(c.fr)}</span>
|
|
</label>`).join('');
|
|
el.querySelectorAll('.class-chip').forEach((chip) => {
|
|
chip.addEventListener('click', () => {
|
|
const name = chip.dataset.name;
|
|
current = current === name ? '' : name;
|
|
el.querySelectorAll('.class-chip').forEach((x) => x.classList.toggle('on', x.dataset.name === current));
|
|
onSave(current);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadSettings() {
|
|
const s = await api.get('/api/admin/settings');
|
|
const fetchEl = $('#fetch-status');
|
|
if (fetchEl) {
|
|
const interval = s.fetch_interval_hours ? `${s.fetch_interval_hours} h` : '6 h';
|
|
fetchEl.innerHTML = s.last_fetch_at
|
|
? `Maj auto toutes les <b>${esc(interval)}</b> · Dernière : <b>${esc(fmtDate(s.last_fetch_at))}</b>`
|
|
: 'Aucune mise à jour automatique';
|
|
}
|
|
$('#st-interval').value = s.fetch_interval_hours || '';
|
|
$('#st-blizz-id').value = s.blizzard_client_id || '';
|
|
$('#st-blizz-secret').value = s.blizzard_client_secret || '';
|
|
$('#st-build').value = s.wow_tools_build || '';
|
|
$('#st-manual-bosses').value = s.manual_mythic_bosses || '';
|
|
$('#st-manual-ach').value = s.manual_achievements || '';
|
|
$('#st-manual-ach-total').value = s.manual_achievements_total || '';
|
|
$('#st-manual-glad-wins').value = s.manual_glad_wins || '';
|
|
renderSingleClassSelect($('#st-manual-glad-class-select'), s.manual_glad_class, async (v) => {
|
|
await api.put('/api/admin/settings', { manual_glad_class: v });
|
|
});
|
|
$('#st-raid-name').value = s.mythic_raid_name || '';
|
|
$('#st-raid-total').value = s.mythic_raid_total || '';
|
|
$('#st-twitch-url').value = s.twitch_url || '';
|
|
$('#st-youtube-url').value = s.youtube_url || '';
|
|
$('#st-tiktok-url').value = s.tiktok_url || '';
|
|
$('#st-glad-season').value = s.glad_title_season || '';
|
|
$('#st-legend-season').value = s.legend_title_season || '';
|
|
renderClassSelect($('#st-manual-class90-select'), s.manual_class90_classes, async (v) => {
|
|
await api.put('/api/admin/settings', { manual_class90_classes: v });
|
|
});
|
|
renderClassSelect($('#st-manual-ss2300-select'), s.manual_ss2300_classes, async (v) => {
|
|
await api.put('/api/admin/settings', { manual_ss2300_classes: v });
|
|
});
|
|
const gladWinsInput = $('#st-manual-glad-wins');
|
|
gladWinsInput.dataset.savedValue = gladWinsInput.value;
|
|
gladWinsInput.addEventListener('change', async () => {
|
|
const v = gladWinsInput.value;
|
|
try {
|
|
await api.put('/api/admin/settings', { manual_glad_wins: v });
|
|
gladWinsInput.dataset.savedValue = v;
|
|
$('#settings-error').textContent = 'Victoires gladiateur enregistrées.';
|
|
$('#settings-error').style.color = 'var(--green)';
|
|
} catch (e) {
|
|
gladWinsInput.value = gladWinsInput.dataset.savedValue;
|
|
$('#settings-error').textContent = e.message;
|
|
$('#settings-error').style.color = '';
|
|
}
|
|
$('#settings-error').hidden = false;
|
|
});
|
|
$('#st-last-fetch').textContent = s.last_fetch_at ? `Dernière mise à jour : ${fmtDate(s.last_fetch_at)}` : 'Aucune mise à jour effectuée.';
|
|
$('#settings-error').hidden = true;
|
|
}
|
|
|
|
async function saveSettings() {
|
|
$('#settings-error').hidden = true;
|
|
try {
|
|
await api.put('/api/admin/settings', {
|
|
fetch_interval_hours: $('#st-interval').value,
|
|
blizzard_client_id: $('#st-blizz-id').value,
|
|
blizzard_client_secret: $('#st-blizz-secret').value,
|
|
wow_tools_build: $('#st-build').value,
|
|
manual_mythic_bosses: $('#st-manual-bosses').value,
|
|
manual_achievements: $('#st-manual-ach').value,
|
|
manual_achievements_total: $('#st-manual-ach-total').value,
|
|
manual_glad_wins: $('#st-manual-glad-wins').value,
|
|
mythic_raid_name: $('#st-raid-name').value,
|
|
mythic_raid_total: $('#st-raid-total').value,
|
|
twitch_url: $('#st-twitch-url').value,
|
|
youtube_url: $('#st-youtube-url').value,
|
|
tiktok_url: $('#st-tiktok-url').value,
|
|
glad_title_season: $('#st-glad-season').value,
|
|
legend_title_season: $('#st-legend-season').value
|
|
});
|
|
$('#settings-error').textContent = 'Paramètres enregistrés.';
|
|
$('#settings-error').hidden = false;
|
|
$('#settings-error').style.color = 'var(--green)';
|
|
await loadSettings();
|
|
} catch (e) {
|
|
$('#settings-error').textContent = e.message;
|
|
$('#settings-error').hidden = false;
|
|
}
|
|
}
|
|
|
|
$('#st-save').addEventListener('click', saveSettings);
|
|
$('#st-save-blizz').addEventListener('click', saveSettings);
|
|
|
|
$('#st-refresh').addEventListener('click', async () => {
|
|
$('#st-refresh').textContent = 'Mise à jour en cours…';
|
|
await api.post('/api/admin/refresh', {});
|
|
$('#st-refresh').textContent = 'Lancer une mise à jour complète';
|
|
await loadSettings();
|
|
await loadLogs();
|
|
await loadGoals();
|
|
});
|
|
|
|
$('#pw-save').addEventListener('click', async () => {
|
|
try {
|
|
await api.post('/api/admin/password', { current: $('#pw-current').value, next: $('#pw-next').value });
|
|
$('#pw-current').value = ''; $('#pw-next').value = '';
|
|
alert('Mot de passe changé.');
|
|
} catch (e) {
|
|
alert(e.message);
|
|
}
|
|
});
|
|
|
|
async function refreshAll() {
|
|
await api.post('/api/admin/refresh', {});
|
|
await loadChars();
|
|
await loadSettings();
|
|
await loadLogs();
|
|
await loadGoals();
|
|
}
|
|
|
|
$('#refresh-all-btn').addEventListener('click', async () => {
|
|
const btn = $('#refresh-all-btn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Mise à jour en cours…';
|
|
try {
|
|
await refreshAll();
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Tout actualiser';
|
|
}
|
|
});
|
|
|
|
// ---- Logs ----
|
|
async function loadLogs() {
|
|
const logs = await api.get('/api/admin/logs');
|
|
$('#logs-tbody').innerHTML = logs.map((l) => `
|
|
<tr>
|
|
<td class="hint">${esc(fmtDate(l.at))}</td>
|
|
<td>#${l.character_id ?? '—'}</td>
|
|
<td>${esc(l.source)}</td>
|
|
<td><span class="status-dot ${l.status === 'ok' ? 'ok' : 'error'}"></span>${esc(l.status)}</td>
|
|
<td class="hint">${esc(shortDetail(l.detail))}</td>
|
|
</tr>`).join('') || '<tr><td colspan="5" class="hint">Aucun log.</td></tr>';
|
|
}
|
|
|
|
function shortDetail(d) {
|
|
try {
|
|
const j = JSON.parse(d || '{}');
|
|
return Object.entries(j).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`).join(' · ');
|
|
} catch {
|
|
return d || '';
|
|
}
|
|
}
|
|
|
|
function fmtDate(iso) {
|
|
if (!iso) return '—';
|
|
const d = new Date(iso);
|
|
if (isNaN(d)) return iso;
|
|
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
const SOCIAL_SVG = {
|
|
youtube: '<svg viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>',
|
|
tiktok: '<svg viewBox="0 0 24 24"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>'
|
|
};
|
|
|
|
function renderSocials(el, socials) {
|
|
const buttons = [];
|
|
if (socials.youtube) buttons.push(`<a class="social-btn" href="${esc(socials.youtube)}" target="_blank" rel="noopener" title="YouTube">${SOCIAL_SVG.youtube}</a>`);
|
|
if (socials.tiktok) buttons.push(`<a class="social-btn" href="${esc(socials.tiktok)}" target="_blank" rel="noopener" title="TikTok">${SOCIAL_SVG.tiktok}</a>`);
|
|
el.innerHTML = buttons.join('');
|
|
}
|
|
|
|
async function updateLiveStatus(statusEl, twitchUrl) {
|
|
const channel = twitchUrl.replace(/\/+$/, '').split('/').pop() || 'wycz';
|
|
const link = `<a class="live-status" href="${esc(twitchUrl)}" target="_blank" rel="noopener">`;
|
|
try {
|
|
const res = await fetch(`https://decapi.me/twitch/uptime/${encodeURIComponent(channel)}`, { mode: 'cors' });
|
|
const text = (await res.text()).trim();
|
|
const isLive = text && !/offline|hors ligne|hors-ligne|not live/i.test(text);
|
|
statusEl.innerHTML = isLive
|
|
? `${link}<span class="live-dot on"></span> Live en cours</a>`
|
|
: `${link}<span class="live-dot"></span> Hors ligne</a>`;
|
|
} catch {
|
|
statusEl.innerHTML = `${link}<span class="live-dot"></span> Twitch</a>`;
|
|
}
|
|
}
|
|
|
|
async function loadHeader() {
|
|
try {
|
|
const overview = await api.get('/api/overview');
|
|
renderSocials($('#socials'), { youtube: (overview.youtube && overview.youtube.url) || '', tiktok: (overview.tiktok && overview.tiktok.url) || '' });
|
|
updateLiveStatus($('#header-live'), (overview.twitch && overview.twitch.url) || 'https://twitch.tv/wycz');
|
|
} catch {
|
|
$('#socials').innerHTML = '';
|
|
}
|
|
}
|
|
|
|
const sessionUndoBtn = $('#session-undo');
|
|
if (sessionUndoBtn) sessionUndoBtn.addEventListener('click', sessionUndo);
|
|
|
|
loadHeader();
|
|
checkLogin();
|
|
})();
|