Introduce client-side localization helper and en/fr locale bundles. Route admin control, picks, movies, stats, winners and settings UIs through i18n keys instead of hardcoded text. Remove French text from the English UI and standardize shared messages (saving, network errors, confirmations). Update .gitignore to ignore editor metadata and localization helper artifacts.
3556 lines
134 KiB
JavaScript
3556 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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) {
|
|
dbBackupListBody.innerHTML = '<tr><td colspan="4" class="text-muted">Aucune sauvegarde.</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();
|
|
}
|
|
};
|
|
|