Admin backoffice : rubriques General/Evenement + videos situation + surprise + rembobinage ruban

- Reorganisation menu admin en 2 rubriques (General / Evenement)
- Destinations deplace dans Evenement
- Nouveau panneau Videos : upload par situation (montage, depannage, bourrage, rechargement)
- Nouveau panneau Surprise : photo/video affichee ~1s avant capture pour provoquer sourire
- Toggle rembobinage ruban Mitsubishi (StpiDecklist) pour economiser le ruban sur petits formats
- Historique email cloisonne par evenement
- Memoire projet consolidee dans memoire.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 12:31:27 +02:00
parent e5e9f763ab
commit 44a9992557
10 changed files with 731 additions and 32 deletions

View File

@@ -417,6 +417,17 @@ html, body {
100% { opacity: 0; }
}
.surprise-overlay {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background: #000;
z-index: 19;
display: flex;
align-items: center;
justify-content: center;
}
.capture-en-cours {
position: absolute;
inset: 0;
@@ -889,6 +900,30 @@ html, body {
overflow: hidden;
}
.admin-rubriques {
display: flex;
gap: 0;
background: rgba(0,0,0,0.3);
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.rubrique {
flex: 1;
background: transparent;
border: none;
color: var(--texte-secondaire);
padding: 0.8rem 1rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
border-bottom: 3px solid transparent;
}
.rubrique.actif {
color: var(--primaire);
border-bottom-color: var(--primaire);
background: rgba(255,255,255,0.03);
}
.admin-onglets {
display: flex;
flex-direction: column;
@@ -898,6 +933,20 @@ html, body {
background: var(--fond-carte);
overflow-y: auto;
}
.admin-onglets.cache { display: none; }
.video-situation-bloc {
background: rgba(255,255,255,0.03);
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.8rem;
}
.video-situation-bloc h4 { margin-bottom: 0.5rem; }
.video-situation-current {
font-size: 0.85rem;
color: var(--texte-secondaire);
margin-bottom: 0.5rem;
}
.onglet {
background: transparent;

View File

@@ -7,7 +7,7 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta name="google" content="notranslate">
<meta http-equiv="Content-Language" content="fr">
<link rel="stylesheet" href="/css/style.css?v=12">
<link rel="stylesheet" href="/css/style.css?v=13">
<link rel="stylesheet" href="/css/themes.css?v=2">
</head>
<body>
@@ -98,6 +98,7 @@
<span id="chiffre-car">3</span>
</div>
<div id="flash-blanc" class="flash-blanc cache"></div>
<div id="surprise-overlay" class="surprise-overlay cache"></div>
<div id="capture-en-cours" class="capture-en-cours cache">
<div class="capture-spinner"></div>
<span>Capture en cours...</span>
@@ -371,18 +372,26 @@
<button class="btn-fermer" onclick="allerA('accueil')">&times;</button>
</div>
<div class="admin-contenu">
<div class="admin-onglets">
<div class="admin-rubriques">
<button class="rubrique actif" data-rubrique="general" onclick="changerRubrique('general')">General</button>
<button class="rubrique" data-rubrique="evenement" onclick="changerRubrique('evenement')">Evenement</button>
</div>
<div class="admin-onglets" id="onglets-general">
<button class="onglet actif" data-onglet="materiel">Materiel</button>
<button class="onglet" data-onglet="compteur-admin">Compteur</button>
<button class="onglet" data-onglet="destinations-admin">Destinations</button>
<button class="onglet" data-onglet="personnalisation">Personnalisation</button>
<button class="onglet" data-onglet="evenement">Evenement</button>
<button class="onglet" data-onglet="fonctions">Fonctions</button>
<button class="onglet" data-onglet="admin-galerie" onclick="chargerAdminGalerie()">Galerie</button>
<button class="onglet" data-onglet="personnalisation">Personnalisation</button>
<button class="onglet" data-onglet="videos-admin" onclick="chargerVideosAdmin()">Videos</button>
<button class="onglet" data-onglet="eclairage" onclick="demarrerEclairageLive()">Eclairage</button>
<button class="onglet" data-onglet="wifi" onclick="chargerWifi()">WiFi</button>
<button class="onglet" data-onglet="infos" onclick="chargerInfosSysteme()">Infos</button>
</div>
<div class="admin-onglets cache" id="onglets-evenement">
<button class="onglet" data-onglet="evenement">Config</button>
<button class="onglet" data-onglet="destinations-admin">Destinations</button>
<button class="onglet" data-onglet="surprise-admin">Surprise</button>
<button class="onglet" data-onglet="admin-galerie" onclick="chargerAdminGalerie()">Galerie</button>
</div>
<!-- Panneau Materiel (Camera + Imprimante) -->
<div class="admin-panneau actif" id="panneau-materiel">
@@ -429,6 +438,9 @@
<label>Nombre max d'exemplaires</label>
<input type="number" id="admin-copies-max" min="1" max="20" value="5">
</div>
<div class="champ">
<label class="toggle"><input type="checkbox" id="tog-rembobinage-ruban"><span class="toggle-slider"></span> Rembobinage ruban (economise le ruban sur les petits formats)</label>
</div>
<button class="btn-action" onclick="sauvegarderMateriel()">Sauvegarder</button>
<div class="champ" style="margin-top:1rem;display:flex;gap:0.8rem;flex-wrap:wrap">
<button class="btn-danger" onclick="evacuerBourrage()">&#9888; Annuler jobs</button>
@@ -846,6 +858,76 @@
<div id="diag-camera-contenu" style="font-family:monospace;font-size:0.75rem;background:#111;color:#0f0;padding:0.75rem;border-radius:6px;white-space:pre-wrap;max-height:300px;overflow-y:auto">
Appuyer sur Tester...
</div>
<div class="admin-panneau" id="panneau-videos-admin">
<h3>Videos de situation</h3>
<p class="aide">Uploadez des videos pour chaque situation. Elles seront jouees automatiquement sur l'ecran du booth.</p>
<div class="video-situations" id="video-situations-liste">
<div class="video-situation-bloc" data-situation="montage">
<h4>Montage / Installation</h4>
<div class="video-situation-current" id="video-sit-montage">Aucune video</div>
<input type="file" id="input-video-montage" accept=".mp4,.webm,.mov" class="input-fichier">
<button class="btn-secondaire btn-petit" onclick="uploaderVideoSituation('montage')">Importer</button>
<button class="btn-danger btn-petit" onclick="supprimerVideoSituation('montage')">Supprimer</button>
</div>
<div class="video-situation-bloc" data-situation="depannage_imprimante">
<h4>Depannage imprimante</h4>
<div class="video-situation-current" id="video-sit-depannage_imprimante">Aucune video</div>
<input type="file" id="input-video-depannage_imprimante" accept=".mp4,.webm,.mov" class="input-fichier">
<button class="btn-secondaire btn-petit" onclick="uploaderVideoSituation('depannage_imprimante')">Importer</button>
<button class="btn-danger btn-petit" onclick="supprimerVideoSituation('depannage_imprimante')">Supprimer</button>
</div>
<div class="video-situation-bloc" data-situation="bourrage">
<h4>Bourrage papier</h4>
<div class="video-situation-current" id="video-sit-bourrage">Aucune video</div>
<input type="file" id="input-video-bourrage" accept=".mp4,.webm,.mov" class="input-fichier">
<button class="btn-secondaire btn-petit" onclick="uploaderVideoSituation('bourrage')">Importer</button>
<button class="btn-danger btn-petit" onclick="supprimerVideoSituation('bourrage')">Supprimer</button>
</div>
<div class="video-situation-bloc" data-situation="rechargement">
<h4>Rechargement papier/ruban</h4>
<div class="video-situation-current" id="video-sit-rechargement">Aucune video</div>
<input type="file" id="input-video-rechargement" accept=".mp4,.webm,.mov" class="input-fichier">
<button class="btn-secondaire btn-petit" onclick="uploaderVideoSituation('rechargement')">Importer</button>
<button class="btn-danger btn-petit" onclick="supprimerVideoSituation('rechargement')">Supprimer</button>
</div>
</div>
<h4 style="margin-top:1.5rem">Apercu</h4>
<video id="video-situation-preview" style="width:100%;max-height:300px;border-radius:8px;background:#111" controls></video>
</div>
<div class="admin-panneau" id="panneau-surprise-admin">
<h3>Photo / Video surprise</h3>
<p class="aide">Media affiche sur l'ecran ~1 seconde avant la prise de photo pour surprendre et provoquer un sourire.</p>
<div class="champ">
<label class="toggle"><input type="checkbox" id="tog-surprise-actif"><span class="toggle-slider"></span> Surprise active</label>
</div>
<div class="champ">
<label>Type de surprise</label>
<div class="radio-group">
<label><input type="radio" name="surprise-type" value="photo" checked> Photo</label>
<label><input type="radio" name="surprise-type" value="video"> Video</label>
</div>
</div>
<div class="champ">
<label>Media actuel</label>
<div id="surprise-current">Aucun media configure</div>
</div>
<div class="champ">
<input type="file" id="input-surprise-media" accept=".jpg,.jpeg,.png,.gif,.mp4,.webm" class="input-fichier">
<button class="btn-action" onclick="uploaderSurprise()">Importer</button>
<button class="btn-danger btn-petit" onclick="supprimerSurprise()">Supprimer</button>
</div>
<div class="champ">
<label>Delai avant capture (ms)</label>
<input type="number" id="admin-surprise-delai" min="500" max="3000" value="1000" step="100">
</div>
<button class="btn-action" onclick="sauvegarderSurprise()">Sauvegarder</button>
<h4 style="margin-top:1.5rem">Apercu</h4>
<div id="surprise-preview" style="width:100%;max-height:300px;border-radius:8px;background:#111;display:flex;align-items:center;justify-content:center;min-height:150px;color:#666">
Aucun apercu
</div>
</div>
</div>
</div>
</section>
@@ -930,11 +1012,11 @@
</style>
<script src="/js/websocket.js?v=16"></script>
<script src="/js/app.js?v=18"></script>
<script src="/js/camera.js?v=17"></script>
<script src="/js/app.js?v=19"></script>
<script src="/js/camera.js?v=18"></script>
<script src="/js/effects.js?v=4"></script>
<script src="/js/gallery.js?v=4"></script>
<script src="/js/share.js?v=8"></script>
<script src="/js/admin.js?v=9"></script>
<script src="/js/admin.js?v=10"></script>
</body>
</html>

View File

@@ -66,6 +66,7 @@ async function chargerMateriel() {
const orient = (imp.orientations || {})[fmt] || 'portrait';
document.querySelector(`input[name="admin-orientation"][value="${orient}"]`).checked = true;
setValue('admin-copies-max', imp.copies_max || 5);
document.getElementById('tog-rembobinage-ruban').checked = imp.rembobinage_ruban || false;
}
async function rafraichirImprimantes() {
@@ -149,6 +150,7 @@ async function sauvegarderMateriel() {
format: getValue('admin-format-papier'),
copies_max: parseInt(getValue('admin-copies-max')) || 5,
orientations: _getOrientations(),
rembobinage_ruban: document.getElementById('tog-rembobinage-ruban').checked,
},
});
afficherStatut('Materiel sauvegarde', 'succes');
@@ -823,9 +825,23 @@ async function uploaderMediaAccueil() {
const formData = new FormData();
formData.append('fichier', input.files[0]);
await fetch('/api/upload/animation', { method: 'POST', body: formData });
const nomFichier = input.files[0].name;
input.value = '';
await rafraichirMediaAccueil();
afficherStatut('Media importe', 'succes');
document.getElementById('admin-media-accueil').value = nomFichier;
// Applique automatiquement la video comme media d'accueil de l'evenement en cours
// (sinon il faut la re-selectionner dans le menu ET cliquer sur "Enregistrer" separement)
if (eventEditId) {
await apiPost('/api/config', { evenement: { media_accueil: nomFichier, event_id: eventEditId } });
await fetch(`/api/evenements/${eventEditId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ media_accueil: nomFichier }),
});
appliquerConfig();
}
afficherStatut('Media importe et applique', 'succes');
}
async function sauvegarderEvenement() {
@@ -925,16 +941,19 @@ async function uploaderCadreEvent() {
const input = document.getElementById('input-cadre-event');
if (!input.files.length) return;
const formData = new FormData();
formData.append('fichier', input.files[0]);
const nomFichier = input.files[0].name;
for (const fmt of FORMATS_CADRE_EVENT) {
const fd = new FormData();
fd.append('fichier', input.files[0]);
await fetch(`/api/evenements/${eventEditId}/upload-cadre/${fmt}`, { method: 'POST', body: fd });
}
input.value = '';
await chargerCadresEvent();
afficherStatut('Cadre importe (10x15 + 15x20)', 'succes');
// Applique automatiquement le cadre importe (sinon il reste inactif tant qu'on ne clique pas sur sa vignette)
const modeSelect = document.getElementById('mode-cadre-event');
if (modeSelect && modeSelect.value === 'aucun') modeSelect.value = 'impose';
await selectionnerCadreEvent(nomFichier);
afficherStatut('Cadre importe et applique (10x15 + 15x20)', 'succes');
}
async function supprimerCadreEvent(nom) {
@@ -1381,3 +1400,130 @@ async function chargerDiagCamera() {
el.textContent = 'ERREUR : ' + e.message;
}
}
// === VIDEOS DE SITUATION ===
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';
}
}
} catch (e) {
console.warn('Erreur chargement videos:', e);
}
}
async function uploaderVideoSituation(situation) {
const input = document.getElementById('input-video-' + situation);
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 });
if (resp.ok) {
input.value = '';
chargerVideosAdmin();
afficherStatut('Video importee', 'succes');
}
} catch (e) {
afficherStatut('Erreur upload video', 'erreur');
}
}
async function supprimerVideoSituation(situation) {
try {
await fetch('/api/videos/' + situation, { method: 'DELETE' });
chargerVideosAdmin();
afficherStatut('Video supprimee', 'succes');
} catch (e) {
afficherStatut('Erreur suppression video', 'erreur');
}
}
function previewVideoSituation(situation) {
const preview = document.getElementById('video-situation-preview');
if (preview) {
preview.src = '/api/videos/' + situation + '/stream';
preview.load();
}
}
// === SURPRISE (photo/video avant capture) ===
async function chargerSurpriseConfig() {
const surprise = config.surprise || {};
const tog = document.getElementById('tog-surprise-actif');
if (tog) tog.checked = !!surprise.actif;
const delai = document.getElementById('admin-surprise-delai');
if (delai) delai.value = surprise.delai_ms || 1000;
const typeRadios = document.querySelectorAll('input[name="surprise-type"]');
typeRadios.forEach(r => r.checked = r.value === (surprise.type || 'photo'));
const current = document.getElementById('surprise-current');
if (current) current.textContent = surprise.fichier || 'Aucun media configure';
majSurprisePreview(surprise);
}
function majSurprisePreview(surprise) {
const container = document.getElementById('surprise-preview');
if (!container) return;
if (!surprise || !surprise.fichier) {
container.innerHTML = '<span style="color:#666">Aucun apercu</span>';
return;
}
const url = '/api/surprise/media';
if (surprise.type === 'video') {
container.innerHTML = `<video src="${url}" style="width:100%;max-height:300px;border-radius:8px" controls></video>`;
} else {
container.innerHTML = `<img src="${url}" style="width:100%;max-height:300px;border-radius:8px;object-fit:contain">`;
}
}
async function uploaderSurprise() {
const input = document.getElementById('input-surprise-media');
if (!input || !input.files.length) return;
const fd = new FormData();
fd.append('media', input.files[0]);
try {
const resp = await fetch('/api/surprise/upload', { method: 'POST', body: fd });
if (resp.ok) {
const data = await resp.json();
input.value = '';
config = await apiGet('/api/config');
chargerSurpriseConfig();
afficherStatut('Media surprise importe', 'succes');
}
} catch (e) {
afficherStatut('Erreur upload surprise', 'erreur');
}
}
async function supprimerSurprise() {
try {
await fetch('/api/surprise/media', { method: 'DELETE' });
config = await apiGet('/api/config');
chargerSurpriseConfig();
afficherStatut('Surprise supprimee', 'succes');
} catch (e) {
afficherStatut('Erreur suppression surprise', 'erreur');
}
}
async function sauvegarderSurprise() {
const actif = document.getElementById('tog-surprise-actif')?.checked || false;
const delai = parseInt(document.getElementById('admin-surprise-delai')?.value) || 1000;
const type = document.querySelector('input[name="surprise-type"]:checked')?.value || 'photo';
await apiPost('/api/config', {
surprise: { actif, delai_ms: delai, type }
});
afficherStatut('Surprise sauvegardee', 'succes');
}

View File

@@ -298,12 +298,28 @@ function lancerSansCadre() {
allerA('capture');
}
// --- Onglets admin ---
// --- Rubriques + Onglets admin ---
let rubriqueActive = 'general';
function changerRubrique(nom) {
rubriqueActive = nom;
document.querySelectorAll('.rubrique').forEach(r => r.classList.toggle('actif', r.dataset.rubrique === nom));
document.querySelectorAll('.admin-onglets').forEach(g => g.classList.toggle('cache', g.id !== 'onglets-' + nom));
document.querySelectorAll('.admin-panneau').forEach(p => p.classList.remove('actif'));
document.querySelectorAll('.onglet').forEach(o => o.classList.remove('actif'));
const groupe = document.getElementById('onglets-' + nom);
if (groupe) {
const premier = groupe.querySelector('.onglet');
if (premier) { premier.click(); }
}
}
function setupOnglets() {
document.querySelectorAll('.onglet').forEach(onglet => {
onglet.addEventListener('click', () => {
document.querySelectorAll('.onglet').forEach(o => o.classList.remove('actif'));
const groupe = onglet.closest('.admin-onglets');
if (groupe) groupe.querySelectorAll('.onglet').forEach(o => o.classList.remove('actif'));
document.querySelectorAll('.admin-panneau').forEach(p => p.classList.remove('actif'));
onglet.classList.add('actif');
const panneau = document.getElementById('panneau-' + onglet.dataset.onglet);

View File

@@ -9,6 +9,27 @@ let captureEnCours = false; // true pendant tout le flux lancerCapture()
const EMOJI_CAR = { 3: '🤪', 2: '😱', 1: '🔥' };
// --- Surprise avant capture ---
async function afficherSurprise() {
const surprise = (config || {}).surprise;
if (!surprise || !surprise.actif || !surprise.fichier) return;
const delai = surprise.delai_ms || 1000;
const overlay = document.getElementById('surprise-overlay');
if (!overlay) return;
if (surprise.type === 'video') {
overlay.innerHTML = '<video src="/api/surprise/media" autoplay muted playsinline style="max-width:100%;max-height:100%;object-fit:contain"></video>';
} else {
overlay.innerHTML = '<img src="/api/surprise/media" style="max-width:100%;max-height:100%;object-fit:contain">';
}
overlay.classList.remove('cache');
await new Promise(r => setTimeout(r, delai));
overlay.classList.add('cache');
}
// --- Preview live ---
function afficherErreurCapture(titre, detail = '') {
@@ -253,6 +274,9 @@ async function lancerCapture() {
lancerPreview(); // Miroir live pendant le compte à rebours
await compteARebours();
// Surprise : afficher media juste avant capture
await afficherSurprise();
// Flash blanc immédiat + lancer capture en parallèle
const flash = document.getElementById('flash-blanc');
flash.classList.remove('cache');