add video backend server, fix styling and more #54

Merged
quoije merged 131 commits from 2026-video.backend into prod 2025-12-22 05:42:04 -05:00
3 changed files with 344 additions and 1 deletions
Showing only changes of commit 975e922c40 - Show all commits
+71
View File
@@ -65,6 +65,11 @@
Réglages Réglages
</button> </button>
</li> </li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-modal-100-tab" data-bs-toggle="tab" data-bs-target="#admin-modal-100" type="button" role="tab" aria-controls="admin-modal-100" aria-selected="false">
Modal 100%
</button>
</li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="admin-users-tab" data-bs-toggle="tab" data-bs-target="#admin-users" type="button" role="tab" aria-controls="admin-users" aria-selected="false"> <button class="nav-link" id="admin-users-tab" data-bs-toggle="tab" data-bs-target="#admin-users" type="button" role="tab" aria-controls="admin-users" aria-selected="false">
Utilisateurs Utilisateurs
@@ -99,6 +104,72 @@
</div> </div>
</div> </div>
<div class="tab-pane fade" id="admin-modal-100" role="tabpanel" aria-labelledby="admin-modal-100-tab">
<div class="row g-3">
<div class="col-12 col-xxl-6">
<div class="card p-3">
<h4 class="mb-1">Contenu “Modal 100%”</h4>
<p class="text-muted mb-3">
Contenu affiché quand un utilisateur a vu 100% des films dans la checklist.
</p>
<div class="mb-3">
<label for="completion_modal_title" class="form-label">Titre</label>
<input type="text" class="form-control" id="completion_modal_title" maxlength="200" placeholder="Félicitations! ...">
</div>
<div class="mb-3">
<label for="completion_modal_body_text" class="form-label">Texte (optionnel)</label>
<textarea class="form-control" id="completion_modal_body_text" rows="4" placeholder="Texte affiché dans le modal (retours à la ligne conservés)"></textarea>
<div class="form-text">Affiché en texte brut (safe). Laisse vide si tu n’en veux pas.</div>
</div>
<div class="mb-3">
<label for="completion_modal_body_html" class="form-label">HTML (optionnel)</label>
<textarea class="form-control font-monospace" id="completion_modal_body_html" rows="6" placeholder="<p>…</p>"></textarea>
<div class="form-text">
Affiché comme HTML (le serveur retire les <code>&lt;script&gt;</code> et les handlers <code>on*</code>).
</div>
</div>
<div class="mb-3">
<label for="completion_modal_video_src" class="form-label">Video src (optionnel)</label>
<input type="text" class="form-control" id="completion_modal_video_src" maxlength="2048" placeholder="video/reward.mp4">
<div class="form-text">Chemin relatif (ex: <code>video/reward.mp4</code>) ou URL complète. Vide = pas de vidéo.</div>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2">
<button type="button" id="completion_modal_reload" class="btn btn-outline-secondary">Recharger</button>
<button type="button" id="completion_modal_save" class="btn btn-warning">Enregistrer</button>
</div>
</div>
</div>
<div class="col-12 col-xxl-6">
<div class="card p-3">
<h4 class="mb-1">Aperçu</h4>
<div class="text-muted mb-3">Aperçu live (non sauvegardé tant que tu n’enregistres pas).</div>
<div class="border rounded p-3 bg-white">
<div class="d-flex align-items-center justify-content-between gap-2 mb-2">
<div class="fw-semibold" id="completion_modal_preview_title">—</div>
<span class="badge bg-secondary">Preview</span>
</div>
<div id="completion_modal_preview_custom" class="text-start d-none"></div>
<div id="completion_modal_preview_text" class="text-start" style="white-space: pre-wrap;"></div>
<div class="mt-3">
<video id="completion_modal_preview_video" width="75%" controls class="d-none">
<source id="completion_modal_preview_video_source" src="" type="video/mp4">
Votre navigateur ne supporte pas la vidéo.
</video>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="admin-users" role="tabpanel" aria-labelledby="admin-users-tab"> <div class="tab-pane fade" id="admin-users" role="tabpanel" aria-labelledby="admin-users-tab">
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-xl-8"> <div class="col-12 col-xl-8">
+85 -1
View File
@@ -1,6 +1,81 @@
window.onload = async function () { window.onload = async function () {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: 'Félicitations! very nice 🎉🎉🎉',
bodyText: '',
videoSrc: 'video/reward.mp4',
bodyHtml: ''
});
function sanitizeHtml(raw) {
const html = String(raw || '');
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
const withoutOnAttrs = withoutScripts
.replace(/\son\w+="[^"]*"/gi, '')
.replace(/\son\w+='[^']*'/gi, '')
.replace(/\son\w+=\S+/gi, '');
return withoutOnAttrs.trim();
}
async function fetchCompletionModalContent() {
try {
const res = await fetch('/api/settings/completion-modal', { method: 'GET' });
if (!res.ok) throw new Error('Failed to fetch completion modal');
const data = await res.json();
return {
title: typeof data?.title === 'string' ? data.title : DEFAULT_COMPLETION_MODAL.title,
bodyText: typeof data?.bodyText === 'string' ? data.bodyText : DEFAULT_COMPLETION_MODAL.bodyText,
videoSrc: typeof data?.videoSrc === 'string' ? data.videoSrc : DEFAULT_COMPLETION_MODAL.videoSrc,
bodyHtml: typeof data?.bodyHtml === 'string' ? data.bodyHtml : DEFAULT_COMPLETION_MODAL.bodyHtml,
};
} catch (_) {
return DEFAULT_COMPLETION_MODAL;
}
}
function applyCompletionModalContent(content) {
const titleEl = document.getElementById('videoModalLabel');
const bodyTextEl = document.getElementById('completion-modal-text');
const bodyCustomEl = document.getElementById('completion-modal-custom');
const videoEl = document.getElementById('rewardVideo');
const videoSourceEl = document.getElementById('rewardVideoSource');
const title = String(content?.title || '').trim() || DEFAULT_COMPLETION_MODAL.title;
const bodyText = String(content?.bodyText || '');
const bodyHtml = sanitizeHtml(String(content?.bodyHtml || ''));
const videoSrc = String(content?.videoSrc || '').trim();
if (titleEl) titleEl.textContent = title;
if (bodyCustomEl) {
if (bodyHtml) {
bodyCustomEl.innerHTML = bodyHtml;
bodyCustomEl.classList.remove('d-none');
} else {
bodyCustomEl.innerHTML = '';
bodyCustomEl.classList.add('d-none');
}
}
if (bodyTextEl) {
bodyTextEl.textContent = bodyText || '';
bodyTextEl.classList.toggle('d-none', !bodyText);
}
if (videoEl && videoSourceEl) {
if (videoSrc) {
videoSourceEl.setAttribute('src', videoSrc);
// reload source (so updated src is used)
try { videoEl.load(); } catch (_) {}
videoEl.classList.remove('d-none');
} else {
try { videoEl.pause(); } catch (_) {}
videoEl.classList.add('d-none');
}
}
}
async function fetchActiveYear() { async function fetchActiveYear() {
try { try {
const res = await fetch('/api/settings/year', { method: 'GET' }); const res = await fetch('/api/settings/year', { method: 'GET' });
@@ -18,6 +93,10 @@ window.onload = async function () {
const oscarYearEl = document.getElementById('oscar-year'); const oscarYearEl = document.getElementById('oscar-year');
if (oscarYearEl) oscarYearEl.textContent = String(activeYear); if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
// Load (public) "100% completion" modal content at runtime.
const completionModalContent = await fetchCompletionModalContent();
applyCompletionModalContent(completionModalContent);
const targetDate = new Date(`March 15, ${activeYear}`); const targetDate = new Date(`March 15, ${activeYear}`);
const currentDate = new Date(); const currentDate = new Date();
const timeDifference = targetDate - currentDate; const timeDifference = targetDate - currentDate;
@@ -128,7 +207,11 @@ window.onload = async function () {
if (percentage === 100) { if (percentage === 100) {
videoModal.show(); videoModal.show();
launchConfetti(); launchConfetti();
document.getElementById("rewardVideo").play(); const rewardVideo = document.getElementById("rewardVideo");
// Only attempt autoplay if we actually have a visible <video>.
if (rewardVideo && !rewardVideo.classList.contains('d-none')) {
rewardVideo.play().catch(() => {});
}
} }
} }
@@ -221,6 +304,7 @@ window.onload = async function () {
const videoModal = document.getElementById("videoModal"); const videoModal = document.getElementById("videoModal");
const rewardVideo = document.getElementById("rewardVideo"); const rewardVideo = document.getElementById("rewardVideo");
videoModal.addEventListener("hidden.bs.modal", function () { videoModal.addEventListener("hidden.bs.modal", function () {
if (!rewardVideo) return;
rewardVideo.pause(); rewardVideo.pause();
rewardVideo.currentTime = 0; rewardVideo.currentTime = 0;
}); });
+188
View File
@@ -41,6 +41,20 @@ window.onload = async function () {
const activeYearInput = document.getElementById('active_oscar_year'); const activeYearInput = document.getElementById('active_oscar_year');
const saveActiveYearBtn = document.getElementById('save-active-year'); const saveActiveYearBtn = document.getElementById('save-active-year');
// 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 manageYearSelect = document.getElementById('manage_year');
const adminMoviesBody = document.getElementById('admin-movies-body'); const adminMoviesBody = document.getElementById('admin-movies-body');
const refreshMoviesBtn = document.getElementById('refresh-movies'); const refreshMoviesBtn = document.getElementById('refresh-movies');
@@ -67,12 +81,134 @@ window.onload = async function () {
let moviesById = new Map(); let moviesById = new Map();
let activeYear = null; let activeYear = null;
const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: 'Félicitations! very nice 🎉🎉🎉',
bodyText: '',
videoSrc: 'video/reward.mp4',
bodyHtml: '',
});
function showResponse(kind, message) { function showResponse(kind, message) {
responseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning'); 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.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger');
responseEl.textContent = message; responseEl.textContent = message;
} }
function sanitizeHtml(raw) {
const html = String(raw || '');
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
const withoutOnAttrs = withoutScripts
.replace(/\son\w+="[^"]*"/gi, '')
.replace(/\son\w+='[^']*'/gi, '')
.replace(/\son\w+=\S+/gi, '');
return withoutOnAttrs.trim();
}
function normalizeCompletionModal(value) {
const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const title = typeof v.title === 'string' ? v.title : DEFAULT_COMPLETION_MODAL.title;
const bodyText = typeof v.bodyText === 'string' ? v.bodyText : DEFAULT_COMPLETION_MODAL.bodyText;
const videoSrc = typeof v.videoSrc === 'string' ? v.videoSrc : DEFAULT_COMPLETION_MODAL.videoSrc;
const bodyHtml = typeof v.bodyHtml === 'string' ? v.bodyHtml : DEFAULT_COMPLETION_MODAL.bodyHtml;
return {
title: String(title || '').trim().slice(0, 200) || DEFAULT_COMPLETION_MODAL.title,
bodyText: String(bodyText || '').slice(0, 8000),
videoSrc: String(videoSrc || '').trim().slice(0, 2048),
bodyHtml: String(bodyHtml || '').slice(0, 20000),
};
}
async function fetchCompletionModal() {
const res = await fetch('/api/settings/completion-modal', { method: 'GET' });
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.');
}
}
const userResetResponseEl = document.getElementById('user-reset-response'); const userResetResponseEl = document.getElementById('user-reset-response');
const userResetForm = document.getElementById('reset-user-password-form'); const userResetForm = document.getElementById('reset-user-password-form');
const userResetEmailEl = document.getElementById('reset_user_email'); const userResetEmailEl = document.getElementById('reset_user_email');
@@ -139,6 +275,55 @@ window.onload = async function () {
window.location.href = '/'; 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', 'Contenu rechargé.');
} 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 = 'Enregistrement...';
try {
const value = getCompletionModalFormValue();
// send raw HTML; server sanitizes/caps it
const saved = await saveCompletionModal(value);
setCompletionModalForm(saved);
updateCompletionModalPreview(getCompletionModalFormValue());
showResponse('success', 'Modal 100% enregistré.');
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
} finally {
completionModalSaveBtn.disabled = false;
completionModalSaveBtn.textContent = oldText;
}
});
}
}
if (userResetForm && userResetEmailEl) { if (userResetForm && userResetEmailEl) {
userResetForm.addEventListener('submit', async function (e) { userResetForm.addEventListener('submit', async function (e) {
e.preventDefault(); e.preventDefault();
@@ -514,6 +699,9 @@ window.onload = async function () {
document.title = `Pool Oscars ${activeYear} - Admin`; document.title = `Pool Oscars ${activeYear} - Admin`;
} }
hookModal100Inputs();
await loadCompletionModalIntoUi();
if (saveActiveYearBtn && activeYearInput) { if (saveActiveYearBtn && activeYearInput) {
saveActiveYearBtn.addEventListener('click', async () => { saveActiveYearBtn.addEventListener('click', async () => {
const y = parseYear(activeYearInput.value); const y = parseYear(activeYearInput.value);