Videos didactiques multi-etapes : wizard operateur + admin dynamique
Backend : 8 endpoints API (upload/delete/reorder/toggle/label/stream/creer etape),
stockage data/videos/{situation}/etape_XX.mp4, config.json toggle+labels,
6 situations par defaut, migration auto anciens fichiers flat.
Admin : panneau dynamique avec toggle, etapes reordonnables, labels editables.
Wizard booth : overlay plein ecran, video loop par etape, bouton validation sequentielle.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1487,63 +1487,273 @@ async function sauvegarderCapacites() {
|
||||
afficherStatut('Capacites sauvegardees', 'succes');
|
||||
}
|
||||
|
||||
// === VIDEOS DE SITUATION ===
|
||||
// === VIDEOS DIDACTIQUES (multi-etapes) ===
|
||||
|
||||
let _videosData = {};
|
||||
|
||||
async function chargerVideosAdmin() {
|
||||
try {
|
||||
const data = await apiGet('/api/videos');
|
||||
const situations = ['montage', 'depannage_imprimante', 'bourrage', 'rechargement'];
|
||||
for (const sit of situations) {
|
||||
const el = document.getElementById('video-sit-' + sit);
|
||||
if (!el) continue;
|
||||
const v = data.videos && data.videos[sit];
|
||||
if (v) {
|
||||
el.innerHTML = `<a href="#" onclick="previewVideoSituation('${sit}');return false">${_escHtml(v)}</a>`;
|
||||
} else {
|
||||
el.textContent = 'Aucune video';
|
||||
}
|
||||
_videosData = data.situations || {};
|
||||
const container = document.getElementById('video-situations-liste');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
for (const [sid, sit] of Object.entries(_videosData)) {
|
||||
container.appendChild(_creerBlocSituation(sid, sit));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Erreur chargement videos:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploaderVideoSituation(situation) {
|
||||
const input = document.getElementById('input-video-' + situation);
|
||||
function _creerBlocSituation(sid, sit) {
|
||||
const bloc = document.createElement('div');
|
||||
bloc.className = 'video-situation-bloc';
|
||||
bloc.style.cssText = 'margin-bottom:1rem;padding:0.75rem;background:#1a1a2e;border-radius:8px;border:1px solid #333';
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem;flex-wrap:wrap';
|
||||
const toggle = document.createElement('label');
|
||||
toggle.className = 'toggle';
|
||||
toggle.style.cssText = 'margin:0;flex-shrink:0';
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = !!sit.actif;
|
||||
cb.onchange = () => toggleSituation(sid, cb.checked);
|
||||
const slider = document.createElement('span');
|
||||
slider.className = 'toggle-slider';
|
||||
toggle.appendChild(cb);
|
||||
toggle.appendChild(slider);
|
||||
const titre = document.createElement('h4');
|
||||
titre.style.cssText = 'margin:0;flex:1;font-size:0.95rem';
|
||||
titre.textContent = sit.label || sid;
|
||||
const nbEtapes = document.createElement('span');
|
||||
nbEtapes.style.cssText = 'color:#888;font-size:0.8rem';
|
||||
nbEtapes.textContent = `${(sit.etapes || []).length} etape(s)`;
|
||||
header.appendChild(toggle);
|
||||
header.appendChild(titre);
|
||||
header.appendChild(nbEtapes);
|
||||
bloc.appendChild(header);
|
||||
|
||||
const etapesDiv = document.createElement('div');
|
||||
etapesDiv.style.cssText = 'margin-left:0.5rem';
|
||||
(sit.etapes || []).forEach((etape, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:0.5rem;margin-bottom:0.4rem;padding:0.3rem 0.5rem;background:#111;border-radius:6px';
|
||||
const num = document.createElement('span');
|
||||
num.style.cssText = 'color:#888;font-size:0.8rem;min-width:1.5rem';
|
||||
num.textContent = `${i + 1}.`;
|
||||
const labelEtape = document.createElement('input');
|
||||
labelEtape.type = 'text';
|
||||
labelEtape.placeholder = `Etape ${i + 1}`;
|
||||
labelEtape.value = (sit.labels_etapes && sit.labels_etapes[String(i)]) || '';
|
||||
labelEtape.style.cssText = 'flex:1;padding:0.2rem 0.4rem;border-radius:4px;border:1px solid #444;background:#1a1a2e;color:#fff;font-size:0.8rem';
|
||||
labelEtape.onchange = () => labeliserEtape(sid, i, labelEtape.value);
|
||||
const btnPlay = document.createElement('button');
|
||||
btnPlay.className = 'btn-secondaire btn-petit';
|
||||
btnPlay.textContent = '▶';
|
||||
btnPlay.title = 'Apercu';
|
||||
btnPlay.onclick = () => previewEtape(sid, i);
|
||||
const btnUp = document.createElement('button');
|
||||
btnUp.className = 'btn-secondaire btn-petit';
|
||||
btnUp.textContent = '↑';
|
||||
btnUp.disabled = i === 0;
|
||||
btnUp.onclick = () => reordonnerEtape(sid, i, i - 1);
|
||||
const btnDown = document.createElement('button');
|
||||
btnDown.className = 'btn-secondaire btn-petit';
|
||||
btnDown.textContent = '↓';
|
||||
btnDown.disabled = i === (sit.etapes || []).length - 1;
|
||||
btnDown.onclick = () => reordonnerEtape(sid, i, i + 1);
|
||||
const btnDel = document.createElement('button');
|
||||
btnDel.className = 'btn-danger btn-petit';
|
||||
btnDel.textContent = '✕';
|
||||
btnDel.onclick = () => supprimerEtape(sid, i);
|
||||
row.appendChild(num);
|
||||
row.appendChild(labelEtape);
|
||||
row.appendChild(btnPlay);
|
||||
row.appendChild(btnUp);
|
||||
row.appendChild(btnDown);
|
||||
row.appendChild(btnDel);
|
||||
etapesDiv.appendChild(row);
|
||||
});
|
||||
|
||||
const ajoutRow = document.createElement('div');
|
||||
ajoutRow.style.cssText = 'display:flex;align-items:center;gap:0.5rem;margin-top:0.4rem';
|
||||
const inputFile = document.createElement('input');
|
||||
inputFile.type = 'file';
|
||||
inputFile.accept = '.mp4,.webm,.mov';
|
||||
inputFile.className = 'input-fichier';
|
||||
inputFile.id = 'input-etape-' + sid;
|
||||
inputFile.style.cssText = 'flex:1;font-size:0.75rem';
|
||||
const btnAjout = document.createElement('button');
|
||||
btnAjout.className = 'btn-secondaire btn-petit';
|
||||
btnAjout.textContent = '+ Etape';
|
||||
btnAjout.onclick = () => uploaderEtape(sid);
|
||||
ajoutRow.appendChild(inputFile);
|
||||
ajoutRow.appendChild(btnAjout);
|
||||
etapesDiv.appendChild(ajoutRow);
|
||||
|
||||
bloc.appendChild(etapesDiv);
|
||||
return bloc;
|
||||
}
|
||||
|
||||
async function toggleSituation(sid, actif) {
|
||||
try {
|
||||
await fetch(`/api/videos/${sid}/toggle`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ actif })
|
||||
});
|
||||
} catch (e) {
|
||||
afficherStatut('Erreur toggle', 'erreur');
|
||||
}
|
||||
}
|
||||
|
||||
async function uploaderEtape(sid) {
|
||||
const input = document.getElementById('input-etape-' + sid);
|
||||
if (!input || !input.files.length) return;
|
||||
const fd = new FormData();
|
||||
fd.append('video', input.files[0]);
|
||||
fd.append('situation', situation);
|
||||
try {
|
||||
const resp = await fetch('/api/videos/upload', { method: 'POST', body: fd });
|
||||
const resp = await fetch(`/api/videos/${sid}/upload`, { method: 'POST', body: fd });
|
||||
if (resp.ok) {
|
||||
input.value = '';
|
||||
chargerVideosAdmin();
|
||||
afficherStatut('Video importee', 'succes');
|
||||
afficherStatut('Etape ajoutee', 'succes');
|
||||
}
|
||||
} catch (e) {
|
||||
afficherStatut('Erreur upload video', 'erreur');
|
||||
afficherStatut('Erreur upload etape', 'erreur');
|
||||
}
|
||||
}
|
||||
|
||||
async function supprimerVideoSituation(situation) {
|
||||
async function supprimerEtape(sid, index) {
|
||||
try {
|
||||
await fetch('/api/videos/' + situation, { method: 'DELETE' });
|
||||
await fetch(`/api/videos/${sid}/${index}`, { method: 'DELETE' });
|
||||
chargerVideosAdmin();
|
||||
afficherStatut('Video supprimee', 'succes');
|
||||
afficherStatut('Etape supprimee', 'succes');
|
||||
} catch (e) {
|
||||
afficherStatut('Erreur suppression video', 'erreur');
|
||||
afficherStatut('Erreur suppression', 'erreur');
|
||||
}
|
||||
}
|
||||
|
||||
function previewVideoSituation(situation) {
|
||||
async function reordonnerEtape(sid, de, vers) {
|
||||
try {
|
||||
await fetch(`/api/videos/${sid}/reorder`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ de, vers })
|
||||
});
|
||||
chargerVideosAdmin();
|
||||
} catch (e) {
|
||||
afficherStatut('Erreur reorder', 'erreur');
|
||||
}
|
||||
}
|
||||
|
||||
async function labeliserEtape(sid, index, label) {
|
||||
try {
|
||||
await fetch(`/api/videos/${sid}/label`, {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ index, label })
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Erreur label etape:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function previewEtape(sid, index) {
|
||||
const preview = document.getElementById('video-situation-preview');
|
||||
if (preview) {
|
||||
preview.src = '/api/videos/' + situation + '/stream';
|
||||
preview.src = `/api/videos/${sid}/${index}/stream`;
|
||||
preview.load();
|
||||
preview.play();
|
||||
}
|
||||
}
|
||||
|
||||
async function creerSituation() {
|
||||
const input = document.getElementById('nouvelle-situation-label');
|
||||
const label = (input && input.value || '').trim();
|
||||
if (!label) return;
|
||||
const id = label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
||||
try {
|
||||
await fetch('/api/videos/situation/creer', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ id, label })
|
||||
});
|
||||
input.value = '';
|
||||
chargerVideosAdmin();
|
||||
afficherStatut('Situation ajoutee', 'succes');
|
||||
} catch (e) {
|
||||
afficherStatut('Erreur creation situation', 'erreur');
|
||||
}
|
||||
}
|
||||
|
||||
// === WIZARD VIDEO DIDACTIQUE ===
|
||||
|
||||
let _wizardSituation = null;
|
||||
let _wizardEtapes = [];
|
||||
let _wizardIndex = 0;
|
||||
|
||||
async function lancerWizard(situationId) {
|
||||
const data = await apiGet('/api/videos');
|
||||
const sit = (data.situations || {})[situationId];
|
||||
if (!sit || !sit.etapes || sit.etapes.length === 0) {
|
||||
afficherStatut('Aucune etape pour cette situation', 'erreur');
|
||||
return;
|
||||
}
|
||||
_wizardSituation = { id: situationId, ...sit };
|
||||
_wizardEtapes = sit.etapes;
|
||||
_wizardIndex = 0;
|
||||
_afficherEtapeWizard();
|
||||
document.getElementById('wizard-video').classList.remove('cache');
|
||||
}
|
||||
|
||||
function _afficherEtapeWizard() {
|
||||
const titre = document.getElementById('wizard-titre');
|
||||
const info = document.getElementById('wizard-etape-info');
|
||||
const video = document.getElementById('wizard-video-player');
|
||||
const label = document.getElementById('wizard-etape-label');
|
||||
const btn = document.getElementById('wizard-btn-ok');
|
||||
titre.textContent = _wizardSituation.label || _wizardSituation.id;
|
||||
info.textContent = `Etape ${_wizardIndex + 1} / ${_wizardEtapes.length}`;
|
||||
const etapeLabel = (_wizardSituation.labels_etapes || {})[String(_wizardIndex)] || '';
|
||||
label.textContent = etapeLabel;
|
||||
video.src = `/api/videos/${_wizardSituation.id}/${_wizardIndex}/stream`;
|
||||
video.load();
|
||||
video.play();
|
||||
if (_wizardIndex === _wizardEtapes.length - 1) {
|
||||
btn.textContent = 'Terminer ✓';
|
||||
btn.style.background = '#2196f3';
|
||||
} else {
|
||||
btn.textContent = "OK, c'est bon !";
|
||||
btn.style.background = '#4caf50';
|
||||
}
|
||||
}
|
||||
|
||||
function wizardEtapeSuivante() {
|
||||
_wizardIndex++;
|
||||
if (_wizardIndex >= _wizardEtapes.length) {
|
||||
fermerWizard();
|
||||
return;
|
||||
}
|
||||
_afficherEtapeWizard();
|
||||
}
|
||||
|
||||
function fermerWizard() {
|
||||
const overlay = document.getElementById('wizard-video');
|
||||
const video = document.getElementById('wizard-video-player');
|
||||
if (video) { video.pause(); video.src = ''; }
|
||||
overlay.classList.add('cache');
|
||||
_wizardSituation = null;
|
||||
_wizardEtapes = [];
|
||||
_wizardIndex = 0;
|
||||
}
|
||||
|
||||
function lancerWizardDepuisAdmin() {
|
||||
const actives = Object.entries(_videosData).filter(([_, s]) => s.actif && s.etapes && s.etapes.length > 0);
|
||||
if (actives.length === 0) {
|
||||
afficherStatut('Aucune situation active avec des etapes', 'erreur');
|
||||
return;
|
||||
}
|
||||
lancerWizard(actives[0][0]);
|
||||
}
|
||||
|
||||
// === SURPRISE (photo/video avant capture) ===
|
||||
|
||||
async function chargerSurpriseConfig() {
|
||||
|
||||
Reference in New Issue
Block a user