From 44a9992557a993e4b761fb29507de57cc660f82b Mon Sep 17 00:00:00 2001 From: Jules Date: Thu, 9 Jul 2026 12:31:27 +0200 Subject: [PATCH] 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 --- PROCEDURE_VPN_FIX.md | 135 +++++++++++++++++++++++++++++++++++ backend/mailer.py | 41 ++++++++--- backend/main.py | 93 ++++++++++++++++++++++++ backend/printer.py | 6 +- frontend/css/style.css | 49 +++++++++++++ frontend/index.html | 100 +++++++++++++++++++++++--- frontend/js/admin.js | 156 +++++++++++++++++++++++++++++++++++++++-- frontend/js/app.js | 20 +++++- frontend/js/camera.js | 24 +++++++ memoire.md | 139 ++++++++++++++++++++++++++++++++++-- 10 files changed, 731 insertions(+), 32 deletions(-) create mode 100644 PROCEDURE_VPN_FIX.md diff --git a/PROCEDURE_VPN_FIX.md b/PROCEDURE_VPN_FIX.md new file mode 100644 index 0000000..89dc1c5 --- /dev/null +++ b/PROCEDURE_VPN_FIX.md @@ -0,0 +1,135 @@ +--- +name: booth-vpn-fix +description: Procédure urgente pour rétablir le tunnel WireGuard sur la Surface (booth terrain) et installer un watchdog +metadata: + node_type: memory + type: project + originSessionId: 6d8a34f4-db2f-4d5b-92e9-895e3925ccb5 +--- + +## Problème + +Le booth (Surface) est sur un WiFi externe, le tunnel WireGuard ne se connecte pas même après reboot. +Dernier handshake : 6+ jours. Aucun accès SSH ni VPN. + +**Why:** Le service wg-quick@wg0 n'est peut-être pas enabled au boot, ou l'Endpoint dans la config WG pointe vers une IP locale (192.168.111.x) au lieu de l'IP publique. + +## Infos serveur WG (LXC 111) + +- IP LXC : 192.168.111.211 +- Port WG : 51820 (UDP) — écoute OK +- Clé publique serveur : `g3s7Gh1Er+LIEI/W91HAVDNIo+gsk/KiajdtoDUQRVo=` +- Le port 51820 est redirigé depuis la box vers le LXC + +## Étapes à faire sur la Surface (SSH ou clavier physique) + +### 1. Diagnostic + +```bash +sudo systemctl status wg-quick@wg0 +sudo wg show +cat /etc/wireguard/wg0.conf +``` + +### 2. Vérifier l'Endpoint + +Dans `/etc/wireguard/wg0.conf`, le `[Peer]` doit avoir : +``` +Endpoint = :51820 +``` +**PAS** une IP locale type 192.168.111.x — ça ne marche que depuis le LAN. + +Pour trouver l'IP publique depuis le PC fixe : +```bash +curl -s ifconfig.me +``` + +### 3. Activer au boot + relancer + +```bash +sudo systemctl enable wg-quick@wg0 +sudo systemctl restart wg-quick@wg0 +sudo wg show +``` + +Vérifier que le handshake se fait (latest handshake < 1 minute). + +### 4. Installer le watchdog WG (une fois le tunnel rétabli) + +Créer `/usr/local/bin/wg-watchdog.sh` : +```bash +#!/bin/bash +IFACE=wg0 +MAX_AGE=180 # 3 minutes sans handshake = relance + +latest=$(sudo wg show $IFACE latest-handshakes 2>/dev/null | awk '{print $2}') +if [ -z "$latest" ] || [ "$latest" = "0" ]; then + systemctl restart wg-quick@$IFACE + exit 0 +fi + +now=$(date +%s) +age=$((now - latest)) +if [ $age -gt $MAX_AGE ]; then + logger "wg-watchdog: handshake age ${age}s > ${MAX_AGE}s, restarting $IFACE" + systemctl restart wg-quick@$IFACE +fi +``` + +```bash +sudo chmod +x /usr/local/bin/wg-watchdog.sh +echo "* * * * * root /usr/local/bin/wg-watchdog.sh" | sudo tee /etc/cron.d/wg-watchdog +``` + +### 5. Installer reverse SSH en fallback + +```bash +sudo apt install -y autossh +``` + +Créer `/etc/systemd/system/autossh-tunnel.service` : +```ini +[Unit] +Description=Reverse SSH tunnel fallback +After=network-online.target +Wants=network-online.target + +[Service] +User=jules +ExecStart=/usr/bin/autossh -M 0 -N -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" -o "ExitOnForwardFailure yes" -R 2222:localhost:22 root@192.168.111.211 +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable autossh-tunnel +sudo systemctl start autossh-tunnel +``` + +Ensuite depuis le LXC 111 : `ssh -p 2222 jules@localhost` pour joindre la Surface même si WG est mort. + +**Prérequis :** la clé SSH de jules@surface doit être dans authorized_keys de root@192.168.111.211 (LXC). + +### 6. Déployer les dernières modifs + +Une fois le tunnel rétabli : +```bash +cd /home/jules/photobooth && git pull && sudo systemctl restart photobooth +``` + +Commits non déployés sur Surface : +- Flash blanc capture + printer toast CSS + admin galerie + compteur copies +- Cache versions bump + +## Changements faits sur LXC 111 (cette session) + +- Landing page vitrine complète (tarifs, formulaire résa, calendrier dispo) +- Endpoint `/api/contact` : crée événement + email admin (pas client) +- Endpoint `/api/contact/valider/{id}` : valide résa + envoie email client +- Endpoint `/api/disponibilites` : dates occupées pour calendrier +- ZIP streaming (fix 500 sur gros ZIP) +- Barre progression téléchargement ZIP +- Admin panel : section "Demandes en attente" avec bouton Valider diff --git a/backend/mailer.py b/backend/mailer.py index 695b5ba..d2fb0aa 100644 --- a/backend/mailer.py +++ b/backend/mailer.py @@ -195,28 +195,47 @@ async def tache_spool_demarrage(): FICHIER_EMAILS = RACINE / "data" / "emails_history.json" +def _event_id_actif() -> str: + return charger_config().get("evenement", {}).get("event_id") or "_sans_evenement" + + +def _charger_toutes_historiques() -> dict: + if not FICHIER_EMAILS.exists(): + return {} + try: + with open(FICHIER_EMAILS, "r", encoding="utf-8") as f: + data = json.load(f) + # Compatibilite avec l'ancien format (liste globale non cloisonnee) + if isinstance(data, list): + return {"_sans_evenement": data} + return data + except (json.JSONDecodeError, OSError): + return {} + + def _sauvegarder_email_historique(email: str): - historique = charger_emails_historique() + toutes = _charger_toutes_historiques() + event_id = _event_id_actif() + historique = toutes.setdefault(event_id, []) email_lower = email.lower().strip() if email_lower not in historique: historique.append(email_lower) try: with open(FICHIER_EMAILS, "w", encoding="utf-8") as f: - json.dump(historique, f, ensure_ascii=False) + json.dump(toutes, f, ensure_ascii=False) except OSError: pass def charger_emails_historique() -> list: - if not FICHIER_EMAILS.exists(): - return [] - try: - with open(FICHIER_EMAILS, "r", encoding="utf-8") as f: - return json.load(f) - except (json.JSONDecodeError, OSError): - return [] + return _charger_toutes_historiques().get(_event_id_actif(), []) def effacer_emails_historique(): - if FICHIER_EMAILS.exists(): - FICHIER_EMAILS.unlink() + toutes = _charger_toutes_historiques() + toutes.pop(_event_id_actif(), None) + try: + with open(FICHIER_EMAILS, "w", encoding="utf-8") as f: + json.dump(toutes, f, ensure_ascii=False) + except OSError: + pass diff --git a/backend/main.py b/backend/main.py index daa8b27..09634e0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1072,19 +1072,28 @@ async def api_obtenir_evenement(event_id: str): return event +def _event_id_actif() -> str | None: + return charger_config().get("evenement", {}).get("event_id") + + @app.put("/api/evenements/{event_id}") async def api_modifier_evenement(event_id: str, donnees: dict): event = modifier_evenement(event_id, donnees) if not event: return JSONResponse({"erreur": "Evenement introuvable"}, status_code=404) + if event_id == _event_id_actif(): + await diffuser_ws({"type": "config_maj", "config": charger_config()}) return event @app.delete("/api/evenements/{event_id}") async def api_supprimer_evenement(event_id: str): + etait_actif = event_id == _event_id_actif() ok = supprimer_evenement(event_id) if not ok: return JSONResponse({"erreur": "Evenement introuvable"}, status_code=404) + if etait_actif: + await diffuser_ws({"type": "config_maj", "config": charger_config()}) return {"succes": True} @@ -1093,6 +1102,7 @@ async def api_activer_evenement(event_id: str): event = activer_evenement(event_id) if not event: return JSONResponse({"erreur": "Evenement introuvable"}, status_code=404) + await diffuser_ws({"type": "config_maj", "config": charger_config()}) return event @@ -1113,6 +1123,8 @@ async def api_set_cadre_event(event_id: str, format_papier: str, donnees: dict): mode = donnees.get("mode", "aucun") cadre = donnees.get("cadre") set_cadre_event(event_id, format_papier, mode, cadre) + if event_id == _event_id_actif(): + await diffuser_ws({"type": "config_maj", "config": charger_config()}) return {"succes": True} @@ -1384,6 +1396,87 @@ async def _lancer_photostation(): FLAG_PHOTOSTATION.unlink(missing_ok=True) +# --- API Videos de situation --- + +DOSSIER_VIDEOS = RACINE / "data" / "videos" +DOSSIER_VIDEOS.mkdir(parents=True, exist_ok=True) + +@app.get("/api/videos") +async def api_videos(): + videos = {} + for f in DOSSIER_VIDEOS.iterdir(): + if f.is_file() and f.suffix.lower() in ('.mp4', '.webm', '.mov'): + videos[f.stem] = f.name + return {"videos": videos} + + +@app.post("/api/videos/upload") +async def api_upload_video(video: UploadFile = File(...), situation: str = ""): + if not situation: + return JSONResponse({"erreur": "situation requise"}, 400) + ext = Path(video.filename).suffix.lower() or '.mp4' + dest = DOSSIER_VIDEOS / f"{situation}{ext}" + for old in DOSSIER_VIDEOS.glob(f"{situation}.*"): + old.unlink() + with open(dest, "wb") as f: + while chunk := await video.read(1024 * 1024): + f.write(chunk) + return {"ok": True, "fichier": dest.name} + + +@app.delete("/api/videos/{situation}") +async def api_delete_video(situation: str): + for f in DOSSIER_VIDEOS.glob(f"{situation}.*"): + f.unlink() + return {"ok": True} + + +@app.get("/api/videos/{situation}/stream") +async def api_stream_video(situation: str): + for f in DOSSIER_VIDEOS.glob(f"{situation}.*"): + media = "video/mp4" if f.suffix == ".mp4" else "video/webm" + return FileResponse(f, media_type=media) + return JSONResponse({"erreur": "video introuvable"}, 404) + + +# --- API Surprise --- + +DOSSIER_SURPRISE = RACINE / "data" / "surprise" +DOSSIER_SURPRISE.mkdir(parents=True, exist_ok=True) + +@app.post("/api/surprise/upload") +async def api_upload_surprise(media: UploadFile = File(...)): + for old in DOSSIER_SURPRISE.iterdir(): + old.unlink() + ext = Path(media.filename).suffix.lower() or '.jpg' + dest = DOSSIER_SURPRISE / f"surprise{ext}" + with open(dest, "wb") as f: + while chunk := await media.read(1024 * 1024): + f.write(chunk) + fichier_type = "video" if ext in ('.mp4', '.webm', '.mov') else "photo" + mettre_a_jour_config({"surprise": {"fichier": dest.name, "type": fichier_type}}) + return {"ok": True, "fichier": dest.name, "type": fichier_type} + + +@app.delete("/api/surprise/media") +async def api_delete_surprise(): + for f in DOSSIER_SURPRISE.iterdir(): + f.unlink() + mettre_a_jour_config({"surprise": {"fichier": None}}) + return {"ok": True} + + +@app.get("/api/surprise/media") +async def api_get_surprise(): + for f in DOSSIER_SURPRISE.iterdir(): + if f.is_file(): + ext = f.suffix.lower() + if ext in ('.mp4', '.webm', '.mov'): + return FileResponse(f, media_type="video/mp4") + return FileResponse(f, media_type="image/jpeg") + return JSONResponse({"erreur": "aucun media"}, 404) + + # --- API Systeme --- @app.post("/api/systeme/redemarrer") diff --git a/backend/printer.py b/backend/printer.py index 46d2946..a641f7a 100644 --- a/backend/printer.py +++ b/backend/printer.py @@ -299,6 +299,8 @@ def imprimer( } time.sleep(1) + rembobinage = conf_imp.get("rembobinage_ruban", False) + try: for tentative in range(1, 4): cmd = [ @@ -307,8 +309,10 @@ def imprimer( "-n", str(copies), "-o", f"PageSize={page_size}", "-o", "StpiShrinkOutput=Crop", - str(chemin_print), ] + if rembobinage: + cmd.extend(["-o", "StpiDecklist=true"]) + cmd.append(str(chemin_print)) code, out, err = _run(cmd, timeout=30) diff --git a/frontend/css/style.css b/frontend/css/style.css index b3229d1..1cfa957 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -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; diff --git a/frontend/index.html b/frontend/index.html index d0e274d..f68f66e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,7 @@ - + @@ -98,6 +98,7 @@ 3
+
Capture en cours... @@ -371,18 +372,26 @@
-
+
+ + +
+
- - - - + +
+
+ + + + +
@@ -429,6 +438,9 @@
+
+ +
@@ -846,6 +858,76 @@
Appuyer sur Tester...
+
+

Videos de situation

+

Uploadez des videos pour chaque situation. Elles seront jouees automatiquement sur l'ecran du booth.

+
+
+

Montage / Installation

+
Aucune video
+ + + +
+
+

Depannage imprimante

+
Aucune video
+ + + +
+
+

Bourrage papier

+
Aucune video
+ + + +
+
+

Rechargement papier/ruban

+
Aucune video
+ + + +
+
+

Apercu

+ +
+ +
+

Photo / Video surprise

+

Media affiche sur l'ecran ~1 seconde avant la prise de photo pour surprendre et provoquer un sourire.

+
+ +
+
+ +
+ + +
+
+
+ +
Aucun media configure
+
+
+ + + +
+
+ + +
+ +

Apercu

+
+ Aucun apercu +
+
+
@@ -930,11 +1012,11 @@ - - + + - + diff --git a/frontend/js/admin.js b/frontend/js/admin.js index a46c299..0620530 100644 --- a/frontend/js/admin.js +++ b/frontend/js/admin.js @@ -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 = `${_escHtml(v)}`; + } 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 = 'Aucun apercu'; + return; + } + const url = '/api/surprise/media'; + if (surprise.type === 'video') { + container.innerHTML = ``; + } else { + container.innerHTML = ``; + } +} + +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'); +} diff --git a/frontend/js/app.js b/frontend/js/app.js index 29f52bd..334c8b9 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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); diff --git a/frontend/js/camera.js b/frontend/js/camera.js index 2f72214..1a8f710 100644 --- a/frontend/js/camera.js +++ b/frontend/js/camera.js @@ -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 = ''; + } else { + overlay.innerHTML = ''; + } + 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'); diff --git a/memoire.md b/memoire.md index adc3543..834b2a0 100644 --- a/memoire.md +++ b/memoire.md @@ -2,7 +2,7 @@ ## Description Photobooth professionnel pour location evenementielle. -RPi4 + ecran tactile + DSLR (gphoto2) + imprimante sublimation. +Surface 6 + ecran tactile + DSLR Canon (gphoto2) + imprimante sublimation Mitsubishi. ## Architecture - **Backend** : Python FastAPI + WebSocket @@ -29,10 +29,141 @@ https://git.copydev.fr/jules/photobooth Phase 1-3 terminees (backend complet + frontend complet). Phase 4 : scripts production (install.sh, start.sh, systemd). -## Matériel reçu -- Imprimante sublimation Mitsubishi (reçue le 2026-05-28) - ## Notes - Projet cree le 2026-03-21 - Mode simulation camera si gphoto2 non installe (dev sans DSLR) - Config persistante dans data/config.json + +--- + +## Infra & réseau + +### Surface (booth terrain) +- IP LAN : 192.168.111.39 (DHCP, sujette à changement) +- IP VPN : 10.10.0.2 +- Accès SSH : `ssh surface` (alias ~/.ssh/config, clé ed25519) +- User : `jules`, repo : `~/photobooth` +- **Pas de clavier physique** — toute intervention SSH uniquement +- Déploiement : pas de systemd (photobooth.service disabled, plante exec 203). Le run = `scripts/kiosk-session.sh` lancé au login LightDM autologin : boucle relance backend + boucle Chromium kiosk. Pour déployer : `git push` depuis dev, puis `kill ` + `pkill -f 'chromium '` → relance auto avec git pull. +- Remote debugging Chromium : port 9222 +- Kernel linux-surface avec iptsd (tactile), scale-factor=3 +- `data/config.json` = état runtime (compteur, imprimante) — ne jamais écraser par commit/push depuis dev + +### LXC 111 (booth-manager / galerie) +- IP : 192.168.111.211 +- Accès : `ssh root@192.168.111.10` puis `pct exec 111 -- ` +- Code : `/opt/photobooth/backend/booth_admin.py` +- Service : `systemctl restart photobooth` +- Héberge : booth.copydev.fr (landing, galerie, admin) + +### WireGuard tunnel +- Serveur (LXC 111) : wg0, 10.10.0.1/24, port 51820/UDP, enabled +- Client (Surface) : 10.10.0.2/24 +- Clé publique serveur : `g3s7Gh1Er+LIEI/W91HAVDNIo+gsk/KiajdtoDUQRVo=` +- Clé privée client : `uH9lesPtezigCDlB6cSCYy87QFxrtLFWcnwi93/YgXM=` +- Clé publique client : `p+yIc+slDkKXnogvvuohbWlF8iapbI4LxJPH1RCaUig=` +- Port forward routeur : UDP 51820 → 192.168.111.211 + +### Proxmox PVE +- IP : 192.168.111.10, accès `ssh root@192.168.111.10` + +### NPM (Nginx Proxy Manager) +- IP : 192.168.111.5, port 81 +- Cert wildcard *.copydev.fr ID #2 + +--- + +## Règles de conduite Claude + +- **Pas de clavier sur la borne** : ne jamais suggérer Ctrl+R, F5, ou actions clavier. Forcer reload via SSH (port 9222) ou restart lightdm/chromium. +- **Pas de confirmation sur la Surface** : PC dédié, aucun risque, exécuter directement les commandes SSH. +- **Pas de SSH sauvage** : ne jamais SSH sur des serveurs non demandés sans instruction explicite. + +--- + +## Matériel + +### Imprimante sublimation Mitsubishi +- Reçue le 2026-05-28 +- Erreur 03/01/01 sur K60 = papier mal chargé (pas fin de rouleau) + +### Module relais USB HID +- Commandé le 2026-06-23, livraison estimée ~2026-07-01 +- Pour piloter alim secteur Canon (power-cycle PTP freeze) + éclairage booth +- Intégration via /dev/hidraw* dans le backend Python + +--- + +## UX dépannage novices +Utilisateurs finaux = novices (événementiel). Erreurs imprimante : prévoir guides imagés (popup plein écran, étapes numérotées, icônes grandes, pas de codes techniques). Cas : cassette mal insérée, bourrage, fin consommable, câble USB. + +--- + +## URGENT — VPN booth en panne (2026-07-04) + +Booth sur WiFi externe, tunnel WG ne connecte pas même après reboot. Dernier handshake : 6+ jours. +Cause probable : wg-quick@wg0 pas enabled au boot, ou Endpoint pointe vers IP locale. + +### Procédure fix (clavier USB temporaire) + +1. `sudo systemctl status wg-quick@wg0 && sudo wg show && cat /etc/wireguard/wg0.conf` +2. Vérifier Endpoint = `:51820` (pas 192.168.111.x). IP publique : `curl -s ifconfig.me` +3. `sudo systemctl enable wg-quick@wg0 && sudo systemctl restart wg-quick@wg0` +4. Installer watchdog + reverse SSH — voir PROCEDURE_VPN_FIX.md + +### Commits non déployés sur Surface +- Flash blanc capture + printer toast CSS + admin galerie + compteur copies +- Déployer : `cd ~/photobooth && git pull && kill $(pgrep -f 'python -m backend.main')` + +--- + +## LXC 111 — booth.copydev.fr (état au 2026-07-04) + +Code sur le serveur : `/opt/photobooth/` (PAS dans le repo git local). +Backup local des fichiers modifiés : `lxc111_backup/` dans ce dossier projet. + +### Fichiers modifiés sur LXC 111 +- `backend/main.py` (1126 lignes) — endpoints galerie + réservation +- `backend/booth_admin.py` (808 lignes) — admin panel API + ZIP streaming +- `frontend/landing.html` (900 lignes) — page vitrine complète +- `frontend/admin.html` (930 lignes) — panel admin avec validation résa +- `frontend/gallery.html` (414 lignes) — galerie publique + modal ZIP progress + +### Endpoints ajoutés +- `GET /api/disponibilites` → retourne dates réservées (statut "valide" ou "demande") +- `POST /api/contact` → crée événement + envoie email admin (pas client). Champs : nom, email, telephone, nom_evenement, type, date, formule, theme, nb_invites, impressions, opt_livraison, message +- `POST /api/contact/valider/{event_id}` → passe statut "valide" + envoie email confirmation au client avec URL galerie + code accès + +### Landing page (frontend/landing.html) +- Vitrine commerciale booth.copydev.fr, couleur indigo #5c6bc0 +- 6 cartes prestations (Canon 18Mpx, Mitsubishi sublimation, galerie live, personnalisation, écran tactile, partage) +- 3 tarifs : Pack 300 (300€), Pack 600 (450€), Livraison & installation (sur devis) +- Formulaire réservation → crée événement auto → email admin +- Calendrier dispo : WE = sam+dim (un bloc), semaine = lun→ven (un bloc), même prix. Dates réservées bloquées. +- Modal accès galerie (code 4 digits) +- Lien admin caché (coin bas-droit, opacity:0 → hover 0.3) + +### Admin panel (frontend/admin.html) +- Section "Demandes en attente" (orange) avec boutons Valider/Refuser +- Section "Galeries en ligne" séparée +- `validerReservation()` → `POST /api/contact/valider/{id}` avec `credentials: 'include'` + +### ZIP galerie +- Corrigé crash mémoire (648 Mo in-memory → temp file + streaming 1 Mo chunks) +- Barre de progression dans modal (ReadableStream, affiche Mo reçus + %) +- Restreint admin only (pas guest) + +### SMTP +- Corrigé pour port 587 : STARTTLS (pas SMTP_SSL qui est pour 465) +- Config OVH : ssl0.ovh.net port 587 + +### Compteur photos +- `distribuer_photo()` accepte paramètre `copies` et incrémente par ce nombre (pas toujours +1) + +## Tâches Trello du projet + +- [📋 À faire] faire essaie avec mon dslr et imprimante ricoh et installer sur ecran tactile terra + (card_id: 69c1122fec1b558a76e207b0) + +Utilise `claude-trello` pour mettre à jour les cartes pendant le travail. +Labels: green=fait, yellow=en cours, red=bloqué, (aucun)=à faire