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, '''); } 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(//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 ? 'Active' : ''; const activateBtn = isActive ? '' : ``; // IMPORTANT: keep markup compact (no preserved whitespace surprises). return ( `
` + `
` + `
` + `${versionLabel}` + `${activeBadge}` + `
` + `
${escapeHtml(dateLabel)}${msg ? ` — ${msg}` : ''}
` + `
` + `
` + `${activateBtn}` + `` + `
` + `
` ); }).join(''); appVersionListEl.innerHTML = `
${rows}
`; 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 = 'Aucun utilisateur.'; 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 = 'Chargement…'; 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 = `${err.message || t('common.networkError', 'Network error')}`; 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 `
${escapeHtml(String(y))}
(aucun gagnant)
`; } 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) ? '' : ` — ${escapeHtml(String(Math.round(pts)))} pts`; const uid = escapeHtml(String(w?.userId || '').trim()); return ( `
` + `
${name}${ptsLabel}
` + `` + `
` ); }).join(''); const tieLabel = sorted.length > 1 ? ` ${t('admin.winners.tie', '(tie)')}` : ''; return `
${escapeHtml(String(y))}${tieLabel}
${rows}
`; }).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 = 'Aucune sauvegarde.'; 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 = ` ${name || '—'} ${formatBytes(b?.sizeBytes)} ${formatDateTime(b?.mtime)} `; 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 = 'Chargement…'; try { const backups = await fetchBackups(); backupsLoadedOnce = true; renderBackups(backups); } catch (err) { dbBackupListBody.innerHTML = `${err.message || 'Network error'}`; } } 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 = '—'; 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 ? `${t('admin.settings.defaultBadge', 'Default')}` : ''; return `
${title} ${meta ? `${meta}` : ''} ${isDefault}
`; }).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 = `Chargement…`; try { const res = await fetch(url, { method: 'GET', cache: 'no-store' }); if (!res.ok) { adminMoviesBody.innerHTML = `Erreur lors du chargement (${res.status})`; 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 = `${t('admin.movies.noMovies', 'No movies.')}`; 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) => `${b.text}`) .join(''); const movieId = String(m._id || ''); const shortId = movieId ? `${movieId.slice(0, 6)}…${movieId.slice(-4)}` : '—'; const tr = document.createElement('tr'); tr.innerHTML = `
${m.title || '(sans titre)'}
${m.imdb_id || ''}
${badgesHtml} ${m.category || ''} ${m.year || ''} `; 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 = `${err.message || 'Network error'}`; } } 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 = '
Chargement…
'; 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 = `
${emptyMsg}
`; return; } categoriesListContainer.innerHTML = `
${categories.map(cat => ` `).join('')}
${t('admin.categories.table.number','#')} ${t('admin.categories.table.name','Nom')} ${t('admin.categories.table.nominees','Nominees')} ${t('admin.categories.table.actions','Actions')}
${cat.categoryNumber} ${cat.categoryName}
    ${cat.nominees.map(n => `
  • ${escapeHtml(n.name)}
  • `).join('')}
`; // Setup bulk selection handlers setupBulkCategorySelection(); } catch (err) { categoriesListContainer.innerHTML = `
Erreur: ${err.message}
`; } } 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(); } };