Files
oscars-pool/public/js/app_control.js

3557 lines
134 KiB
JavaScript

window.onload = async function () {
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/';
return;
}
let decoded;
try {
decoded = JSON.parse(atob(token.split('.')[1]));
} catch (e) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < currentTime) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
}
document.getElementById('user-name').textContent = decoded.name || '';
// Admin-only page: if token doesn't contain admin flag, bounce.
if (!decoded.admin) {
window.location.href = '/movies.html';
return;
}
const responseEl = document.getElementById('response');
const form = document.getElementById('add-movie-form');
const yearInput = document.getElementById('oscar_year');
const activeYearInput = document.getElementById('active_oscar_year') || document.getElementById('oscar_date_year');
const saveActiveYearBtn = document.getElementById('save-active-year');
// Player UI (settings tab): show/hide admin status blocks in player.html
const playerUiShowStatusEl = document.getElementById('player_ui_show_status');
const playerUiReloadBtn = document.getElementById('player_ui_reload');
const playerUiSaveBtn = document.getElementById('player_ui_save');
// Version control tab elements
const adminVersionTabBtn = document.getElementById('admin-version-tab');
const appVersionAlertEl = document.getElementById('app_version_alert');
const appVersionPreviewEl = document.getElementById('app_version_preview');
const appVersionNewVersionEl = document.getElementById('app_version_new_version');
const appVersionNewMessageEl = document.getElementById('app_version_new_message');
const appVersionCreateBtn = document.getElementById('app_version_create');
const appVersionCreateAndActivateBtn = document.getElementById('app_version_create_and_activate');
const appVersionListEl = document.getElementById('app_version_list');
// Oscar date per year (settings tab)
const oscarDateYearSelect = document.getElementById('oscar_date_year');
const oscarDateValueInput = document.getElementById('oscar_date_value');
const saveOscarDateBtn = document.getElementById('save-oscar-date');
const clearOscarDateBtn = document.getElementById('clear-oscar-date');
const oscarDateCurrentEl = document.getElementById('oscar-date-current');
// Modal 100% elements
const completionModalTitleEl = document.getElementById('completion_modal_title');
const completionModalBodyTextEl = document.getElementById('completion_modal_body_text');
const completionModalBodyHtmlEl = document.getElementById('completion_modal_body_html');
const completionModalVideoSrcEl = document.getElementById('completion_modal_video_src');
const completionModalReloadBtn = document.getElementById('completion_modal_reload');
const completionModalSaveBtn = document.getElementById('completion_modal_save');
const completionModalPreviewTitleEl = document.getElementById('completion_modal_preview_title');
const completionModalPreviewTextEl = document.getElementById('completion_modal_preview_text');
const completionModalPreviewCustomEl = document.getElementById('completion_modal_preview_custom');
const completionModalPreviewVideoEl = document.getElementById('completion_modal_preview_video');
const completionModalPreviewVideoSourceEl = document.getElementById('completion_modal_preview_video_source');
const manageYearSelect = document.getElementById('manage_year');
const adminMoviesBody = document.getElementById('admin-movies-body');
const refreshMoviesBtn = document.getElementById('refresh-movies');
const deleteSelectedBtn = document.getElementById('delete-selected');
const updateOmdbSelectedBtn = document.getElementById('update-omdb-selected');
const selectAllBox = document.getElementById('select-all-movies');
const selectedCountEl = document.getElementById('selected-count');
// Winners tab elements
const adminWinnersTabBtn = document.getElementById('admin-winners-tab');
const winnerYearSelect = document.getElementById('winner_year');
const winnerUserSelect = document.getElementById('winner_user');
const winnerPointsEl = document.getElementById('winner_points');
const winnerReloadBtn = document.getElementById('winner_reload');
const winnerClearBtn = document.getElementById('winner_clear');
const winnerSaveBtn = document.getElementById('winner_save');
const winnerCurrentEl = document.getElementById('winner_current');
const editMovieModalEl = document.getElementById('editMovieModal');
const editMovieModal = editMovieModalEl ? new bootstrap.Modal(editMovieModalEl) : null;
const editMovieModalTitleEl = document.getElementById('editMovieModalTitle');
const saveMovieChangesBtn = document.getElementById('save-movie-changes');
const editMovieIdEl = document.getElementById('edit_movie_id');
const editMovieIdLabelEl = document.getElementById('edit_movie_id_label');
const copyMovieIdBtn = document.getElementById('copy-movie-id');
const editImdbIdEl = document.getElementById('edit_imdb_id');
const editYearEl = document.getElementById('edit_year');
const editCategoryEl = document.getElementById('edit_category');
const editVodLinkEl = document.getElementById('edit_vod_link');
const editPlayerModeEl = document.getElementById('edit_player_mode');
const editVideoSrcEl = document.getElementById('edit_video_src');
const editVideoFileEl = document.getElementById('edit_video_file');
const editVideoFileLowEl = document.getElementById('edit_video_file_low');
const editSubtitleFileEl = document.getElementById('edit_subtitle_file');
const editSubtitleRemoveEl = document.getElementById('edit_subtitle_remove');
const editSubtitleLangEl = document.getElementById('edit_subtitle_lang');
const editSubtitleLabelEl = document.getElementById('edit_subtitle_label');
const editSubtitleDefaultEl = document.getElementById('edit_subtitle_default');
const editSubtitleListEl = document.getElementById('edit_subtitle_list');
const editEmbedSrcEl = document.getElementById('edit_embed_src');
const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb');
const editTitleEl = document.getElementById('edit_title');
const editRatingEl = document.getElementById('edit_rating');
const editRuntimeEl = document.getElementById('edit_runtime');
const editPosterEl = document.getElementById('edit_poster');
const editDescriptionEl = document.getElementById('edit_description');
const editCamFlagEl = document.getElementById('edit_cam_flag');
let moviesById = new Map();
let activeYear = null;
let winnersLoadedOnce = false;
let winnerUsersLoadedOnce = false;
let winnersByYear = new Map(); // year -> [{ year, userId, name, points }]
let winnerUsers = []; // admin list: {id, name, email, ...}
let oscarDatesLoadedOnce = false;
let oscarDatesByYear = {}; // { "2026": "2026-03-15" }
let appVersionLoadedOnce = false;
let appVersionState = { active: null, versions: [] };
const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: t('admin.modal100.defaultTitle', 'Congratulations! very nice 🎉🎉🎉'),
bodyText: '',
videoSrc: 'video/reward.mp4',
bodyHtml: '',
});
const DEFAULT_PLAYER_UI = Object.freeze({
showSource: true,
showProgress: true,
});
// Small translation helper: prefers window.i18n.t when available,
// falls back to the provided fallback string and does simple {name}/{year} interpolation.
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
function showResponse(kind, message) {
responseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning');
responseEl.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger');
responseEl.textContent = message;
}
function escapeHtml(raw) {
return String(raw || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function normalizeSubtitleText(v, maxLen = 80) {
if (v === undefined || v === null) return '';
const s = String(v).trim();
if (!s) return '';
return s.slice(0, maxLen);
}
function getMovieSubtitles(movie) {
const subs = Array.isArray(movie?.subtitles) ? movie.subtitles.filter(Boolean) : [];
if (subs.length > 0) return subs;
const legacyFile = typeof movie?.subtitle_file === 'string' ? movie.subtitle_file.trim() : '';
if (!legacyFile) return [];
return [{
_id: 'legacy',
file: legacyFile,
lang: movie?.subtitle_lang || '',
label: movie?.subtitle_label || '',
default: !!movie?.subtitle_default,
}];
}
async function uploadMovieSubtitle({ movieId, file, lang, label, isDefault }) {
if (!movieId || !file) return null;
const formData = new FormData();
formData.append('subtitle', file);
if (lang) formData.append('subtitle_lang', lang);
if (label) formData.append('subtitle_label', label);
formData.append('subtitle_default', isDefault ? 'true' : 'false');
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/subtitles`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return data;
}
async function removeMovieSubtitle({ movieId, subtitleId }) {
if (!movieId || !subtitleId) return null;
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/subtitles/${encodeURIComponent(subtitleId)}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return data;
}
function normalizePlayerUi(value) {
const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const showSource = v.showSource === undefined ? DEFAULT_PLAYER_UI.showSource : !!v.showSource;
const showProgress = v.showProgress === undefined ? DEFAULT_PLAYER_UI.showProgress : !!v.showProgress;
return { showSource, showProgress };
}
async function fetchPlayerUi() {
const res = await fetch('/api/settings/player-admin-status-ui', { method: 'GET', cache: 'no-store' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return normalizePlayerUi(data);
}
async function savePlayerUi(payload) {
const res = await fetch('/api/settings/player-admin-status-ui', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return normalizePlayerUi(data);
}
function setPlayerUiForm(value) {
if (!playerUiShowStatusEl) return;
// Single checkbox controls both flags.
playerUiShowStatusEl.checked = !!value.showSource && !!value.showProgress;
}
function getPlayerUiFormValue() {
const enabled = !!playerUiShowStatusEl?.checked;
return normalizePlayerUi({
showSource: enabled,
showProgress: enabled,
});
}
async function loadPlayerUiIntoUi() {
// If the section isn't present (older HTML), skip gracefully.
if (!playerUiShowStatusEl) return;
try {
const value = await fetchPlayerUi();
setPlayerUiForm(value);
} catch (err) {
// Keep defaults so admin can still set something.
setPlayerUiForm(DEFAULT_PLAYER_UI);
showResponse('warning', err.message || t('admin.player.errorLoading', 'Unable to load player settings.'));
}
}
async function copyToClipboard(text) {
const t = String(text || '').trim();
if (!t) return false;
try {
await navigator.clipboard.writeText(t);
return true;
} catch (_) {
return false;
}
}
function sanitizeHtml(raw) {
const html = String(raw || '');
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
const withoutOnAttrs = withoutScripts
.replace(/\son\w+="[^"]*"/gi, '')
.replace(/\son\w+='[^']*'/gi, '')
.replace(/\son\w+=\S+/gi, '');
return withoutOnAttrs.trim();
}
function normalizeCompletionModal(value) {
const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const title = typeof v.title === 'string' ? v.title : DEFAULT_COMPLETION_MODAL.title;
const bodyText = typeof v.bodyText === 'string' ? v.bodyText : DEFAULT_COMPLETION_MODAL.bodyText;
const videoSrc = typeof v.videoSrc === 'string' ? v.videoSrc : DEFAULT_COMPLETION_MODAL.videoSrc;
const bodyHtml = typeof v.bodyHtml === 'string' ? v.bodyHtml : DEFAULT_COMPLETION_MODAL.bodyHtml;
return {
title: String(title || '').trim().slice(0, 200) || DEFAULT_COMPLETION_MODAL.title,
bodyText: String(bodyText || '').slice(0, 8000),
videoSrc: String(videoSrc || '').trim().slice(0, 2048),
bodyHtml: String(bodyHtml || '').slice(0, 20000),
};
}
async function fetchAppVersionState() {
const res = await fetch('/api/settings/app-version', { method: 'GET', cache: 'no-store' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return {
active: data?.active && typeof data.active === 'object' ? data.active : null,
versions: Array.isArray(data?.versions) ? data.versions : [],
};
}
async function createAppVersionEntry({ version, message, activate }) {
const res = await fetch(`/api/settings/app-version?activate=${activate ? 'true' : 'false'}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ version, message }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return {
active: data?.active && typeof data.active === 'object' ? data.active : null,
versions: Array.isArray(data?.versions) ? data.versions : [],
};
}
async function setActiveAppVersion(id) {
const res = await fetch('/api/settings/app-version/active', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ id }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return {
active: data?.active && typeof data.active === 'object' ? data.active : null,
versions: Array.isArray(data?.versions) ? data.versions : [],
};
}
async function deleteAppVersionEntry(id) {
const res = await fetch(`/api/settings/app-version/${encodeURIComponent(String(id))}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return {
active: data?.active && typeof data.active === 'object' ? data.active : null,
versions: Array.isArray(data?.versions) ? data.versions : [],
};
}
function setAppVersionAlert(kind, message) {
if (!appVersionAlertEl) return;
appVersionAlertEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning');
appVersionAlertEl.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger');
appVersionAlertEl.textContent = message;
}
function hideAppVersionAlert() {
if (!appVersionAlertEl) return;
appVersionAlertEl.classList.add('d-none');
appVersionAlertEl.textContent = '';
}
async function refreshAppVersionPreview() {
if (!appVersionPreviewEl) return;
try {
const res = await fetch('/api/version', { method: 'GET', cache: 'no-store' });
if (!res.ok) throw new Error(`Erreur (${res.status})`);
const data = await res.json().catch(() => ({}));
const line = `${data?.date || ''} - ${data?.version || ''} - ${data?.message || ''}`.trim();
appVersionPreviewEl.textContent = line || '—';
appVersionPreviewEl.style.whiteSpace = 'pre-wrap';
} catch (_) {
appVersionPreviewEl.textContent = '—';
}
}
function renderAppVersionUI() {
if (!appVersionListEl) return;
const versions = Array.isArray(appVersionState.versions) ? appVersionState.versions : [];
const activeId = appVersionState?.active?.id ? String(appVersionState.active.id) : '';
if (!versions.length) {
appVersionListEl.textContent = '—';
return;
}
const rows = versions.map((v) => {
const id = String(v?.id || '');
const isActive = activeId && id === activeId;
const dateLabel = v?.dateISO ? formatDateTime(v.dateISO) : '—';
const msg = escapeHtml(String(v?.message || '').trim());
const versionLabel = escapeHtml(String(v?.version || '—'));
const activeBadge = isActive ? '<span class="badge bg-warning text-dark">Active</span>' : '';
const activateBtn = isActive
? ''
: `<button type="button" class="btn btn-sm btn-outline-secondary btn-compact btn-check-compact" data-app-version-activate="${escapeHtml(id)}" aria-label="Activer">✓</button>`;
// IMPORTANT: keep markup compact (no preserved whitespace surprises).
return (
`<div class="list-group-item d-flex justify-content-between align-items-start gap-3">` +
`<div class="min-width-0 flex-grow-1">` +
`<div class="d-flex align-items-center gap-2 flex-wrap">` +
`<span class="fw-semibold text-break">${versionLabel}</span>` +
`${activeBadge}` +
`</div>` +
`<div class="small text-muted text-break">${escapeHtml(dateLabel)}${msg ? ` — ${msg}` : ''}</div>` +
`</div>` +
`<div class="flex-shrink-0 d-flex gap-2">` +
`${activateBtn}` +
`<button type="button" class="btn btn-sm btn-outline-danger btn-compact" data-app-version-delete="${escapeHtml(id)}">${t('common.delete','Delete')}</button>` +
`</div>` +
`</div>`
);
}).join('');
appVersionListEl.innerHTML = `<div class="list-group list-group-flush">${rows}</div>`;
appVersionListEl.querySelectorAll('button[data-app-version-activate]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-app-version-activate');
if (!id) return;
btn.disabled = true;
const old = btn.textContent;
btn.textContent = '...';
try {
hideAppVersionAlert();
appVersionState = await setActiveAppVersion(id);
appVersionLoadedOnce = true;
renderAppVersionUI();
await refreshAppVersionPreview();
setAppVersionAlert('success', t('admin.versions.updated', 'Version updated.'));
} catch (err) {
setAppVersionAlert('danger', err.message || 'Network error');
} finally {
btn.disabled = false;
btn.textContent = old;
}
});
});
appVersionListEl.querySelectorAll('button[data-app-version-delete]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-app-version-delete');
if (!id) return;
const ok = window.confirm(t('admin.version.confirmDelete','Delete this version?'));
if (!ok) return;
btn.disabled = true;
const old = btn.textContent;
btn.textContent = '...';
try {
hideAppVersionAlert();
appVersionState = await deleteAppVersionEntry(id);
appVersionLoadedOnce = true;
renderAppVersionUI();
await refreshAppVersionPreview();
setAppVersionAlert('success', t('admin.versions.deleted', 'Version deleted.'));
} catch (err) {
setAppVersionAlert('danger', err.message || 'Network error');
} finally {
btn.disabled = false;
btn.textContent = old;
}
});
});
}
async function loadAppVersionTab(options = {}) {
const force = !!options.force;
if (!appVersionListEl) return;
if (appVersionLoadedOnce && !force) return;
try {
hideAppVersionAlert();
appVersionState = await fetchAppVersionState();
appVersionLoadedOnce = true;
renderAppVersionUI();
await refreshAppVersionPreview();
} catch (err) {
appVersionLoadedOnce = true;
appVersionState = { active: null, versions: [] };
renderAppVersionUI();
await refreshAppVersionPreview();
setAppVersionAlert('danger', err.message || 'Network error');
}
}
async function fetchCompletionModal() {
const res = await fetch('/api/settings/completion-modal', { method: 'GET', cache: 'no-store' });
if (!res.ok) throw new Error(`Erreur (${res.status})`);
const data = await res.json().catch(() => ({}));
return normalizeCompletionModal(data);
}
async function saveCompletionModal(payload) {
const res = await fetch('/api/settings/completion-modal', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return normalizeCompletionModal(data);
}
function setCompletionModalForm(value) {
if (!completionModalTitleEl || !completionModalBodyTextEl || !completionModalBodyHtmlEl || !completionModalVideoSrcEl) return;
completionModalTitleEl.value = value.title || '';
completionModalBodyTextEl.value = value.bodyText || '';
completionModalBodyHtmlEl.value = value.bodyHtml || '';
completionModalVideoSrcEl.value = value.videoSrc || '';
}
function getCompletionModalFormValue() {
return normalizeCompletionModal({
title: completionModalTitleEl ? completionModalTitleEl.value : '',
bodyText: completionModalBodyTextEl ? completionModalBodyTextEl.value : '',
bodyHtml: completionModalBodyHtmlEl ? completionModalBodyHtmlEl.value : '',
videoSrc: completionModalVideoSrcEl ? completionModalVideoSrcEl.value : '',
});
}
function updateCompletionModalPreview(value) {
if (completionModalPreviewTitleEl) {
completionModalPreviewTitleEl.textContent = value.title || DEFAULT_COMPLETION_MODAL.title;
}
const text = String(value.bodyText || '');
if (completionModalPreviewTextEl) {
completionModalPreviewTextEl.textContent = text;
completionModalPreviewTextEl.classList.toggle('d-none', !text);
}
const safeHtml = sanitizeHtml(value.bodyHtml || '');
if (completionModalPreviewCustomEl) {
if (safeHtml) {
completionModalPreviewCustomEl.innerHTML = safeHtml;
completionModalPreviewCustomEl.classList.remove('d-none');
} else {
completionModalPreviewCustomEl.innerHTML = '';
completionModalPreviewCustomEl.classList.add('d-none');
}
}
const videoSrc = String(value.videoSrc || '').trim();
if (completionModalPreviewVideoEl && completionModalPreviewVideoSourceEl) {
if (videoSrc) {
completionModalPreviewVideoSourceEl.setAttribute('src', videoSrc);
try { completionModalPreviewVideoEl.load(); } catch (_) {}
completionModalPreviewVideoEl.classList.remove('d-none');
} else {
try { completionModalPreviewVideoEl.pause(); } catch (_) {}
completionModalPreviewVideoEl.classList.add('d-none');
}
}
}
async function loadCompletionModalIntoUi() {
// If the tab isn't in DOM (older HTML), just skip gracefully.
if (!completionModalTitleEl) return;
try {
const value = await fetchCompletionModal();
setCompletionModalForm(value);
updateCompletionModalPreview(getCompletionModalFormValue());
} catch (err) {
// Still show defaults so admin can edit even if network hiccups.
setCompletionModalForm(DEFAULT_COMPLETION_MODAL);
updateCompletionModalPreview(getCompletionModalFormValue());
showResponse('warning', err.message || 'Impossible de charger le contenu du modal.');
}
}
// Admin users list elements
const adminUsersBody = document.getElementById('admin-users-body');
const refreshUsersBtn = document.getElementById('refresh-users');
const adminUsersCountEl = document.getElementById('admin-users-count');
const adminUsersTabBtn = document.getElementById('admin-users-tab');
// Admin DB backup/restore elements
const adminBackupTabBtn = document.getElementById('admin-backup-tab');
const dbBackupCreateBtn = document.getElementById('db-backup-create');
const dbBackupRefreshBtn = document.getElementById('db-backup-refresh');
const dbBackupListBody = document.getElementById('db-backup-list');
const dbRestoreFileEl = document.getElementById('db-restore-file');
const dbRestoreDropEl = document.getElementById('db-restore-drop');
const dbRestoreRunBtn = document.getElementById('db-restore-run');
const dbRestoreResultEl = document.getElementById('db-restore-result');
let backupsLoadedOnce = false;
// Admin: add user form elements
const openAddUserModalBtn = document.getElementById('open-add-user-modal');
const addUserModalEl = document.getElementById('addUserModal');
const addUserModal = addUserModalEl ? new bootstrap.Modal(addUserModalEl) : null;
const addUserForm = document.getElementById('add-user-form');
const addUserNameEl = document.getElementById('add_user_name');
const addUserEmailEl = document.getElementById('add_user_email');
const addUserAdminEl = document.getElementById('add_user_admin');
const addUserResponseEl = document.getElementById('add-user-response');
const addUserResultEl = document.getElementById('add-user-result');
const addUserTempPasswordEl = document.getElementById('add-user-temp-password');
const addUserExpiresAtEl = document.getElementById('add-user-expires-at');
const addUserSubmitBtn = document.getElementById('add-user-submit');
const addUserResetBtn = document.getElementById('add-user-reset');
const copyAddUserTempPasswordBtn = document.getElementById('copy-add-user-temp-password');
// Admin user modals
const resetUserPasswordModalEl = document.getElementById('resetUserPasswordModal');
const resetUserPasswordModal = resetUserPasswordModalEl ? new bootstrap.Modal(resetUserPasswordModalEl) : null;
const resetUserLabelEl = document.getElementById('reset_user_label');
const resetUserEmailLabelEl = document.getElementById('reset_user_email_label');
const resetUserModalResponseEl = document.getElementById('reset-user-modal-response');
const resetUserModalResultEl = document.getElementById('reset-user-modal-result');
const resetUserTempPasswordEl = document.getElementById('reset-user-temp-password');
const resetUserExpiresAtEl = document.getElementById('reset-user-expires-at');
const confirmResetUserPasswordBtn = document.getElementById('confirm-reset-user-password');
const copyTempPasswordBtn = document.getElementById('copy-temp-password');
const deleteUserModalEl = document.getElementById('deleteUserModal');
const deleteUserModal = deleteUserModalEl ? new bootstrap.Modal(deleteUserModalEl) : null;
const deleteUserLabelEl = document.getElementById('delete_user_label');
const deleteUserEmailLabelEl = document.getElementById('delete_user_email_label');
const deleteUserVerifyInputEl = document.getElementById('delete_user_verify_input');
const deleteUserModalResponseEl = document.getElementById('delete-user-modal-response');
const confirmDeleteUserBtn = document.getElementById('confirm-delete-user');
let usersLoadedOnce = false;
let currentResetTarget = null;
let currentDeleteTarget = null;
function setAlert(el, kind, message) {
if (!el) return;
el.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning');
el.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger');
el.textContent = message;
}
function hideAlert(el) {
if (!el) return;
el.classList.add('d-none');
el.textContent = '';
}
function resetAddUserUi(options = {}) {
hideAlert(addUserResponseEl);
if (addUserResultEl) addUserResultEl.classList.add('d-none');
if (addUserTempPasswordEl) addUserTempPasswordEl.textContent = '—';
if (addUserExpiresAtEl) addUserExpiresAtEl.textContent = '';
if (options.resetForm && addUserForm) {
try { addUserForm.reset(); } catch (_) {}
}
if (addUserSubmitBtn) addUserSubmitBtn.disabled = false;
}
function openAddUserModal() {
if (!addUserModal) return;
resetAddUserUi({ resetForm: true });
try { addUserModal.show(); } catch (_) {}
// Focus name field (best effort)
setTimeout(() => {
try { addUserNameEl?.focus(); } catch (_) {}
}, 50);
}
function resetResetUserModalUi() {
hideAlert(resetUserModalResponseEl);
if (resetUserModalResultEl) resetUserModalResultEl.classList.add('d-none');
if (resetUserTempPasswordEl) resetUserTempPasswordEl.textContent = '—';
if (resetUserExpiresAtEl) resetUserExpiresAtEl.textContent = '';
if (confirmResetUserPasswordBtn) confirmResetUserPasswordBtn.disabled = false;
}
function openResetUserModal(user) {
currentResetTarget = user || null;
if (!resetUserPasswordModal) return;
resetResetUserModalUi();
if (resetUserLabelEl) resetUserLabelEl.textContent = user?.name ? user.name : '(sans nom)';
if (resetUserEmailLabelEl) resetUserEmailLabelEl.textContent = user?.email || '';
resetUserPasswordModal.show();
}
function resetDeleteUserModalUi() {
hideAlert(deleteUserModalResponseEl);
if (deleteUserVerifyInputEl) deleteUserVerifyInputEl.value = '';
if (confirmDeleteUserBtn) confirmDeleteUserBtn.disabled = true;
}
function openDeleteUserModal(user) {
currentDeleteTarget = user || null;
if (!deleteUserModal) return;
resetDeleteUserModalUi();
if (deleteUserLabelEl) deleteUserLabelEl.textContent = user?.name ? user.name : '(sans nom)';
if (deleteUserEmailLabelEl) deleteUserEmailLabelEl.textContent = user?.email || '';
deleteUserModal.show();
}
function setUsersCount(count) {
if (!adminUsersCountEl) return;
adminUsersCountEl.textContent = Number.isInteger(count) ? String(count) : '—';
}
function renderAdminUsers(users) {
if (!adminUsersBody) return;
const list = Array.isArray(users) ? users : [];
setUsersCount(list.length);
if (!list.length) {
adminUsersBody.innerHTML = '<tr><td colspan="4" class="text-muted">Aucun utilisateur.</td></tr>';
return;
}
adminUsersBody.innerHTML = '';
list.forEach((u) => {
const tr = document.createElement('tr');
const tdName = document.createElement('td');
tdName.textContent = u?.name || '(sans nom)';
const tdEmail = document.createElement('td');
tdEmail.textContent = u?.email || '';
const tdStatus = document.createElement('td');
tdStatus.className = 'text-nowrap';
const badges = [];
if (u?.admin) badges.push({ text: 'Admin', cls: 'bg-warning text-dark' });
if (u?.mustChangePassword) badges.push({ text: t('admin.users.resetPassword','Reset MDP'), cls: 'bg-danger' });
if (!badges.length) {
const span = document.createElement('span');
span.className = 'badge bg-secondary';
span.textContent = 'OK';
tdStatus.appendChild(span);
} else {
badges.forEach((b, idx) => {
const span = document.createElement('span');
span.className = `badge ${b.cls}`;
span.textContent = b.text;
tdStatus.appendChild(span);
if (idx < badges.length - 1) {
tdStatus.appendChild(document.createTextNode(' '));
}
});
}
const tdActions = document.createElement('td');
tdActions.className = 'text-nowrap';
const resetBtn = document.createElement('button');
resetBtn.type = 'button';
resetBtn.className = 'btn btn-outline-danger btn-sm me-1';
resetBtn.textContent = t('admin.users.resetPassword','Reset MDP');
resetBtn.addEventListener('click', () => openResetUserModal(u));
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'btn btn-outline-danger btn-sm';
deleteBtn.textContent = t('common.delete','Delete');
const isSelf = String(u?.id || '') && String(u.id) === String(decoded?.id || '');
if (isSelf || u?.admin) {
deleteBtn.disabled = true;
deleteBtn.title = isSelf
? t('admin.users.cannotDeleteSelf','You cannot delete your own account from here.')
: t('admin.users.adminDeleteDisabled','Deleting admin accounts is disabled via this interface.');
}
deleteBtn.addEventListener('click', () => openDeleteUserModal(u));
tdActions.appendChild(resetBtn);
tdActions.appendChild(deleteBtn);
tr.appendChild(tdName);
tr.appendChild(tdEmail);
tr.appendChild(tdStatus);
tr.appendChild(tdActions);
adminUsersBody.appendChild(tr);
});
}
async function fetchAdminUsers() {
const res = await fetch('/api/users/admin/list', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
cache: 'no-store',
});
const data = await res.json().catch(() => ([]));
if (!res.ok) {
const msg = data?.message || data?.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return Array.isArray(data) ? data : [];
}
async function loadAdminUsers(options = {}) {
const force = !!options.force;
if (!adminUsersBody) return;
if (usersLoadedOnce && !force) return;
adminUsersBody.innerHTML = '<tr><td colspan="4" class="text-muted">Chargement…</td></tr>';
setUsersCount(null);
try {
const users = await fetchAdminUsers();
usersLoadedOnce = true;
users.sort((a, b) => (a?.name || '').localeCompare((b?.name || ''), 'fr', { sensitivity: 'base' }));
renderAdminUsers(users);
} catch (err) {
adminUsersBody.innerHTML = `<tr><td colspan="4" class="text-danger">${err.message || t('common.networkError', 'Network error')}</td></tr>`;
setUsersCount(null);
}
}
function parseYear(value) {
const n = Number(value);
if (!Number.isInteger(n)) return null; // Ensure the year is an integer
if (n < 1900 || n > 3000) return null;
return n;
}
function isIsoDateString(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(String(value || '').trim());
}
async function fetchOscarDates() {
const res = await fetch('/api/settings/oscar-dates', { method: 'GET', cache: 'no-store' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return data?.dates && typeof data.dates === 'object' ? data.dates : {};
}
async function ensureOscarDatesLoaded(options = {}) {
const force = !!options.force;
if (oscarDatesLoadedOnce && !force) return;
try {
oscarDatesByYear = await fetchOscarDates();
oscarDatesLoadedOnce = true;
} catch (_) {
oscarDatesByYear = {};
oscarDatesLoadedOnce = true;
}
}
async function setOscarDateForYear(year, dateStrOrEmpty) {
const res = await fetch(`/api/settings/oscar-date/${encodeURIComponent(String(year))}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ date: dateStrOrEmpty }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return data;
}
function renderOscarDatesCurrent(cleanedYears) {
if (!oscarDateCurrentEl) return;
const years = Array.isArray(cleanedYears) ? cleanedYears : [];
if (!years.length) {
oscarDateCurrentEl.textContent = '—';
return;
}
const lines = years.map((y) => {
const key = String(y);
const val = oscarDatesByYear && typeof oscarDatesByYear === 'object' ? oscarDatesByYear[key] : null;
const date = (typeof val === 'string' && isIsoDateString(val))
? val
: t('admin.settings.oscarDateDefaultShort', '— (default: 03-15)');
return `${key}: ${date}`;
});
oscarDateCurrentEl.textContent = lines.join('\n');
oscarDateCurrentEl.style.whiteSpace = 'pre';
}
function applyOscarDateSelection() {
if (!oscarDateYearSelect || !oscarDateValueInput) return;
const y = parseYear(oscarDateYearSelect.value); // Parse the selected year
if (!y) {
oscarDateValueInput.value = '';
return;
}
const key = String(y);
const val = oscarDatesByYear && typeof oscarDatesByYear === 'object' ? oscarDatesByYear[key] : null;
oscarDateValueInput.value = (typeof val === 'string' && isIsoDateString(val)) ? val : '';
}
async function fetchWinners() {
const res = await fetch('/api/settings/winners', { method: 'GET', cache: 'no-store' });
const data = await res.json().catch(() => ({})); // Handle JSON parsing errors
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return Array.isArray(data?.winners) ? data.winners : [];
}
async function addWinner(year, userId, points) {
const res = await fetch(`/api/settings/winners/${encodeURIComponent(String(year))}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
userId: userId ? String(userId) : '',
points: points === undefined ? null : points,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return data;
}
async function removeWinner(year, userId) {
const res = await fetch(`/api/settings/winners/${encodeURIComponent(String(year))}/${encodeURIComponent(String(userId))}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return data;
}
function buildWinnerUsersOptions(users) {
if (!winnerUserSelect) return;
const list = Array.isArray(users) ? users : [];
const previous = String(winnerUserSelect.value || '');
winnerUserSelect.innerHTML = '';
const emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = '—';
winnerUserSelect.appendChild(emptyOpt);
list.forEach((u) => {
const opt = document.createElement('option');
opt.value = String(u?.id || '');
const label = u?.name ? u.name : u?.email ? u.email : '(sans nom)';
opt.textContent = label;
winnerUserSelect.appendChild(opt);
});
const stillExists = list.map((u) => String(u?.id || '')).includes(previous);
winnerUserSelect.value = stillExists ? previous : '';
}
function setWinnerYearOptions(cleanedYears) {
if (!winnerYearSelect) return;
const years = Array.isArray(cleanedYears) ? cleanedYears : [];
const previous = String(winnerYearSelect.value || '');
winnerYearSelect.innerHTML = '';
if (!years.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = t('admin.settings.noYearAddMovie', 'No year (add a movie)');
opt.disabled = true;
opt.selected = true;
winnerYearSelect.appendChild(opt);
winnerYearSelect.disabled = true;
if (winnerSaveBtn) winnerSaveBtn.disabled = true;
if (winnerClearBtn) winnerClearBtn.disabled = true;
return;
}
years.forEach((y) => {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
winnerYearSelect.appendChild(opt);
});
winnerYearSelect.disabled = false;
if (winnerSaveBtn) winnerSaveBtn.disabled = false;
if (winnerClearBtn) winnerClearBtn.disabled = false;
const stillExists = years.map(String).includes(previous);
const desired = stillExists
? previous
: (activeYear && years.map(String).includes(String(activeYear)))
? String(activeYear)
: String(years[0]);
winnerYearSelect.value = desired;
}
function getWinnerForYear(year) {
const y = parseYear(year);
if (!y) return null;
return winnersByYear.get(String(y)) || null;
}
function sortWinnersForDisplay(list) {
const winners = Array.isArray(list) ? list : [];
// Sort for stable display: points desc (null last), then name
return winners.slice().sort((a, b) => {
const ap = a?.points === null || a?.points === undefined ? null : Number(a.points);
const bp = b?.points === null || b?.points === undefined ? null : Number(b.points);
if (ap === null && bp !== null) return 1;
if (ap !== null && bp === null) return -1;
if (ap !== null && bp !== null && ap !== bp) return bp - ap;
return String(a?.name || '').localeCompare(String(b?.name || ''), 'fr', { sensitivity: 'base' });
});
}
function renderWinnerCurrent() {
if (!winnerCurrentEl) return;
const years = Array.from(winnersByYear.keys())
.map((k) => Number(k))
.filter((n) => Number.isInteger(n))
.sort((a, b) => b - a);
if (!years.length) {
winnerCurrentEl.textContent = '—';
return;
}
const blocks = years.map((y) => {
const list = winnersByYear.get(String(y)) || [];
const sorted = sortWinnersForDisplay(list);
if (!sorted.length) {
return `
<div class="border rounded p-2 bg-white mb-2">
<div class="fw-semibold">${escapeHtml(String(y))}</div>
<div class="text-muted small">(aucun gagnant)</div>
</div>
`;
}
const rows = sorted.map((w) => {
const name = escapeHtml(w?.name || t('stats.deletedUser', '(deleted user)'));
const pts = w?.points === null || w?.points === undefined ? null : Number(w.points);
const ptsLabel = pts === null || Number.isNaN(pts) ? '' : ` <span class="text-muted">— ${escapeHtml(String(Math.round(pts)))} pts</span>`;
const uid = escapeHtml(String(w?.userId || '').trim());
return (
`<div class="d-flex justify-content-between align-items-center border rounded px-2 py-1 mb-2 bg-white">` +
`<div class="me-2 min-width-0"><strong class="text-break">${name}</strong>${ptsLabel}</div>` +
`<button type="button" class="btn btn-sm btn-outline-danger btn-compact flex-shrink-0" data-winner-remove-year="${escapeHtml(String(y))}" data-winner-remove-user="${uid}">${t('common.remove','Retirer')}</button>` +
`</div>`
);
}).join('');
const tieLabel = sorted.length > 1 ? ` <span class="text-muted fw-normal">${t('admin.winners.tie', '(tie)')}</span>` : '';
return `
<div class="border rounded p-2 bg-light mb-2">
<div class="fw-semibold mb-2">${escapeHtml(String(y))}${tieLabel}</div>
${rows}
</div>
`;
}).join('');
winnerCurrentEl.innerHTML = blocks;
winnerCurrentEl.querySelectorAll('button[data-winner-remove-year][data-winner-remove-user]').forEach((btn) => {
btn.addEventListener('click', async () => {
const yearRaw = btn.getAttribute('data-winner-remove-year');
const userRaw = btn.getAttribute('data-winner-remove-user');
const yy = parseYear(yearRaw);
const uid = String(userRaw || '').trim();
if (!yy || !uid) return;
btn.disabled = true;
const old = btn.textContent;
btn.textContent = '...';
try {
await removeWinner(yy, uid);
await loadWinners({ force: true });
showResponse('success', t('admin.winners.removedFor', 'Winner removed for {year}.', { year: yy }));
} catch (err) {
showResponse('danger', err.message || 'Network error');
} finally {
btn.disabled = false;
btn.textContent = old;
}
});
});
}
function applyWinnerFormFromCache() {
if (!winnerYearSelect) return;
const y = parseYear(winnerYearSelect.value);
if (!y) return;
if (winnerUserSelect) {
winnerUserSelect.value = '';
}
if (winnerPointsEl) {
// Convenience: if all winners share the same non-null points, pre-fill it.
const list = getWinnerForYear(y);
const winners = Array.isArray(list) ? list : [];
const ptsVals = winners
.map((w) => (w?.points === null || w?.points === undefined) ? null : Number(w.points))
.filter((n) => Number.isFinite(n));
const unique = Array.from(new Set(ptsVals));
winnerPointsEl.value = unique.length === 1 ? String(unique[0]) : '';
}
renderWinnerCurrent();
}
async function loadWinners(options = {}) {
const force = !!options.force;
if (winnersLoadedOnce && !force) return;
const list = await fetchWinners();
winnersLoadedOnce = true;
const grouped = new Map();
(Array.isArray(list) ? list : [])
.filter((w) => parseYear(w?.year))
.forEach((w) => {
const key = String(w.year);
const arr = grouped.get(key) || [];
arr.push({
year: Number(w.year),
userId: String(w.userId || ''),
name: w.name || null,
points: w.points ?? null,
});
grouped.set(key, arr);
});
winnersByYear = grouped;
applyWinnerFormFromCache();
}
async function loadWinnerUsers(options = {}) {
const force = !!options.force;
if (winnerUsersLoadedOnce && !force) return;
const users = await fetchAdminUsers();
winnerUsersLoadedOnce = true;
winnerUsers = Array.isArray(users) ? users : [];
winnerUsers.sort((a, b) => (a?.name || '').localeCompare((b?.name || ''), 'fr', { sensitivity: 'base' }));
buildWinnerUsersOptions(winnerUsers);
applyWinnerFormFromCache();
}
async function fetchActiveYear() {
try {
const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-store' });
if (!res.ok) throw new Error('Failed to fetch active year');
const data = await res.json();
const year = Number(data?.year);
return Number.isInteger(year) ? year : null;
} catch (_) {
return null;
}
}
async function setActiveYear(newYear) {
const res = await fetch('/api/settings/year', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ year: newYear })
});
if (!res.ok) {
let msg = `Erreur (${res.status})`;
try {
const data = await res.json();
msg = data.message || data.error || msg;
} catch (_) {}
throw new Error(msg);
}
const data = await res.json();
const year = Number(data?.year);
return Number.isInteger(year) ? year : newYear;
}
function isValidImdbId(value) {
return /^tt\d{5,}$/.test(String(value || '').trim());
}
document.getElementById('reset-form')?.addEventListener('click', function () {
form.reset();
responseEl.classList.add('d-none');
});
document.getElementById('log-off').addEventListener('click', function () {
localStorage.removeItem('auth_token');
window.location.href = '/';
});
// Modal 100% live preview wiring
function hookModal100Inputs() {
if (!completionModalTitleEl) return;
const inputs = [completionModalTitleEl, completionModalBodyTextEl, completionModalBodyHtmlEl, completionModalVideoSrcEl].filter(Boolean);
inputs.forEach((el) => {
el.addEventListener('input', () => {
updateCompletionModalPreview(getCompletionModalFormValue());
});
});
if (completionModalReloadBtn) {
completionModalReloadBtn.addEventListener('click', async () => {
completionModalReloadBtn.disabled = true;
const oldText = completionModalReloadBtn.textContent;
completionModalReloadBtn.textContent = 'Chargement...';
try {
await loadCompletionModalIntoUi();
showResponse('success', t('admin.content.reloaded', 'Content reloaded.'));
} catch (_) {
// loadCompletionModalIntoUi already handles messaging
} finally {
completionModalReloadBtn.disabled = false;
completionModalReloadBtn.textContent = oldText;
}
});
}
if (completionModalSaveBtn) {
completionModalSaveBtn.addEventListener('click', async () => {
completionModalSaveBtn.disabled = true;
const oldText = completionModalSaveBtn.textContent;
completionModalSaveBtn.textContent = t('common.saving', 'Saving...');
try {
const value = getCompletionModalFormValue();
// send raw HTML; server sanitizes/caps it
const saved = await saveCompletionModal(value);
setCompletionModalForm(saved);
updateCompletionModalPreview(getCompletionModalFormValue());
showResponse('success', t('admin.modal100.saved', '100% modal saved.'));
} catch (err) {
showResponse('danger', err.message || 'Network error');
} finally {
completionModalSaveBtn.disabled = false;
completionModalSaveBtn.textContent = oldText;
}
});
}
}
// Oscar date (settings tab) wiring
if (oscarDateYearSelect) {
oscarDateYearSelect.addEventListener('change', () => {
applyOscarDateSelection();
});
}
if (saveOscarDateBtn && oscarDateYearSelect && oscarDateValueInput) {
saveOscarDateBtn.addEventListener('click', async () => {
const y = parseYear(oscarDateYearSelect.value);
if (!y) {
showResponse('warning', t('admin.oscars.invalidYear', 'Invalid year. Expected: 2026'));
return;
}
const dateValue = String(oscarDateValueInput.value || '').trim();
if (dateValue && !isIsoDateString(dateValue)) {
showResponse('warning', 'Date invalide. Format attendu: YYYY-MM-DD');
return;
}
saveOscarDateBtn.disabled = true;
if (clearOscarDateBtn) clearOscarDateBtn.disabled = true;
const oldText = saveOscarDateBtn.textContent;
saveOscarDateBtn.textContent = t('common.saving', 'Saving...');
try {
const result = await setOscarDateForYear(y, dateValue);
if (result?.date) {
oscarDatesByYear[String(y)] = String(result.date);
showResponse('success', t('admin.oscars.dateSaved', 'Oscars date saved for {year}: {date}', { year: y, date: result.date }));
} else {
delete oscarDatesByYear[String(y)];
showResponse('success', t('admin.oscars.dateCleared', 'Oscars date cleared for {year} (reverted to default: March 15).', { year: y }));
}
await refreshYears();
} catch (err) {
showResponse('danger', err.message || 'Network error');
} finally {
saveOscarDateBtn.disabled = false;
if (clearOscarDateBtn) clearOscarDateBtn.disabled = false;
saveOscarDateBtn.textContent = oldText;
applyOscarDateSelection();
}
});
}
if (clearOscarDateBtn && oscarDateYearSelect && oscarDateValueInput) {
clearOscarDateBtn.addEventListener('click', async () => {
const y = parseYear(oscarDateYearSelect.value);
if (!y) {
showResponse('warning', t('admin.oscars.invalidYear', 'Invalid year. Expected: 2026'));
return;
}
clearOscarDateBtn.disabled = true;
if (saveOscarDateBtn) saveOscarDateBtn.disabled = true;
const oldText = clearOscarDateBtn.textContent;
clearOscarDateBtn.textContent = t('common.saving', 'Saving...');
try {
oscarDateValueInput.value = '';
await setOscarDateForYear(y, '');
delete oscarDatesByYear[String(y)];
showResponse('success', t('admin.oscars.dateCleared', 'Oscars date cleared for {year} (reverted to default: March 15).', { year: y }));
await refreshYears();
} catch (err) {
showResponse('danger', err.message || 'Network error');
} finally {
clearOscarDateBtn.disabled = false;
if (saveOscarDateBtn) saveOscarDateBtn.disabled = false;
clearOscarDateBtn.textContent = oldText;
applyOscarDateSelection();
}
});
}
if (deleteUserVerifyInputEl && confirmDeleteUserBtn) {
deleteUserVerifyInputEl.addEventListener('input', () => {
const expected = String(currentDeleteTarget?.email || '').trim();
const typed = String(deleteUserVerifyInputEl.value || '').trim();
confirmDeleteUserBtn.disabled = !(expected && typed && expected === typed);
});
}
if (copyTempPasswordBtn) {
copyTempPasswordBtn.addEventListener('click', async () => {
const text = String(resetUserTempPasswordEl?.textContent || '').trim();
if (!text || text === '—') return;
try {
await navigator.clipboard.writeText(text);
setAlert(resetUserModalResponseEl, 'success', t('admin.users.copied', 'Copied.'));
setTimeout(() => hideAlert(resetUserModalResponseEl), 1200);
} catch (_) {
// Fallback: select text for manual copy
setAlert(resetUserModalResponseEl, 'warning', t('admin.users.cannotCopyAuto', 'Unable to copy automatically. Copy manually.'));
}
});
}
if (copyAddUserTempPasswordBtn) {
copyAddUserTempPasswordBtn.addEventListener('click', async () => {
const text = String(addUserTempPasswordEl?.textContent || '').trim();
if (!text || text === '—') return;
try {
await navigator.clipboard.writeText(text);
setAlert(addUserResponseEl, 'success', t('admin.users.copied', 'Copied.'));
setTimeout(() => hideAlert(addUserResponseEl), 1200);
} catch (_) {
setAlert(addUserResponseEl, 'warning', t('admin.users.cannotCopyAuto', 'Unable to copy automatically. Copy manually.'));
}
});
}
if (openAddUserModalBtn) {
openAddUserModalBtn.addEventListener('click', () => openAddUserModal());
}
if (addUserModalEl) {
// Whenever the modal is opened, clear previous result/state.
addUserModalEl.addEventListener('shown.bs.modal', () => {
resetAddUserUi({ resetForm: true });
});
}
if (addUserResetBtn) {
addUserResetBtn.addEventListener('click', () => {
resetAddUserUi({ resetForm: true });
});
}
if (addUserForm) {
addUserForm.addEventListener('submit', async (e) => {
e.preventDefault();
resetAddUserUi({ resetForm: false });
const name = String(addUserNameEl?.value || '').trim();
const email = String(addUserEmailEl?.value || '').trim();
const admin = !!addUserAdminEl?.checked;
if (!name) {
setAlert(addUserResponseEl, 'warning', t('admin.users.nameRequired', 'Name required.'));
return;
}
if (!email) {
setAlert(addUserResponseEl, 'warning', t('admin.users.emailRequired', 'Email required.'));
return;
}
if (addUserSubmitBtn) addUserSubmitBtn.disabled = true;
setAlert(addUserResponseEl, 'warning', t('admin.users.creating', 'Creating…'));
try {
const res = await fetch('/api/users/admin/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ name, email, admin }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setAlert(addUserResponseEl, 'danger', data.message || data.error || `Erreur (${res.status})`);
if (addUserSubmitBtn) addUserSubmitBtn.disabled = false;
return;
}
// Show temp password once
if (addUserTempPasswordEl) addUserTempPasswordEl.textContent = data?.tempPassword || '—';
if (addUserExpiresAtEl) addUserExpiresAtEl.textContent = data?.expiresAt ? t('admin.users.tempPasswordExpires', 'Expires: {expiresAt}', { expiresAt: data.expiresAt }) : '';
if (addUserResultEl) addUserResultEl.classList.remove('d-none');
setAlert(addUserResponseEl, 'success', data.message || t('admin.users.userCreated', 'User created. Temporary password generated.'));
if (addUserSubmitBtn) addUserSubmitBtn.disabled = false;
// Refresh list if already loaded or when user tab is active
await loadAdminUsers({ force: true });
} catch (err) {
setAlert(addUserResponseEl, 'danger', err.message || 'Network error');
if (addUserSubmitBtn) addUserSubmitBtn.disabled = false;
}
});
}
if (confirmResetUserPasswordBtn) {
confirmResetUserPasswordBtn.addEventListener('click', async () => {
const target = currentResetTarget;
const userId = String(target?.id || '').trim();
if (!userId) return;
confirmResetUserPasswordBtn.disabled = true;
resetResetUserModalUi();
setAlert(resetUserModalResponseEl, 'warning', t('admin.users.tempPasswordGenerating', 'Generating temporary password…'));
try {
const res = await fetch('/api/users/admin/reset-temp-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ userId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setAlert(resetUserModalResponseEl, 'danger', data.message || data.error || `Erreur (${res.status})`);
confirmResetUserPasswordBtn.disabled = false;
return;
}
hideAlert(resetUserModalResponseEl);
if (resetUserTempPasswordEl) resetUserTempPasswordEl.textContent = data?.tempPassword || '—';
if (resetUserExpiresAtEl) {
resetUserExpiresAtEl.textContent = data?.expiresAt ? `Expire: ${data.expiresAt}` : '';
}
if (resetUserModalResultEl) resetUserModalResultEl.classList.remove('d-none');
setAlert(resetUserModalResponseEl, 'success', t('admin.users.tempPasswordGenerated', 'Temporary password generated. Copy it and send it to the user.'));
confirmResetUserPasswordBtn.disabled = false;
await loadAdminUsers({ force: true });
} catch (err) {
setAlert(resetUserModalResponseEl, 'danger', err.message || 'Network error');
confirmResetUserPasswordBtn.disabled = false;
}
});
}
if (confirmDeleteUserBtn) {
confirmDeleteUserBtn.addEventListener('click', async () => {
const target = currentDeleteTarget;
const userId = String(target?.id || '').trim();
if (!userId) return;
confirmDeleteUserBtn.disabled = true;
hideAlert(deleteUserModalResponseEl);
setAlert(deleteUserModalResponseEl, 'warning', 'Suppression...');
try {
const res = await fetch('/api/users/admin/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ userId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setAlert(deleteUserModalResponseEl, 'danger', data.message || data.error || `Erreur (${res.status})`);
// Keep disabled until the input matches again.
const expected = String(target?.email || '').trim();
const typed = String(deleteUserVerifyInputEl?.value || '').trim();
confirmDeleteUserBtn.disabled = !(expected && typed && expected === typed);
return;
}
setAlert(deleteUserModalResponseEl, 'success', data.message || t('admin.users.userDeleted', 'User deleted.'));
await loadAdminUsers({ force: true });
// Close shortly after success
setTimeout(() => {
try { deleteUserModal?.hide(); } catch (_) {}
}, 600);
} catch (err) {
setAlert(deleteUserModalResponseEl, 'danger', err.message || 'Network error');
}
});
}
if (refreshUsersBtn) {
refreshUsersBtn.addEventListener('click', async () => {
refreshUsersBtn.disabled = true;
const oldText = refreshUsersBtn.textContent;
refreshUsersBtn.textContent = 'Chargement...';
try {
await loadAdminUsers({ force: true });
} finally {
refreshUsersBtn.disabled = false;
refreshUsersBtn.textContent = oldText;
}
});
}
if (adminUsersTabBtn) {
// Only load when the tab is shown (and then cache), to keep initial load snappy.
adminUsersTabBtn.addEventListener('shown.bs.tab', () => loadAdminUsers({ force: false }));
}
function formatBytes(n) {
const v = Number(n);
if (!Number.isFinite(v) || v < 0) return '—';
if (v < 1024) return `${v} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let x = v / 1024;
let idx = 0;
while (x >= 1024 && idx < units.length - 1) {
x /= 1024;
idx += 1;
}
return `${x.toFixed(x >= 10 ? 1 : 2)} ${units[idx]}`;
}
function formatDateTime(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleString('fr-FR', { hour12: false });
}
async function fetchBackups() {
const res = await fetch('/api/admin/db/backups', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-store',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || data.error || `Erreur (${res.status})`);
}
return Array.isArray(data.backups) ? data.backups : [];
}
function renderBackups(backups) {
if (!dbBackupListBody) return;
const list = Array.isArray(backups) ? backups : [];
if (!list.length) {
const emptyText = t('admin.backup.noBackups', 'No backups found.');
dbBackupListBody.innerHTML = `<tr><td colspan="4" class="text-muted">${emptyText}</td></tr>`;
return;
}
dbBackupListBody.innerHTML = '';
const sorted = list.slice().sort((a, b) => {
const at = a?.mtime ? new Date(a.mtime).getTime() : 0;
const bt = b?.mtime ? new Date(b.mtime).getTime() : 0;
if (Number.isFinite(at) && Number.isFinite(bt) && at !== bt) return bt - at; // newest first
const an = String(a?.name || '');
const bn = String(b?.name || '');
return an.localeCompare(bn, 'fr', { sensitivity: 'base' });
});
sorted.forEach((b) => {
const name = String(b?.name || '');
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="font-monospace">${name || '—'}</td>
<td class="text-nowrap">${formatBytes(b?.sizeBytes)}</td>
<td class="text-nowrap">${formatDateTime(b?.mtime)}</td>
<td class="text-end text-nowrap">
<button type="button" class="btn btn-sm btn-outline-secondary btn-compact me-1" data-backup-download="${name}">${t('admin.backup.download','Download')}</button>
<button type="button" class="btn btn-sm btn-outline-danger btn-compact" data-backup-delete="${name}">${t('common.delete','Delete')}</button>
</td>
`;
dbBackupListBody.appendChild(tr);
});
dbBackupListBody.querySelectorAll('button[data-backup-download]').forEach((btn) => {
btn.addEventListener('click', async () => {
const name = btn.getAttribute('data-backup-download');
if (!name) return;
btn.disabled = true;
const old = btn.textContent;
btn.textContent = '...';
try {
const res = await fetch(`/api/admin/db/backups/${encodeURIComponent(name)}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || data.error || `Erreur (${res.status})`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
showResponse('danger', err.message || 'Network error');
} finally {
btn.disabled = false;
btn.textContent = old;
}
});
});
dbBackupListBody.querySelectorAll('button[data-backup-delete]').forEach((btn) => {
btn.addEventListener('click', async () => {
const name = btn.getAttribute('data-backup-delete');
if (!name) return;
const ok = window.confirm(t('admin.backup.confirmDelete', `Delete backup "${name}"?`, { name }));
if (!ok) return;
btn.disabled = true;
const old = btn.textContent;
btn.textContent = '...';
try {
const res = await fetch(`/api/admin/db/backups/${encodeURIComponent(name)}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || data.error || `Erreur (${res.status})`);
}
showResponse('success', data.message || t('admin.backup.deleted', 'Backup deleted.'));
await loadBackups({ force: true });
} catch (err) {
showResponse('danger', err.message || 'Network error');
} finally {
btn.disabled = false;
btn.textContent = old;
}
});
});
}
async function loadBackups(options = {}) {
const force = !!options.force;
if (!dbBackupListBody) return;
if (backupsLoadedOnce && !force) return;
dbBackupListBody.innerHTML = '<tr><td colspan="4" class="text-muted">Chargement…</td></tr>';
try {
const backups = await fetchBackups();
backupsLoadedOnce = true;
renderBackups(backups);
} catch (err) {
dbBackupListBody.innerHTML = `<tr><td colspan="4" class="text-danger">${err.message || 'Network error'}</td></tr>`;
}
}
async function createBackup() {
if (!dbBackupCreateBtn) return;
dbBackupCreateBtn.disabled = true;
const old = dbBackupCreateBtn.textContent;
dbBackupCreateBtn.textContent = t('admin.backup.creating', 'Creating…');
try {
const res = await fetch('/api/admin/db/backup', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || data.error || `Erreur (${res.status})`);
}
showResponse('success', t('admin.backup.created', 'Backup created: {name}', { name: data?.backup?.name || 'OK' }));
await loadBackups({ force: true });
} catch (err) {
showResponse('danger', err.message || 'Network error');
} finally {
dbBackupCreateBtn.disabled = false;
dbBackupCreateBtn.textContent = old;
}
}
async function runRestore() {
if (!dbRestoreRunBtn || !dbRestoreFileEl) return;
const file = dbRestoreFileEl.files && dbRestoreFileEl.files[0];
if (!file) {
showResponse('warning', t('admin.backup.chooseFile', 'Choose a backup file (.ndjson.gz).'));
return;
}
const drop = !!dbRestoreDropEl?.checked;
const warning = drop
? t('admin.backup.restoreWithDrop', 'RESTORE with DROP: all restored collections will be replaced.')
: t('admin.backup.restoreWithoutDrop', 'RESTORE without DROP: high risk of duplicates/errors if the DB already contains data.');
const ok = window.confirm(`${warning}\n\n${t('common.continueQuestion', 'Continue?')}`);
if (!ok) return;
dbRestoreRunBtn.disabled = true;
const old = dbRestoreRunBtn.textContent;
dbRestoreRunBtn.textContent = t('common.saving', 'Saving…');
if (dbRestoreResultEl) dbRestoreResultEl.textContent = '';
try {
const fd = new FormData();
fd.append('backup', file);
// drop passed as query (server supports both)
const res = await fetch(`/api/admin/db/restore?drop=${drop ? 'true' : 'false'}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: fd,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || data.error || `Erreur (${res.status})`);
}
showResponse('success', t('admin.backup.restoreComplete', 'Restore complete.'));
if (dbRestoreResultEl) {
const inserted = data?.inserted && typeof data.inserted === 'object' ? data.inserted : {};
const keys = Object.keys(inserted);
const summary = keys.length
? keys.map((k) => `${k}: ${inserted[k]}`).join(' | ')
: t('admin.backup.noDocumentsInserted', 'No documents inserted (or empty backup).');
dbRestoreResultEl.textContent = summary;
}
} catch (err) {
showResponse('danger', err.message || 'Network error');
} finally {
dbRestoreRunBtn.disabled = false;
dbRestoreRunBtn.textContent = old;
}
}
if (adminBackupTabBtn) {
adminBackupTabBtn.addEventListener('shown.bs.tab', () => loadBackups({ force: false }));
}
// Version tab wiring
if (adminVersionTabBtn) {
adminVersionTabBtn.addEventListener('shown.bs.tab', () => loadAppVersionTab({ force: false }));
}
// Active version is handled inside the history list (✓).
async function handleCreateAppVersion(activate) {
const version = String(appVersionNewVersionEl?.value || '').trim();
const message = String(appVersionNewMessageEl?.value || '').trim();
if (!version) {
setAppVersionAlert('warning', 'Version requise (ex: 1.0.0).');
return;
}
const btn = activate ? appVersionCreateAndActivateBtn : appVersionCreateBtn;
if (!btn) return;
btn.disabled = true;
const old = btn.textContent;
btn.textContent = '...';
try {
hideAppVersionAlert();
appVersionState = await createAppVersionEntry({ version, message, activate });
appVersionLoadedOnce = true;
if (appVersionNewVersionEl) appVersionNewVersionEl.value = '';
if (appVersionNewMessageEl) appVersionNewMessageEl.value = '';
renderAppVersionUI();
await refreshAppVersionPreview();
setAppVersionAlert('success', activate ? t('admin.version.createdAndActivated', 'Version created & activated.') : t('admin.version.created', 'Version created.'));
} catch (err) {
setAppVersionAlert('danger', err.message || 'Network error');
} finally {
btn.disabled = false;
btn.textContent = old;
}
}
if (appVersionCreateBtn) {
appVersionCreateBtn.addEventListener('click', () => handleCreateAppVersion(false));
}
if (appVersionCreateAndActivateBtn) {
appVersionCreateAndActivateBtn.addEventListener('click', () => handleCreateAppVersion(true));
}
if (dbBackupRefreshBtn) {
dbBackupRefreshBtn.addEventListener('click', async () => {
dbBackupRefreshBtn.disabled = true;
const old = dbBackupRefreshBtn.textContent;
dbBackupRefreshBtn.textContent = 'Chargement...';
try {
await loadBackups({ force: true });
} finally {
dbBackupRefreshBtn.disabled = false;
dbBackupRefreshBtn.textContent = old;
}
});
}
if (dbBackupCreateBtn) {
dbBackupCreateBtn.addEventListener('click', createBackup);
}
if (dbRestoreRunBtn) {
dbRestoreRunBtn.addEventListener('click', runRestore);
}
// Winners tab wiring
async function initWinnersTab(options = {}) {
const force = !!options.force;
try {
await Promise.all([
loadWinners({ force }),
loadWinnerUsers({ force }),
]);
renderWinnerCurrent();
} catch (err) {
showResponse('danger', err.message || 'Network error');
}
}
if (adminWinnersTabBtn) {
adminWinnersTabBtn.addEventListener('shown.bs.tab', () => initWinnersTab({ force: false }));
}
if (winnerYearSelect) {
winnerYearSelect.addEventListener('change', () => applyWinnerFormFromCache());
}
if (winnerReloadBtn) {
winnerReloadBtn.addEventListener('click', async () => {
winnerReloadBtn.disabled = true;
const old = winnerReloadBtn.textContent;
winnerReloadBtn.textContent = 'Chargement...';
try {
await initWinnersTab({ force: true });
showResponse('success', t('admin.winners.reloaded', 'Winners reloaded.'));
} finally {
winnerReloadBtn.disabled = false;
winnerReloadBtn.textContent = old;
}
});
}
if (winnerClearBtn) {
winnerClearBtn.addEventListener('click', async () => {
if (!winnerYearSelect) return;
const y = parseYear(winnerYearSelect.value);
if (!y) return;
const ok = window.confirm(t('admin.winners.confirmDeleteAll', 'Delete all winners for {year}?', { year: y }));
if (!ok) return;
try {
if (winnerUserSelect) winnerUserSelect.value = '';
if (winnerPointsEl) winnerPointsEl.value = '';
await addWinner(y, '', null);
await loadWinners({ force: true });
showResponse('success', t('admin.winners.deletedFor', 'Winner deleted for {year}.', { year: y }));
} catch (err) {
showResponse('danger', err.message || 'Network error');
}
});
}
if (winnerSaveBtn) {
winnerSaveBtn.addEventListener('click', async () => {
if (!winnerYearSelect) return;
const y = parseYear(winnerYearSelect.value);
if (!y) {
showResponse('warning', t('admin.oscars.invalidYearShort', 'Invalid year.'));
return;
}
const userId = String(winnerUserSelect?.value || '').trim();
if (!userId) {
showResponse('warning', t('admin.winners.chooseUser', 'Choose a user.'));
return;
}
const pointsRaw = String(winnerPointsEl?.value || '').trim();
const points = pointsRaw === '' ? null : Number(pointsRaw);
const pointsToSend = pointsRaw === '' || Number.isNaN(points) ? null : Math.round(points);
winnerSaveBtn.disabled = true;
const old = winnerSaveBtn.textContent;
winnerSaveBtn.textContent = t('common.saving', 'Saving…');
try {
await addWinner(y, userId, pointsToSend);
await loadWinners({ force: true });
showResponse('success', t('admin.winners.addedFor', 'Winner added for {year}.', { year: y }));
} catch (err) {
showResponse('danger', err.message || 'Network error');
} finally {
winnerSaveBtn.disabled = false;
winnerSaveBtn.textContent = old;
}
});
}
form.addEventListener('submit', async function (e) {
e.preventDefault();
const year = parseYear(yearInput.value);
const imdb_id = document.getElementById('imdb_id').value.trim();
const category = document.getElementById('category').value.trim();
const cam = false;
const vod_link = '';
const player_mode = 'auto';
const video_src = '';
const video_file = '';
const video_file_low = '';
const embed_src = '';
const subtitleFile = null;
const subtitle_lang = '';
const subtitle_label = '';
const subtitle_default = false;
if (!year) {
showResponse('warning', t('admin.oscars.invalidYearExample', 'Invalid year. Expected example: 2026'));
return;
}
if (!/^tt\d{5,}$/.test(imdb_id)) {
showResponse('warning', 'IMDB ID invalide. Exemple attendu: tt1234567');
return;
}
try {
const res = await fetch('/api/movies/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
year,
imdb_id,
category,
vod_link,
player_mode,
video_src,
video_file,
video_file_low,
embed_src,
subtitle_lang,
subtitle_label,
subtitle_default,
cam,
})
});
if (res.ok) {
const payload = await res.json().catch(() => ({}));
const movieId = payload?.movie?._id;
if (subtitleFile && movieId) {
try {
await uploadMovieSubtitle({
movieId,
file: subtitleFile,
lang: subtitle_lang,
label: subtitle_label,
isDefault: subtitle_default,
});
showResponse('success', t('admin.movies.addedWithSubtitles', 'Movie added with subtitles.'));
} catch (err) {
showResponse('warning', t('admin.movies.addedNoSubtitles', 'Movie added, but subtitles were not uploaded: {error}', { error: err.message || 'Error' }));
}
} else {
showResponse('success', t('admin.movies.addedSuccessfully', 'Movie added successfully.'));
}
form.reset();
await refreshYears();
await loadMoviesForManagement();
return;
}
let errMessage = `Erreur (${res.status})`;
try {
const data = await res.json();
errMessage = data.message || data.error || errMessage;
} catch (_) {
// ignore JSON parse errors
}
showResponse('danger', errMessage);
} catch (err) {
showResponse('danger', err.message || 'Network error');
}
});
async function refreshYears() {
try {
const yearsRes = await fetch('/api/movies/years', { cache: 'no-store' });
const years = yearsRes.ok ? await yearsRes.json() : [];
const cleanedYears = (Array.isArray(years) ? years : [])
.map((y) => Number(y))
.filter((y) => Number.isInteger(y) && y >= 1900 && y <= 3000)
.sort((a, b) => b - a);
// Ensure Oscar dates cache is loaded (for settings UI).
await ensureOscarDatesLoaded();
// Rebuild manage dropdown from scratch (so removed years disappear)
const previousSelection = manageYearSelect.value;
manageYearSelect.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = '';
allOpt.textContent = t('admin.movies.allYears', 'All');
manageYearSelect.appendChild(allOpt);
cleanedYears.forEach((y) => {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
manageYearSelect.appendChild(opt);
});
const stillExists = cleanedYears.map(String).includes(previousSelection);
manageYearSelect.value = stillExists ? previousSelection : '';
// Prefer defaulting UI to active year (if selection is empty/invalid).
const hasSelection = !!manageYearSelect.value;
if (!hasSelection && activeYear && cleanedYears.map(String).includes(String(activeYear))) {
manageYearSelect.value = String(activeYear);
}
// Default add-year to active year (or latest year) if empty
if (!yearInput.value) {
if (activeYear) {
yearInput.value = String(activeYear);
} else if (cleanedYears.length > 0) {
yearInput.value = String(cleanedYears[0]);
}
}
// Rebuild active-year dropdown from distinct movie years
if (activeYearInput && saveActiveYearBtn) {
const previousActiveSelection = String(activeYearInput.value || '');
activeYearInput.innerHTML = '';
if (cleanedYears.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = t('admin.settings.noYearAddMovie', 'No year (add a movie)');
opt.disabled = true;
opt.selected = true;
activeYearInput.appendChild(opt);
activeYearInput.disabled = true;
saveActiveYearBtn.disabled = true;
} else {
cleanedYears.forEach((y) => {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
activeYearInput.appendChild(opt);
});
// Keep current selection if still valid, else use activeYear, else latest.
const desired =
(previousActiveSelection && cleanedYears.map(String).includes(previousActiveSelection))
? previousActiveSelection
: (activeYear && cleanedYears.map(String).includes(String(activeYear)))
? String(activeYear)
: String(cleanedYears[0]);
activeYearInput.disabled = false;
saveActiveYearBtn.disabled = false;
activeYearInput.value = desired;
}
}
// Oscar date year dropdown (settings tab)
if (oscarDateYearSelect && oscarDateValueInput && saveOscarDateBtn) {
const previousOscarYearSelection = String(oscarDateYearSelect.value || '');
oscarDateYearSelect.innerHTML = '';
if (cleanedYears.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = t('admin.settings.noYearAddMovie', 'No year (add a movie)');
opt.disabled = true;
opt.selected = true;
oscarDateYearSelect.appendChild(opt);
oscarDateYearSelect.disabled = true;
oscarDateValueInput.disabled = true;
saveOscarDateBtn.disabled = true;
if (clearOscarDateBtn) clearOscarDateBtn.disabled = true;
} else {
cleanedYears.forEach((y) => {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
oscarDateYearSelect.appendChild(opt);
});
const desired =
(previousOscarYearSelection && cleanedYears.map(String).includes(previousOscarYearSelection))
? previousOscarYearSelection
: (activeYear && cleanedYears.map(String).includes(String(activeYear)))
? String(activeYear)
: String(cleanedYears[0]);
oscarDateYearSelect.disabled = false;
oscarDateValueInput.disabled = false;
saveOscarDateBtn.disabled = false;
if (clearOscarDateBtn) clearOscarDateBtn.disabled = false;
oscarDateYearSelect.value = desired;
}
}
applyOscarDateSelection();
renderOscarDatesCurrent(cleanedYears);
// Winners year dropdown (optional tab)
setWinnerYearOptions(cleanedYears);
applyWinnerFormFromCache();
// Categories year dropdowns
const manualYearSelect = document.getElementById('manual-year');
const listYearFilterSelect = document.getElementById('list-year-filter');
// Populate manual creation year dropdown
if (manualYearSelect) {
const previousManualYear = manualYearSelect.value;
manualYearSelect.innerHTML = '';
if (cleanedYears.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = t('admin.settings.noYearAddMovie', 'No year (add a movie)');
opt.disabled = true;
manualYearSelect.appendChild(opt);
} else {
cleanedYears.forEach((y) => {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
manualYearSelect.appendChild(opt);
});
const desired = previousManualYear && cleanedYears.map(String).includes(previousManualYear)
? previousManualYear
: (activeYear && cleanedYears.map(String).includes(String(activeYear)))
? String(activeYear)
: cleanedYears.length > 0 ? String(cleanedYears[0]) : '';
if (desired) manualYearSelect.value = desired;
}
}
// Populate list filter year dropdown
if (listYearFilterSelect) {
const previousListYear = listYearFilterSelect.value;
listYearFilterSelect.innerHTML = '';
// Add "All years" option
const allOpt = document.createElement('option');
allOpt.value = '';
allOpt.textContent = t('admin.categories.allYears', 'All years');
listYearFilterSelect.appendChild(allOpt);
if (cleanedYears.length > 0) {
cleanedYears.forEach((y) => {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
listYearFilterSelect.appendChild(opt);
});
// Keep previous selection if still valid
if (previousListYear && cleanedYears.map(String).includes(previousListYear)) {
listYearFilterSelect.value = previousListYear;
} else if (activeYear && cleanedYears.map(String).includes(String(activeYear))) {
listYearFilterSelect.value = String(activeYear);
} else {
listYearFilterSelect.value = '';
}
}
}
} catch (_) {
// ignore
}
}
function getSelectedMovieIds() {
return Array.from(adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]:checked'))
.map((el) => el.getAttribute('data-movie-id'))
.filter(Boolean);
}
function updateSelectionUI() {
const selectedIds = getSelectedMovieIds();
selectedCountEl.textContent = String(selectedIds.length);
deleteSelectedBtn.disabled = selectedIds.length === 0;
if (updateOmdbSelectedBtn) updateOmdbSelectedBtn.disabled = selectedIds.length === 0;
const allBoxes = Array.from(adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]'));
const checked = allBoxes.filter((b) => b.checked).length;
selectAllBox.indeterminate = checked > 0 && checked < allBoxes.length;
selectAllBox.checked = allBoxes.length > 0 && checked === allBoxes.length;
}
function renderSubtitleList(movie) {
if (!editSubtitleListEl) return;
const subs = getMovieSubtitles(movie);
if (!subs.length) {
editSubtitleListEl.innerHTML = '<span class="text-muted">—</span>';
return;
}
editSubtitleListEl.innerHTML = subs.map((s) => {
const id = escapeHtml(String(s?._id || ''));
const lang = escapeHtml(String(s?.lang || ''));
const label = escapeHtml(String(s?.label || ''));
const file = escapeHtml(String(s?.file || ''));
const title = label || lang || file || 'Sous-titres';
const meta = [lang, label].filter(Boolean).join(' · ');
const isDefault = s?.default ? `<span class="badge bg-success ms-2">${t('admin.settings.defaultBadge', 'Default')}</span>` : '';
return `
<div class="d-flex align-items-center justify-content-between gap-2">
<div class="small">
<span class="fw-semibold">${title}</span>
${meta ? `<span class="text-muted ms-2">${meta}</span>` : ''}
${isDefault}
</div>
<button type="button" class="btn btn-sm btn-outline-danger" data-remove-subtitle-id="${id}">
${t('common.delete','Delete')}
</button>
</div>
`;
}).join('');
editSubtitleListEl.querySelectorAll('[data-remove-subtitle-id]').forEach((btn) => {
btn.addEventListener('click', async () => {
const subtitleId = btn.getAttribute('data-remove-subtitle-id');
const movieId = String(editMovieIdEl?.value || '');
if (!movieId || !subtitleId) return;
if (!confirm(t('admin.movies.confirmRemoveSubtitle','Delete this subtitle?'))) return;
try {
btn.disabled = true;
const updated = await removeMovieSubtitle({ movieId, subtitleId });
moviesById.set(updated._id, updated);
renderSubtitleList(updated);
showResponse('success', t('admin.movies.subtitleDeleted', 'Subtitle deleted.'));
await loadMoviesForManagement();
} catch (err) {
showResponse('danger', err.message || 'Network error');
} finally {
btn.disabled = false;
}
});
});
}
function openEditModal(movieId) {
if (!editMovieModal) return;
const movie = moviesById.get(movieId);
if (!movie) return;
editMovieIdEl.value = movie._id || '';
if (editMovieIdLabelEl) editMovieIdLabelEl.textContent = movie._id || '—';
editImdbIdEl.value = movie.imdb_id || '';
editYearEl.value = movie.year ? String(movie.year) : '';
editCategoryEl.value = movie.category || '';
editVodLinkEl.value = movie.vod_link || '';
if (editPlayerModeEl) editPlayerModeEl.value = movie.player_mode || 'auto';
if (editVideoSrcEl) editVideoSrcEl.value = movie.video_src || '';
if (editVideoFileEl) editVideoFileEl.value = movie.video_file || '';
if (editVideoFileLowEl) editVideoFileLowEl.value = movie.video_file_low || '';
if (editSubtitleLangEl) editSubtitleLangEl.value = movie.subtitle_lang || '';
if (editSubtitleLabelEl) editSubtitleLabelEl.value = movie.subtitle_label || '';
if (editSubtitleDefaultEl) editSubtitleDefaultEl.checked = !!movie.subtitle_default;
renderSubtitleList(movie);
if (editSubtitleRemoveEl) editSubtitleRemoveEl.checked = false;
if (editSubtitleFileEl) editSubtitleFileEl.value = '';
if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || '';
editRefreshOmdbEl.checked = false;
editTitleEl.value = movie.title || '';
editRatingEl.value = movie.rating || '';
if (editRuntimeEl) editRuntimeEl.value = movie.runtime || '';
editPosterEl.value = movie.poster || '';
editDescriptionEl.value = movie.description || '';
if (editCamFlagEl) editCamFlagEl.checked = !!movie.cam;
if (editMovieModalTitleEl) {
const label = movie.title ? `Modifier: ${movie.title}` : 'Modifier le film';
editMovieModalTitleEl.textContent = label;
}
editMovieModal.show();
}
async function saveMovieChanges() {
const movieId = (editMovieIdEl.value || '').trim();
if (!movieId) return;
const imdb_id = (editImdbIdEl.value || '').trim();
const yearRaw = (editYearEl.value || '').trim();
const category = (editCategoryEl.value || '').trim();
const vod_link = (editVodLinkEl.value || '').trim();
const player_mode = (editPlayerModeEl?.value || 'auto').trim();
const video_src = (editVideoSrcEl?.value || '').trim();
const video_file = (editVideoFileEl?.value || '').trim();
const video_file_low = (editVideoFileLowEl?.value || '').trim();
const embed_src = (editEmbedSrcEl?.value || '').trim();
const subtitle_lang = normalizeSubtitleText(editSubtitleLangEl?.value, 24);
const subtitle_label = normalizeSubtitleText(editSubtitleLabelEl?.value, 80);
const subtitle_default = !!editSubtitleDefaultEl?.checked;
const subtitle_remove = !!editSubtitleRemoveEl?.checked;
const subtitleFile = subtitle_remove ? null : (editSubtitleFileEl?.files?.[0] || null);
const refreshOmdb = !!editRefreshOmdbEl.checked;
const cam = !!editCamFlagEl?.checked;
const title = editTitleEl.value;
const rating = editRatingEl.value;
const runtime = editRuntimeEl ? editRuntimeEl.value : '';
const poster = editPosterEl.value;
const description = editDescriptionEl.value;
if (!isValidImdbId(imdb_id)) {
showResponse('warning', 'IMDB ID invalide. Exemple attendu: tt1234567');
return;
}
let year = null;
if (yearRaw !== '') {
year = parseYear(yearRaw);
if (!year) {
showResponse('warning', t('admin.oscars.invalidYearExample', 'Invalid year. Expected example: 2026'));
return;
}
}
if (!category) {
showResponse('warning', t('admin.categories.categoryInvalid', 'Invalid category.'));
return;
}
const body = {
imdb_id,
year: yearRaw === '' ? null : year,
category,
vod_link,
player_mode,
video_src,
video_file,
video_file_low,
embed_src,
subtitle_remove,
refreshOmdb,
cam
};
if (!subtitle_remove && !subtitleFile) {
body.subtitle_lang = subtitle_lang;
body.subtitle_label = subtitle_label;
body.subtitle_default = subtitle_default;
}
// Only send manual fields if we're not refreshing from OMDb
if (!refreshOmdb) {
body.title = title;
body.description = description;
body.rating = rating;
body.runtime = runtime;
body.poster = poster;
}
try {
saveMovieChangesBtn.disabled = true;
saveMovieChangesBtn.textContent = t('common.saving', 'Saving...');
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(body)
});
if (!res.ok) {
let msg = `Erreur (${res.status})`;
try {
const data = await res.json();
msg = data.message || data.error || msg;
} catch (_) {}
showResponse('danger', msg);
return;
}
const updated = await res.json();
if (subtitleFile) {
try {
const uploaded = await uploadMovieSubtitle({
movieId,
file: subtitleFile,
lang: subtitle_lang,
label: subtitle_label,
isDefault: subtitle_default,
});
if (uploaded) moviesById.set(uploaded._id, uploaded);
} catch (err) {
showResponse('warning', t('admin.movies.updatedNoSubtitles', 'Movie updated, but subtitles were not uploaded: {error}', { error: err.message || 'Error' }));
}
}
moviesById.set(updated._id, updated);
showResponse('success', t('admin.movies.updatedSuccessfully', 'Movie updated successfully.'));
editMovieModal.hide();
await refreshYears();
await loadMoviesForManagement();
} catch (err) {
showResponse('danger', err.message || 'Network error');
} finally {
saveMovieChangesBtn.disabled = false;
saveMovieChangesBtn.textContent = 'Enregistrer';
}
}
async function loadMoviesForManagement() {
const year = manageYearSelect.value;
const url = year ? `/api/movies?year=${encodeURIComponent(year)}` : '/api/movies';
adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-muted">Chargement…</td></tr>`;
try {
const res = await fetch(url, { method: 'GET', cache: 'no-store' });
if (!res.ok) {
adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-danger">Erreur lors du chargement (${res.status})</td></tr>`;
return;
}
const movies = await res.json();
movies.sort((a, b) => (a.title || '').localeCompare((b.title || ''), 'fr', { sensitivity: 'base' }));
moviesById = new Map(movies.map((m) => [m._id, m]));
if (!movies.length) {
adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-muted">${t('admin.movies.noMovies', 'No movies.')}</td></tr>`;
updateSelectionUI();
return;
}
function buildPlayerBadges(m) {
const badges = [];
const hasVideo = !!(m && m.video_src);
const hasFile = !!(m && m.video_file);
const hasLowFile = !!(m && m.video_file_low);
const hasEmbed = !!(m && m.embed_src);
const hasLegacy = !!(m && m.vod_link);
const hasSubtitle = Array.isArray(m?.subtitles) ? m.subtitles.length > 0 : !!(m && m.subtitle_file);
if (hasVideo) badges.push({ text: 'Video', cls: 'bg-primary' });
if (hasFile) badges.push({ text: 'Server file', cls: 'bg-dark' });
if (hasLowFile) badges.push({ text: 'Low file', cls: 'bg-secondary' });
if (hasEmbed) badges.push({ text: 'Embed', cls: 'bg-info text-dark' });
if (hasLegacy) badges.push({ text: 'Legacy', cls: 'bg-secondary' });
if (hasSubtitle) badges.push({ text: 'Subtitles', cls: 'bg-warning text-dark' });
const modeRaw = String(m?.player_mode || 'auto').toLowerCase();
const mode = (modeRaw === 'video' || modeRaw === 'embed' || modeRaw === 'auto') ? modeRaw : 'auto';
const modeText = mode === 'auto' ? 'Auto' : mode === 'video' ? 'Mode: Video' : 'Mode: Embed';
badges.push({ text: modeText, cls: 'bg-light text-dark border' });
if (!hasVideo && !hasEmbed && !hasLegacy) {
return [{ text: '—', cls: 'bg-light text-dark border' }];
}
return badges;
}
adminMoviesBody.innerHTML = '';
movies.forEach((m) => {
const badges = buildPlayerBadges(m);
const badgesHtml = badges
.map((b) => `<span class="badge ${b.cls} me-1 mb-1">${b.text}</span>`)
.join('');
const movieId = String(m._id || '');
const shortId = movieId ? `${movieId.slice(0, 6)}…${movieId.slice(-4)}` : '—';
const tr = document.createElement('tr');
tr.innerHTML = `
<td>
<input type="checkbox" class="form-check-input" data-movie-id="${m._id}">
</td>
<td class="text-nowrap">
<button type="button" class="btn btn-sm btn-outline-secondary font-monospace" data-copy-movie-id="${escapeHtml(movieId)}" title="${escapeHtml(movieId)}">
${escapeHtml(shortId)}
</button>
</td>
<td>
<div class="fw-semibold">${m.title || '(sans titre)'}</div>
<div class="text-muted small">${m.imdb_id || ''}</div>
</td>
<td>${badgesHtml}</td>
<td>${m.category || ''}</td>
<td>${m.year || ''}</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-secondary" data-edit-movie-id="${m._id}">
${t('common.edit','Edit')}
</button>
</td>
`;
adminMoviesBody.appendChild(tr);
});
adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]').forEach((cb) => {
cb.addEventListener('change', updateSelectionUI);
});
adminMoviesBody.querySelectorAll('button[data-edit-movie-id]').forEach((btn) => {
btn.addEventListener('click', () => openEditModal(btn.getAttribute('data-edit-movie-id')));
});
adminMoviesBody.querySelectorAll('button[data-copy-movie-id]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.getAttribute('data-copy-movie-id');
if (!id) return;
const ok = await copyToClipboard(id);
if (ok) {
showResponse('success', t('admin.movies.idCopied', 'Movie ID copied.'));
} else {
showResponse('warning', t('admin.users.cannotCopyAuto', 'Unable to copy automatically. Copy manually.'));
}
});
});
updateSelectionUI();
} catch (err) {
adminMoviesBody.innerHTML = `<tr><td colspan="7" class="text-danger">${err.message || 'Network error'}</td></tr>`;
}
}
refreshMoviesBtn.addEventListener('click', loadMoviesForManagement);
manageYearSelect.addEventListener('change', loadMoviesForManagement);
selectAllBox.addEventListener('change', () => {
const checked = selectAllBox.checked;
adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]').forEach((cb) => {
cb.checked = checked;
});
updateSelectionUI();
});
deleteSelectedBtn.addEventListener('click', async () => {
const selectedIds = getSelectedMovieIds();
if (!selectedIds.length) return;
const yearLabel = manageYearSelect.value ? ` (${manageYearSelect.value})` : '';
const ok = window.confirm(t('admin.movies.confirmDeleteSelected', `Delete ${selectedIds.length} movie(s)${yearLabel}? This action is irreversible.`, { count: selectedIds.length }));
if (!ok) return;
try {
const res = await fetch('/api/movies/delete', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ ids: selectedIds })
});
if (!res.ok) {
let msg = `Erreur (${res.status})`;
try {
const data = await res.json();
msg = data.message || data.error || msg;
} catch (_) {}
showResponse('danger', msg);
return;
}
showResponse('success', t('admin.movies.deletedSuccessfully', 'Movie(s) deleted successfully.'));
await refreshYears();
await loadMoviesForManagement();
} catch (err) {
showResponse('danger', err.message || 'Network error');
}
});
if (updateOmdbSelectedBtn) {
updateOmdbSelectedBtn.addEventListener('click', async () => {
const selectedIds = getSelectedMovieIds();
if (!selectedIds.length) return;
const yearLabel = manageYearSelect.value ? ` (${manageYearSelect.value})` : '';
const ok = window.confirm(t('admin.movies.confirmRefreshOmdb', `Refresh ${selectedIds.length} movie(s)${yearLabel} from OMDb? This operation may overwrite info (title, plot, rating, poster, runtime).`, { count: selectedIds.length }));
if (!ok) return;
updateOmdbSelectedBtn.disabled = true;
updateOmdbSelectedBtn.textContent = 'Refresh OMDb...';
let successCount = 0;
let errorCount = 0;
const errors = [];
for (const movieId of selectedIds) {
try {
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ refreshOmdb: true })
});
if (!res.ok) {
let msg = `Erreur (${res.status})`;
try {
const data = await res.json();
msg = data.message || data.error || msg;
} catch (_) {}
errors.push(`${movieId}: ${msg}`);
errorCount++;
} else {
successCount++;
}
} catch (err) {
errors.push(`${movieId}: ${err.message || 'Network error'}`);
errorCount++;
}
}
updateOmdbSelectedBtn.disabled = false;
updateOmdbSelectedBtn.textContent = 'Refresh OMDb';
if (errorCount === 0) {
showResponse('success', t('admin.movies.updatedFromOmdb', '{count} movie(s) updated from OMDb.', { count: successCount }));
} else if (successCount === 0) {
showResponse('danger', `Failed for ${errorCount} movie(s). ${errors.slice(0, 3).join('; ')}`);
} else {
const details = errors.slice(0, 2).join('; ');
showResponse('warning', t('admin.movies.omdbResults', '{success} success, {errors} error(s). {details}', {
success: successCount,
errors: errorCount,
details,
}));
}
await loadMoviesForManagement();
});
}
if (saveMovieChangesBtn) {
saveMovieChangesBtn.addEventListener('click', saveMovieChanges);
}
if (copyMovieIdBtn) {
copyMovieIdBtn.addEventListener('click', async () => {
const id = String(editMovieIdEl?.value || '').trim();
if (!id) return;
const ok = await copyToClipboard(id);
if (ok) {
showResponse('success', t('admin.movies.idCopied', 'Movie ID copied.'));
} else {
showResponse('warning', t('admin.users.cannotCopyAuto', 'Unable to copy automatically. Copy manually.'));
}
});
}
// Initial load
activeYear = await fetchActiveYear();
if (activeYear) {
document.title = `Pool Oscars (${activeYear}) - Admin`;
}
hookModal100Inputs();
await loadCompletionModalIntoUi();
await loadPlayerUiIntoUi();
if (playerUiReloadBtn) {
playerUiReloadBtn.addEventListener('click', async () => {
playerUiReloadBtn.disabled = true;
const old = playerUiReloadBtn.textContent;
playerUiReloadBtn.textContent = 'Chargement...';
try {
await loadPlayerUiIntoUi();
showResponse('success', t('admin.player.settingsReloaded', 'Player settings reloaded.'));
} finally {
playerUiReloadBtn.disabled = false;
playerUiReloadBtn.textContent = old;
}
});
}
if (playerUiSaveBtn) {
playerUiSaveBtn.addEventListener('click', async () => {
playerUiSaveBtn.disabled = true;
const old = playerUiSaveBtn.textContent;
playerUiSaveBtn.textContent = 'Saving...';
try {
const payload = getPlayerUiFormValue();
const saved = await savePlayerUi(payload);
setPlayerUiForm(saved);
showResponse('success', t('admin.player.settingsSaved', 'Player settings saved.'));
} catch (err) {
showResponse('danger', err.message || 'Network error');
} finally {
playerUiSaveBtn.disabled = false;
playerUiSaveBtn.textContent = old;
}
});
}
if (saveActiveYearBtn && activeYearInput) {
saveActiveYearBtn.addEventListener('click', async () => {
const y = parseYear(activeYearInput.value);
if (!y) {
showResponse('warning', t('admin.oscars.invalidYear', 'Invalid year. Expected: 2026'));
return;
}
saveActiveYearBtn.disabled = true;
const oldText = saveActiveYearBtn.textContent;
saveActiveYearBtn.textContent = 'Application...';
try {
activeYear = await setActiveYear(y);
activeYearInput.value = String(activeYear);
yearInput.value = String(activeYear);
document.title = `Pool Oscars (${activeYear}) - Admin`;
showResponse('success', t('admin.activeYear.applied', 'Active year applied: {year}', { year: activeYear }));
await refreshYears();
await loadMoviesForManagement();
} catch (err) {
showResponse('danger', err.message || 'Network error');
} finally {
saveActiveYearBtn.disabled = false;
saveActiveYearBtn.textContent = oldText;
}
});
}
await refreshYears();
await loadMoviesForManagement();
// Points configuration
const pointsPerMovieInput = document.getElementById('points-per-movie');
const pointsPerCorrectPickInput = document.getElementById('points-per-correct-pick');
const pointsConfigReloadBtn = document.getElementById('points-config-reload');
const pointsConfigSaveBtn = document.getElementById('points-config-save');
const pointsConfigAlert = document.getElementById('points-config-alert');
const pointsPerMoviePreview = document.getElementById('points-per-movie-preview');
const pointsPerCorrectPickPreview = document.getElementById('points-per-correct-pick-preview');
async function loadPointsConfig() {
try {
const res = await fetch('/api/settings/points-config', { cache: 'no-store' });
if (!res.ok) throw new Error('Failed to load points config');
const config = await res.json();
if (pointsPerMovieInput) pointsPerMovieInput.value = config.pointsPerMovie || 1;
if (pointsPerCorrectPickInput) pointsPerCorrectPickInput.value = config.pointsPerCorrectPick || 1;
if (pointsPerMoviePreview) pointsPerMoviePreview.textContent = config.pointsPerMovie || 1;
if (pointsPerCorrectPickPreview) pointsPerCorrectPickPreview.textContent = config.pointsPerCorrectPick || 1;
} catch (err) {
console.error('Error loading points config:', err);
}
}
function updatePointsPreview() {
const moviePoints = pointsPerMovieInput ? parseInt(pointsPerMovieInput.value) || 1 : 1;
const pickPoints = pointsPerCorrectPickInput ? parseInt(pointsPerCorrectPickInput.value) || 1 : 1;
if (pointsPerMoviePreview) pointsPerMoviePreview.textContent = moviePoints;
if (pointsPerCorrectPickPreview) pointsPerCorrectPickPreview.textContent = pickPoints;
}
if (pointsPerMovieInput) {
pointsPerMovieInput.addEventListener('input', updatePointsPreview);
}
if (pointsPerCorrectPickInput) {
pointsPerCorrectPickInput.addEventListener('input', updatePointsPreview);
}
if (pointsConfigReloadBtn) {
pointsConfigReloadBtn.addEventListener('click', async () => {
await loadPointsConfig();
showResponse('success', t('admin.configuration.reloaded', 'Configuration reloaded.'));
});
}
if (pointsConfigSaveBtn) {
pointsConfigSaveBtn.addEventListener('click', async () => {
const pointsPerMovie = parseInt(pointsPerMovieInput?.value) || 1;
const pointsPerCorrectPick = parseInt(pointsPerCorrectPickInput?.value) || 1;
if (pointsPerMovie < 0 || pointsPerCorrectPick < 0) {
if (pointsConfigAlert) {
pointsConfigAlert.className = 'alert alert-warning';
pointsConfigAlert.textContent = t('admin.settings.pointsMustBeNonNegative', 'Points must be zero or positive');
pointsConfigAlert.classList.remove('d-none');
}
return;
}
try {
pointsConfigSaveBtn.disabled = true;
const oldText = pointsConfigSaveBtn.textContent;
pointsConfigSaveBtn.textContent = t('common.saving', 'Saving...');
const res = await fetch('/api/settings/points-config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
pointsPerMovie,
pointsPerCorrectPick
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to save');
}
const data = await res.json();
updatePointsPreview();
showResponse('success', t('admin.settings.pointsSaved', 'Configuration saved: {p} pts/movie, {q} pts/correct pick', { p: data.pointsPerMovie, q: data.pointsPerCorrectPick }));
} catch (err) {
if (pointsConfigAlert) {
pointsConfigAlert.className = 'alert alert-danger';
pointsConfigAlert.textContent = 'Erreur: ' + err.message;
pointsConfigAlert.classList.remove('d-none');
}
} finally {
pointsConfigSaveBtn.disabled = false;
pointsConfigSaveBtn.textContent = oldText;
}
});
}
await loadPointsConfig();
// Visibility config
const visibilityShowPicksFeaturesEl = document.getElementById('visibility-show-picks-features');
const visibilityShowVersionTabEl = document.getElementById('visibility-show-version-tab');
const visibilityConfigReloadBtn = document.getElementById('visibility-config-reload');
const visibilityConfigSaveBtn = document.getElementById('visibility-config-save');
const visibilityConfigAlert = document.getElementById('visibility-config-alert');
function applyVersionTabVisibility(showVersionTab) {
const versionTabBtn = document.getElementById('admin-version-tab');
const versionPane = document.getElementById('admin-version');
if (versionTabBtn) versionTabBtn.style.display = showVersionTab ? '' : 'none';
if (versionPane) versionPane.style.display = showVersionTab ? '' : 'none';
if (!showVersionTab) {
const activeTab = document.querySelector('#adminTabs .nav-link.active');
if (activeTab && activeTab.id === 'admin-version-tab') {
const fallback = document.getElementById('admin-settings-tab');
if (fallback) fallback.click();
}
}
}
async function loadVisibilityConfig() {
try {
const res = await fetch('/api/settings/visibility-config', { cache: 'no-store' });
if (!res.ok) throw new Error('Failed to load visibility config');
const config = await res.json();
// Both settings should be the same - use showPicksButton as the master
const isEnabled = config.showPicksButton !== false && config.showBonPicksColumn !== false;
if (visibilityShowPicksFeaturesEl) visibilityShowPicksFeaturesEl.checked = isEnabled;
if (visibilityShowVersionTabEl) visibilityShowVersionTabEl.checked = config.showVersionTab !== false;
applyVersionTabVisibility(config.showVersionTab !== false);
} catch (err) {
console.error('Error loading visibility config:', err);
// Default to showing both
if (visibilityShowPicksFeaturesEl) visibilityShowPicksFeaturesEl.checked = false;
if (visibilityShowVersionTabEl) visibilityShowVersionTabEl.checked = true;
applyVersionTabVisibility(true);
}
}
await loadVisibilityConfig();
if (visibilityConfigReloadBtn) {
visibilityConfigReloadBtn.addEventListener('click', async () => {
visibilityConfigReloadBtn.disabled = true;
const oldText = visibilityConfigReloadBtn.textContent;
visibilityConfigReloadBtn.textContent = 'Chargement...';
try {
await Promise.all([
loadVisibilityConfig(),
loadPlayerUiIntoUi()
]);
showResponse('success', t('admin.configuration.reloaded', 'Configuration reloaded.'));
} catch (err) {
showResponse('warning', 'Erreur lors du rechargement: ' + (err.message || 'Erreur inconnue'));
} finally {
visibilityConfigReloadBtn.disabled = false;
visibilityConfigReloadBtn.textContent = oldText;
}
});
}
if (visibilityConfigSaveBtn) {
visibilityConfigSaveBtn.addEventListener('click', async () => {
const isEnabled = visibilityShowPicksFeaturesEl ? visibilityShowPicksFeaturesEl.checked : true;
const showPicksButton = isEnabled;
const showBonPicksColumn = isEnabled;
const showVersionTab = visibilityShowVersionTabEl ? visibilityShowVersionTabEl.checked : true;
try {
visibilityConfigSaveBtn.disabled = true;
const oldText = visibilityConfigSaveBtn.textContent;
visibilityConfigSaveBtn.textContent = t('common.saving', 'Saving...');
// Save both visibility config and player UI settings
const [visibilityRes, playerUiPayload] = await Promise.all([
fetch('/api/settings/visibility-config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
showPicksButton,
showBonPicksColumn,
showVersionTab
})
}),
Promise.resolve(getPlayerUiFormValue())
]);
if (!visibilityRes.ok) {
const error = await visibilityRes.json();
throw new Error(error.message || 'Failed to save visibility config');
}
// Save player UI settings
try {
const saved = await savePlayerUi(playerUiPayload);
setPlayerUiForm(saved);
} catch (playerErr) {
// If player UI save fails, still show success for visibility but warn
console.warn('Player UI save failed:', playerErr);
}
const data = await visibilityRes.json();
// Clear cache so all pages get the new setting immediately
localStorage.removeItem('visibility_config_cache');
applyVersionTabVisibility(showVersionTab);
showResponse('success', t('admin.visibility.saved', 'Configuration saved'));
} catch (err) {
if (visibilityConfigAlert) {
visibilityConfigAlert.className = 'alert alert-danger';
visibilityConfigAlert.textContent = t('admin.visibility.saveError', 'Error saving configuration: {error}', { error: err.message });
visibilityConfigAlert.classList.remove('d-none');
}
showResponse('danger', t('admin.visibility.saveError', 'Error saving configuration: {error}', { error: err.message }));
} finally {
visibilityConfigSaveBtn.disabled = false;
visibilityConfigSaveBtn.textContent = oldText || t('common.save', 'Save');
}
});
}
// Load visibility config and player UI when visibility tab is shown
const visibilitySubTab = document.getElementById('admin-settings-subtab-visibility');
if (visibilitySubTab) {
visibilitySubTab.addEventListener('shown.bs.tab', async () => {
await Promise.all([
loadVisibilityConfig(),
loadPlayerUiIntoUi()
]);
});
}
// Language config
const siteLanguageSelect = document.getElementById('site_language');
const languageConfigSaveBtn = document.getElementById('language-config-save');
const languageConfigAlert = document.getElementById('language-config-alert');
async function loadLanguageConfig() {
try {
const res = await fetch('/api/settings/language', { cache: 'no-store' });
if (!res.ok) throw new Error('Failed to load language setting');
const data = await res.json();
if (siteLanguageSelect) {
siteLanguageSelect.value = data.language || 'en';
}
} catch (err) {
console.error('Error loading language config:', err);
if (siteLanguageSelect) siteLanguageSelect.value = 'en';
}
}
if (languageConfigSaveBtn) {
languageConfigSaveBtn.addEventListener('click', async () => {
const language = siteLanguageSelect ? siteLanguageSelect.value : 'en';
try {
languageConfigSaveBtn.disabled = true;
const oldText = languageConfigSaveBtn.textContent;
languageConfigSaveBtn.textContent = 'Saving...';
const res = await fetch('/api/settings/language', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ language })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to save language setting');
}
// Clear i18n cache and reload translations
if (typeof window.i18n !== 'undefined' && window.i18n.setLanguage) {
await window.i18n.setLanguage(language);
// After applying the new language, reload the page so all UI text updates
// (this avoids requiring the admin to manually refresh the page).
setTimeout(() => {
try { window.location.reload(); } catch (_) {}
}, 200);
}
showResponse('success', t('admin.language.saved', 'Language saved'));
} catch (err) {
if (languageConfigAlert) {
languageConfigAlert.className = 'alert alert-danger';
languageConfigAlert.textContent = 'Error: ' + err.message;
languageConfigAlert.classList.remove('d-none');
}
showResponse('danger', 'Error: ' + err.message);
} finally {
languageConfigSaveBtn.disabled = false;
languageConfigSaveBtn.textContent = typeof window.i18n !== 'undefined' ? window.i18n.t('common.save') : 'Save';
}
});
}
// Load language config when language tab is shown
const languageSubTab = document.getElementById('admin-settings-subtab-language');
if (languageSubTab) {
languageSubTab.addEventListener('shown.bs.tab', async () => {
await loadLanguageConfig();
});
}
// Categories management
const createCategoryForm = document.getElementById('create-category-form');
const refreshCategoriesListBtn = document.getElementById('refresh-categories-list');
const categoriesListContainer = document.getElementById('categories-list-container');
const listYearFilter = document.getElementById('list-year-filter');
function showCategoriesAlert(message, type = 'info') {
const alertEl = document.getElementById('categories-manual-alert');
if (!alertEl) return;
alertEl.className = `alert alert-${type}`;
alertEl.textContent = message;
alertEl.classList.remove('d-none');
setTimeout(() => {
alertEl.classList.add('d-none');
}, 5000);
}
if (createCategoryForm) {
createCategoryForm.addEventListener('submit', async (e) => {
e.preventDefault();
const year = parseYear(document.getElementById('manual-year').value);
const categoryNumber = parseInt(document.getElementById('category-number').value);
const categoryName = document.getElementById('category-name').value.trim();
const nomineesText = document.getElementById('nominees-list').value.trim();
if (!year || !categoryNumber || !categoryName || !nomineesText) {
showCategoriesAlert(t('admin.categories.fillAllFields', 'Please fill in all fields'), 'warning');
return;
}
const nominees = nomineesText.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (nominees.length === 0) {
showCategoriesAlert(t('admin.categories.enterAtLeastOneNominee', 'Please enter at least one nominee'), 'warning');
return;
}
try {
const res = await fetch('/api/categories/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
year,
categoryNumber,
categoryName,
nominees
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Creation failed');
}
const data = await res.json();
showCategoriesAlert(t('admin.categories.categoryCreated', 'Category created successfully.'), 'success');
createCategoryForm.reset();
if (refreshCategoriesListBtn) refreshCategoriesListBtn.click();
} catch (err) {
showCategoriesAlert('Erreur: ' + err.message, 'danger');
}
});
}
async function loadCategoriesList() {
if (!categoriesListContainer) return;
const year = listYearFilter?.value ? parseYear(listYearFilter.value) : null;
const url = year ? `/api/categories/list?year=${year}` : '/api/categories/list';
try {
categoriesListContainer.innerHTML = '<div class="text-muted">Chargement…</div>';
const res = await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
throw new Error('Failed to load categories');
}
const data = await res.json();
const categories = data.categories || [];
if (categories.length === 0) {
const emptyMsg = year
? t('admin.categories.noneFoundYear', 'No categories found for year {year}.', { year })
: t('admin.categories.noneFound', 'No categories found.');
categoriesListContainer.innerHTML = `<div class="text-muted">${emptyMsg}</div>`;
return;
}
categoriesListContainer.innerHTML = `
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" class="form-check-input" id="select-all-categories" title="${t('admin.categories.selectAllTitle','Select all')}">
</th>
<th>${t('admin.categories.table.number','#')}</th>
<th>${t('admin.categories.table.name','Nom')}</th>
<th>${t('admin.categories.table.nominees','Nominees')}</th>
<th>${t('admin.categories.table.actions','Actions')}</th>
</tr>
</thead>
<tbody>
${categories.map(cat => `
<tr>
<td>
<input type="checkbox" class="form-check-input category-checkbox" data-category-id="${cat._id}" data-category-name="${cat.categoryName}">
</td>
<td>${cat.categoryNumber}</td>
<td><strong>${cat.categoryName}</strong></td>
<td>
<ul class="mb-0 small">
${cat.nominees.map(n => `<li>${escapeHtml(n.name)}</li>`).join('')}
</ul>
</td>
<td>
<button class="btn btn-sm btn-primary me-1" onclick="editCategory('${cat._id}')">${t('common.edit','Edit')}</button>
<button class="btn btn-sm btn-danger" onclick="deleteCategory('${cat._id}')">${t('common.delete','Delete')}</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
// Setup bulk selection handlers
setupBulkCategorySelection();
} catch (err) {
categoriesListContainer.innerHTML = `<div class="alert alert-danger">Erreur: ${err.message}</div>`;
}
}
window.editCategory = async function(categoryId) {
try {
// Fetch category directly by ID
const res = await fetch(`/api/categories/${categoryId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
if (res.status === 404) {
showResponse('danger', t('admin.categories.categoryNotFound', 'Category not found.'));
return;
}
throw new Error('Failed to load category');
}
const data = await res.json();
const category = data.category;
if (!category) {
showResponse('danger', t('admin.categories.categoryNotFound', 'Category not found.'));
return;
}
// Populate modal
document.getElementById('edit_category_id').value = categoryId;
document.getElementById('edit_category_number').value = category.categoryNumber;
document.getElementById('edit_category_name').value = category.categoryName;
document.getElementById('edit_nominees_list').value = category.nominees.map(n => n.name).join('\n');
// Hide alert
const alertEl = document.getElementById('edit-category-alert');
if (alertEl) {
alertEl.classList.add('d-none');
}
// Show modal
const modal = new bootstrap.Modal(document.getElementById('editCategoryModal'));
modal.show();
} catch (err) {
showResponse('danger', 'Erreur: ' + err.message);
}
};
window.deleteCategory = async function(categoryId) {
if (!confirm(t('admin.categories.confirmDelete','Are you sure you want to delete this category?'))) return;
try {
const res = await fetch(`/api/categories/${categoryId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Delete failed');
}
showResponse('success', t('admin.categories.categoryDeleted', 'Category deleted.'));
await loadCategoriesList();
} catch (err) {
showResponse('danger', 'Erreur: ' + err.message);
}
};
// Save category changes
const saveCategoryChangesBtn = document.getElementById('save-category-changes');
if (saveCategoryChangesBtn) {
saveCategoryChangesBtn.addEventListener('click', async () => {
const categoryId = document.getElementById('edit_category_id').value;
const categoryName = document.getElementById('edit_category_name').value.trim();
const nomineesText = document.getElementById('edit_nominees_list').value.trim();
const alertEl = document.getElementById('edit-category-alert');
if (!categoryName || !nomineesText) {
if (alertEl) {
alertEl.className = 'alert alert-warning';
alertEl.textContent = t('admin.categories.fillAllFields', 'Please fill in all fields');
alertEl.classList.remove('d-none');
}
return;
}
const nominees = nomineesText.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (nominees.length === 0) {
if (alertEl) {
alertEl.className = 'alert alert-warning';
alertEl.textContent = t('admin.categories.enterAtLeastOneNominee', 'Please enter at least one nominee');
alertEl.classList.remove('d-none');
}
return;
}
const categoryNumber = parseInt(document.getElementById('edit_category_number').value);
if (!categoryNumber || categoryNumber < 1 || categoryNumber > 50) {
if (alertEl) {
alertEl.className = 'alert alert-warning';
alertEl.textContent = t('admin.categories.numberBetween1And50', 'Category number must be between 1 and 50');
alertEl.classList.remove('d-none');
}
return;
}
// Store button state before async operation
const oldText = saveCategoryChangesBtn.textContent || 'Enregistrer';
try {
saveCategoryChangesBtn.disabled = true;
saveCategoryChangesBtn.textContent = t('common.saving', 'Saving...');
// Hide any previous alerts
if (alertEl) {
alertEl.classList.add('d-none');
}
const res = await fetch(`/api/categories/${categoryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
categoryNumber,
categoryName,
nominees
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Update failed');
}
const data = await res.json();
showResponse('success', t('admin.categories.categoryUpdated', 'Category updated successfully.'));
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('editCategoryModal'));
if (modal) modal.hide();
// Reload list
await loadCategoriesList();
} catch (err) {
// Show error in modal alert
if (alertEl) {
alertEl.className = 'alert alert-danger';
alertEl.textContent = 'Erreur: ' + err.message;
alertEl.classList.remove('d-none');
// Scroll to alert to make it visible
alertEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
// Fallback to main response if alert element not found
showResponse('danger', 'Erreur: ' + err.message);
}
} finally {
// Always restore button state
saveCategoryChangesBtn.disabled = false;
saveCategoryChangesBtn.textContent = oldText;
}
});
}
if (refreshCategoriesListBtn) {
refreshCategoriesListBtn.addEventListener('click', loadCategoriesList);
// Auto-reload when year filter changes
if (listYearFilter) {
listYearFilter.addEventListener('change', loadCategoriesList);
}
}
// Auto-load when tab is shown
const categoriesTabBtn = document.getElementById('admin-categories-tab');
if (categoriesTabBtn) {
categoriesTabBtn.addEventListener('shown.bs.tab', () => {
loadCategoriesList();
});
}
// Bulk category selection and deletion
function setupBulkCategorySelection() {
const selectAllCheckbox = document.getElementById('select-all-categories');
const bulkDeleteBtn = document.getElementById('bulk-delete-categories-btn');
const bulkDeleteCount = document.getElementById('bulk-delete-count');
const selectionText = document.getElementById('categories-selection-text');
const categoryCheckboxes = document.querySelectorAll('.category-checkbox');
if (!selectAllCheckbox || !bulkDeleteBtn) return;
// Select all checkbox handler
selectAllCheckbox.addEventListener('change', function() {
categoryCheckboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
updateBulkDeleteButton();
});
// Individual checkbox handlers
categoryCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateBulkDeleteButton();
// Update select all checkbox state
const allChecked = Array.from(categoryCheckboxes).every(cb => cb.checked);
const someChecked = Array.from(categoryCheckboxes).some(cb => cb.checked);
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = someChecked && !allChecked;
});
});
// Update bulk delete button state
function updateBulkDeleteButton() {
const selected = Array.from(categoryCheckboxes).filter(cb => cb.checked);
const count = selected.length;
if (bulkDeleteCount) {
bulkDeleteCount.textContent = count;
}
bulkDeleteBtn.disabled = count === 0;
if (selectionText) {
selectionText.style.display = count > 0 ? 'block' : 'none';
}
}
// Bulk delete handler
bulkDeleteBtn.addEventListener('click', async function() {
const selected = Array.from(categoryCheckboxes).filter(cb => cb.checked);
if (selected.length === 0) return;
const categoryIds = selected.map(cb => cb.dataset.categoryId);
const categoryNames = selected.map(cb => cb.dataset.categoryName);
const confirmMessage = t(
'admin.categories.bulkDeleteConfirm',
`Are you sure you want to delete ${categoryIds.length} category(ies)?\n\n${categoryNames.join('\n')}\n\nThis action is irreversible.`,
{ count: categoryIds.length }
);
if (!confirm(t('admin.categories.confirmDeleteMultiple', confirmMessage, { count: categoryIds.length, names: categoryNames.join('\n') }))) return;
try {
bulkDeleteBtn.disabled = true;
const oldText = bulkDeleteBtn.textContent;
bulkDeleteBtn.textContent = 'Suppression...';
// Delete categories one by one
let successCount = 0;
let errorCount = 0;
for (const categoryId of categoryIds) {
try {
const res = await fetch(`/api/categories/${categoryId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
successCount++;
} else {
errorCount++;
}
} catch (err) {
console.error(`Error deleting category ${categoryId}:`, err);
errorCount++;
}
}
if (successCount > 0) {
const errorsSuffix = errorCount > 0 ? `, ${errorCount} error(s)` : '';
showResponse('success', t('admin.categories.deletedCount', '{count} category/categories deleted{errors}', {
count: successCount,
errors: errorsSuffix,
}));
await loadCategoriesList();
} else {
showResponse('danger', `Error while deleting: ${errorCount} error(s)`);
}
} catch (err) {
showResponse('danger', 'Erreur: ' + err.message);
} finally {
bulkDeleteBtn.disabled = false;
bulkDeleteBtn.textContent = oldText;
}
});
// Initialize button state
updateBulkDeleteButton();
}
};