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:
135
PROCEDURE_VPN_FIX.md
Normal file
135
PROCEDURE_VPN_FIX.md
Normal file
@@ -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 = <IP_PUBLIQUE_MAISON>: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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')">×</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()">⚠ 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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
139
memoire.md
139
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 <pid backend.main>` + `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 -- <cmd>`
|
||||
- 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 = `<IP_PUBLIQUE>: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
|
||||
|
||||
Reference in New Issue
Block a user