Compare commits

...

41 Commits

Author SHA1 Message Date
admin b2d3bb1833 feat: rampe LED progressive en salle sombre + fix setFlashPreset
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-21 00:24:11 +02:00
admin fc9e9c7def fix: flash ON time = pre_flash_advance + flash_duration 2026-07-21 00:23:45 +02:00
admin bc5f9213ec config: countdown 5s, pre_flash_advance 2.5s 2026-07-21 00:23:30 +02:00
admin 35233c0a43 feat: slider pre-flash advance (0-3s) dans settings UI
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-21 00:02:12 +02:00
admin 1419600969 fix: couleurs widget LED dashboard (countdown=amber, captured=vert, printing=violet)
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-20 23:53:19 +02:00
admin 370d08371a feat: couleurs LEDs + pre-flash 0.5s avant countdown
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-20 23:49:38 +02:00
admin 69b9cd0901 feat: pre-flash configurable avant fin countdown
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-20 23:29:55 +02:00
admin b2ee41c367 test: flash 2s pour diagnostic
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
Changes to be committed:
	modified:   config/settings.yaml
2026-07-20 23:13:15 +02:00
admin 5142d6c8d9 maj settings
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-20 23:07:00 +02:00
admin ffa25646b9 fix: URL liveview MJPEG correcte (/api/aquisition/stream.mjpg)
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-20 19:38:59 +02:00
admin 19557b20fb fix: timing flash + couleur idle LED + diagnostic auto-brightness
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-20 19:06:16 +02:00
admin f192ffc92f Dashboard: boutons restart services, JH-Photomaton et reboot Pi
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-20 19:04:37 +02:00
admin fb35533b37 Dashboard: boutons restart services, JH-Photomaton et reboot Pi
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-18 10:35:43 +02:00
admin 3522d2d996 feat: bouton restart photobooth-app après modification des actions
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-18 10:33:57 +02:00
admin 2b99ef6469 fix: remove TEST_MODE reference
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 18:23:57 +02:00
admin dd5c4be555 Changes to be committed:
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
modified:   Opencode/scripts/script_print.sh
	modified:   photobooth-app/script/script_print.sh
2026-07-17 18:20:29 +02:00
admin 94def589bd fix: mode descriptions, bordure impression (remove raw+extent), logs httpx silencieux
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 18:00:00 +02:00
admin 0b612e1302 feat: impression depuis galerie publique (quota session, modal copies)
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 17:52:52 +02:00
admin d6642c2519 feat: impression galerie utilisateur (enable/quota/max_copies)
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 17:44:47 +02:00
admin 4388503102 fix: remove UI button section, fix _path→path, show folder in file pickers
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 17:41:54 +02:00
admin 2393396948 feat: gestion complète des actions (clone, delete, reorder, presets)
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
- actions_api.py: POST /create, POST /{idx}/clone, DELETE /{idx},
  PUT /reorder, PUT /{idx} étendu (ui_trigger.*),
  GET/POST/POST-restore/DELETE /presets
- admin_api.py: route /admin/actions simplifiée (plus de pré-chargement Jinja2)
- actions.html: page full JS dynamique
  · Drag & drop pour réordonner (barre de sauvegarde explicite)
  · Clone, suppression, création par bouton
  · Edit form complet: général, cadre, fond, traitement, UI bouton
  · Sélecteurs cadres/fonds avec dropdown + saisie libre
  · Mapping bouton dynamique
  · Presets: save/restore/delete avec nom + description
2026-07-17 17:36:32 +02:00
admin 5bc55c7591 fix: moniteur imprimante ignore les imprimantes offline (non branchées)
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 17:25:47 +02:00
admin 31f4023f16 chore: fichiers modifiés sessions précédentes
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 17:19:57 +02:00
admin 8a70dea240 feat: détection erreurs imprimante via IPP direct (Selphy CP1300)
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
- script_print.sh : après lp, polling CUPS + sondage IPP direct ipp://IP:631/ipp/print
  Détecte input-tray-missing / media-empty en ~3-6s sans attendre le timeout CUPS
- printer_service.py : ajout _get_printer_ip(), _query_ipp_direct()
  _get_printer_status() utilise IPP pour état/raisons/encre (source de vérité)
- Timeout porté à 120s côté Python, fallback lpstat conservé
2026-07-17 17:08:00 +02:00
admin 68b0a36031 Changes to be committed:
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
modified:   Opencode/scripts/script_print.sh
	modified:   backend/services/printer_service.py
	modified:   frontend/templates/admin/actions.html
	modified:   frontend/templates/admin/events.html
	modified:   frontend/templates/admin/gallery.html
	modified:   frontend/templates/admin/settings.html
	modified:   frontend/templates/base.html
	modified:   frontend/templates/public/gallery.html
 Untracked files:
	photobooth-app/script/
	photobooth-app/userdata/
2026-07-17 17:02:57 +02:00
admin c32b3ba345 feat: badges imprimé ( copies) dans galeries admin+public; fix: timestamp_to_date dans print.html
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 16:48:48 +02:00
admin 742186ec8e fix: UUID→fichier via API photobooth (created_at/processed), photo_id dans print_queue
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 16:44:33 +02:00
admin 4592f20be3 fix: mtime index dans processed_full, print 404, galerie full-width
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 16:29:06 +02:00
admin 11bd91c386 fix: mtime filter + lightbox navigation (arrows, swipe, keyboard)
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 16:22:41 +02:00
admin c89924ea18 feat: admin gallery - lightbox with icons, filters by date/event/status; fix: event badges - only current slug shows En cours; fix: archive_all_open on new event; fix: mtime_index uses stem for event photo filter
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 15:59:51 +02:00
admin 223d71883e fix: gallery download - use local URL for proxy, rename file with event name + photo date
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 15:54:00 +02:00
admin f6d4eafd20 fix: settings - add missing previewFlash/saveFlash/colorPickerToRgb/loadFlashConfig; fix previewBrightness to call API; fix printer refresh to update state badges
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 15:49:13 +02:00
admin fed824fbb1 fix: use __dataclass_fields__ instead of hasattr to load config - fixes printers list being ignored
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 15:44:03 +02:00
admin 4629962371 feat: printer card - URI, model, state reasons, marker levels; add zoraxy/kiosk/hostapd/dnsmasq to services dashboard; fix settings.html truncated template
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 15:33:05 +02:00
admin 35faf19db0 fix: settings.html template truncated - complete missing JS + endblock
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 14:59:32 +02:00
admin b4426e9738 feat: add zoraxy, kiosk, hostapd, dnsmasq to services dashboard
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 14:56:36 +02:00
admin d5f111d60b feat: gestion événements — historique, export ZIP, filtre galerie
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 14:43:05 +02:00
admin 7d60eba86d fix: wayland-0 pour wtype refresh écran
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 14:35:51 +02:00
admin 7fa1b49a9b config: merge settings
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled
2026-07-17 14:19:31 +02:00
admin 6b53269484 fix: maintenance.sh bug dmesg grep-c 2026-07-17 14:17:18 +02:00
admin b199765e03 config: timings bouton + auto_brightness depuis config Pi 2026-07-17 14:06:58 +02:00
69 changed files with 11827 additions and 856 deletions
+24
View File
@@ -0,0 +1,24 @@
# JH Photomaton — droits sudo sans mot de passe pour la gestion des services
#
# INSTALLATION :
# sudo cp sudoers-jh-photomaton /etc/sudoers.d/jh-photomaton
# sudo chmod 440 /etc/sudoers.d/jh-photomaton
# sudo visudo -cf /etc/sudoers.d/jh-photomaton # vérification syntaxe
#
# Ces règles permettent à l'utilisateur "pi" de redémarrer les services
# nécessaires au photomaton ainsi que de rebooter le Pi, sans mot de passe.
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart photobooth-app.service
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart photobooth-kiosk.service
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart zoraxy.service
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart hostapd.service
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart dnsmasq.service
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart cups.service
pi ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart jh-photomaton.service
pi ALL=(ALL) NOPASSWD: /usr/sbin/reboot
# Gestion des imprimantes CUPS (boutons activer/désactiver dans l'admin impression)
pi ALL=(ALL) NOPASSWD: /usr/sbin/cupsenable
pi ALL=(ALL) NOPASSWD: /usr/sbin/cupsdisable
pi ALL=(ALL) NOPASSWD: /usr/sbin/cupsaccept
pi ALL=(ALL) NOPASSWD: /usr/sbin/cupsreject
+2 -1
View File
@@ -112,7 +112,8 @@ log_info "Cache systeme nettoye"
log_info "Verification de l'integrite du systeme..."
# Verifier le filesystem (sans corriger, juste signaler)
disk_errors=$(dmesg | grep -c "EXT4-fs error" 2>/dev/null || echo "0")
disk_errors=$(dmesg 2>/dev/null | grep -c "EXT4-fs error" || true)
disk_errors=${disk_errors:-0}
if [ "$disk_errors" -gt 0 ]; then
log_warn " $disk_errors erreur(s) filesystem detectee(s) dans dmesg!"
fi
+140 -53
View File
@@ -8,10 +8,22 @@
# Imprimantes: Canon Selphy CP1300 x2 via WiFi (blanche + noire)
# Load balancing: utilise l'imprimante idle, sinon file d'attente sur la 1ere
#
# Sortie stdout parsable par Node-RED:
# PRINTED:<printer_name>:<copies> en cas de succes
# PRINT_ERROR:<printer_name> en cas d'erreur
# Sortie stdout parsable par printer_service.py:
# PRINTED:<printer_name>:<copies> en cas de succes
# PRINT_ERROR:<printer_name>:<raison> en cas d'erreur
# =============================================================================
#
# ── CALIBRAGE IMPRESSION ─────────────────────────────────────────────────────
#
# Selphy CP1300 — papier Postcard borderless 100x148mm
# Image traitée par photobooth-app : 2000x1333
# Overscan : 2114x1418 avec fond blanc (la Selphy rogne les bords en borderless)
# Décalage : -roll -12+8 (calibré pour l'offset naturel de l'imprimante)
# Option CUPS : PageSize=Postcard.Borderless (défaut imprimante = om_postcard-borderless_100x148mm)
# PAS de -o raw : le driver CUPS gère le rendu correctement
# =============================================================================
# (plus de TEST_MODE — paramètres calibrés définitifs ci-dessous)
set -uo pipefail
@@ -20,23 +32,13 @@ filename="${1:-}"
media_type="${2:-}"
action_config_name="${3:-}"
copies="${4:-1}"
# Liste CSV d'imprimantes à exclure du load-balancing (optionnel, passé par JH-Photomaton)
skip_printers="${5:-}"
# --- Noms des imprimantes CUPS (WiFi) ---
PRINTER_1="Selphy_Blanche_WiFi"
PRINTER_2="Selphy_Noire_WiFi"
# --- Options d'impression Selphy ---
# Mode raw : envoie le JPEG directement a la Selphy
# Postcard.Fullbleed : impression sans bordures
PRINT_OPTIONS="-o media=Postcard.Fullbleed -o raw"
# --- Calibrage bordures pour impression Fullbleed ---
# La Selphy deborde de quelques mm en mode Fullbleed
# On ajoute des marges noires pour compenser et eviter de couper la photo
# Valeurs calibrees pour Canon Selphy CP1300 via WiFi
PRINT_EXTENT="2114x1418"
PRINT_ROLL="-12+8"
# --- Logging ---
LOG_TAG="photomaton-print"
log_info() { logger -t "$LOG_TAG" "[INFO] $1"; echo "[INFO] $1"; }
@@ -59,80 +61,165 @@ if [ "$copies" -lt 1 ] || [ "$copies" -gt 3 ]; then
fi
# --- Preparation de l'image pour impression ---
# Resize a 2000x1333 (ratio 3:2), ajout de marges noires pour compenser
# le debordement Fullbleed, puis decalage pour centrer correctement
filename_no_ext="${filename%.jpg}"
filename_print="${filename_no_ext}_print.jpg"
if command -v convert &>/dev/null; then
log_info "Preparation de l'image pour impression Fullbleed..."
if ! command -v convert &>/dev/null; then
log_error "ImageMagick (convert) non installe — impression de l'original"
FILE_TO_PRINT="$filename"
PRINT_OPTIONS="-o PageSize=Postcard.Borderless"
else
log_info "Preparation image pour impression borderless..."
if convert "$filename" \
-resize 2000x1333 \
-background black \
-background white \
-gravity center \
-extent ${PRINT_EXTENT} \
-roll ${PRINT_ROLL} \
-extent 2114x1418 \
-roll -12+8 \
-quality 95 \
"$filename_print" 2>/dev/null; then
FILE_TO_PRINT="$filename_print"
log_info "Image preparee: $filename_print"
PRINT_OPTIONS="-o PageSize=Postcard.Borderless"
log_info "Image preparee: 2114x1418 (overscan+roll) → $filename_print"
else
log_error "Echec de la preparation, impression de l'original"
log_error "Echec convert — impression de l'original"
FILE_TO_PRINT="$filename"
PRINT_OPTIONS="-o PageSize=Postcard.Borderless"
fi
else
log_error "ImageMagick (convert) non installe, impression de l'original"
FILE_TO_PRINT="$filename"
fi
# --- Selection de l'imprimante (load balancing) ---
printer_1_status=$(lpstat -p "$PRINTER_1" 2>/dev/null || echo "not found")
printer_2_status=$(lpstat -p "$PRINTER_2" 2>/dev/null || echo "not found")
# Verifie si une imprimante est dans la liste skip_printers (CSV)
is_skipped() { [[ ",$skip_printers," == *",$1,"* ]]; }
# Une imprimante est utilisable si CUPS la connait ET qu'elle n'est pas exclue
p1_ok=false
p2_ok=false
[[ "$printer_1_status" != "not found" ]] && ! is_skipped "$PRINTER_1" && p1_ok=true
[[ "$printer_2_status" != "not found" ]] && ! is_skipped "$PRINTER_2" && p2_ok=true
SELECTED_PRINTER=""
if [[ "$printer_1_status" == *"idle"* ]]; then
if $p1_ok && [[ "$printer_1_status" == *"idle"* ]]; then
SELECTED_PRINTER="$PRINTER_1"
log_info "Imprimante selectionnee: $PRINTER_1 (idle)"
elif [[ "$printer_2_status" == *"idle"* ]]; then
elif $p2_ok && [[ "$printer_2_status" == *"idle"* ]]; then
SELECTED_PRINTER="$PRINTER_2"
log_info "Imprimante selectionnee: $PRINTER_2 (idle)"
else
# Aucune idle : choisir celle avec le moins de jobs en attente
elif $p1_ok && $p2_ok; then
# Les deux sont accessibles mais aucune n'est idle — load balance sur les jobs
jobs_1=$(lpstat -o "$PRINTER_1" 2>/dev/null | wc -l)
jobs_2=$(lpstat -o "$PRINTER_2" 2>/dev/null | wc -l)
if [ "$jobs_1" -le "$jobs_2" ] 2>/dev/null; then
SELECTED_PRINTER="$PRINTER_1"
else
SELECTED_PRINTER="$PRINTER_2"
fi
log_info "Aucune imprimante idle, file d'attente sur: $SELECTED_PRINTER (jobs: P1=$jobs_1, P2=$jobs_2)"
log_info "Load balancing: $SELECTED_PRINTER (P1=$jobs_1 jobs, P2=$jobs_2 jobs)"
elif $p1_ok; then
SELECTED_PRINTER="$PRINTER_1"
log_info "Seule imprimante disponible: $PRINTER_1 (P2 inaccessible ou exclue)"
elif $p2_ok; then
SELECTED_PRINTER="$PRINTER_2"
log_info "Seule imprimante disponible: $PRINTER_2 (P1 inaccessible ou exclue)"
else
log_error "Aucune imprimante accessible dans CUPS"
echo "PRINT_ERROR::no_printer_available"
[ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
exit 1
fi
# --- Impression ---
log_info "Impression x${copies} sur $SELECTED_PRINTER: $FILE_TO_PRINT"
if lp -n "$copies" -d "$SELECTED_PRINTER" $PRINT_OPTIONS "$FILE_TO_PRINT"; then
log_info "Job d'impression envoye avec succes"
# Sortie parsable par Node-RED pour les compteurs
echo "PRINTED:${SELECTED_PRINTER}:${copies}"
# Nettoyage du fichier temporaire (apres que CUPS l'ait lu)
if [ "$FILE_TO_PRINT" = "$filename_print" ] && [ -f "$filename_print" ]; then
(sleep 30 && rm -f "$filename_print" 2>/dev/null) &
fi
exit 0
else
log_error "Echec de l'envoi du job d'impression"
echo "PRINT_ERROR:${SELECTED_PRINTER}"
# Nettoyage meme en cas d'erreur
if [ -f "$filename_print" ]; then
rm -f "$filename_print" 2>/dev/null
fi
job_output=$(lp -n "$copies" -d "$SELECTED_PRINTER" $PRINT_OPTIONS "$FILE_TO_PRINT" 2>&1)
lp_exit=$?
if [ $lp_exit -ne 0 ]; then
log_error "lp a echoue (exit $lp_exit): $job_output"
echo "PRINT_ERROR:${SELECTED_PRINTER}:lp_failed"
[ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
exit 1
fi
# Extraction du job ID (ex: "request id is Selphy_Blanche_WiFi-42 (1 file(s))")
JOB_ID=$(echo "$job_output" | grep -oE '[A-Za-z0-9_-]+-[0-9]+' | head -1)
log_info "Job CUPS accepte: ${JOB_ID:-inconnu}"
if [ -z "$JOB_ID" ]; then
log_info "Job ID non extrait — impression reputee reussie"
echo "PRINTED:${SELECTED_PRINTER}:${copies}"
[ "$FILE_TO_PRINT" = "$filename_print" ] && (sleep 30 && rm -f "$filename_print" 2>/dev/null) &
exit 0
fi
# --- Sondage IPP direct (Selphy CP1300 port 631, sans auth) ---
PRINTER_IP=$(lpstat -v "$SELECTED_PRINTER" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
IPP_URI=""
IPP_TEST="/usr/share/cups/ipptool/get-printer-attributes.test"
if [ -n "$PRINTER_IP" ] && command -v ipptool &>/dev/null && [ -f "$IPP_TEST" ]; then
IPP_URI="ipp://${PRINTER_IP}:631/ipp/print"
log_info "Sondage IPP direct actif: $IPP_URI"
else
log_info "Sondage IPP desactive (IP=${PRINTER_IP:-inconnu})"
fi
IPP_ERROR_REASONS="input-tray-missing|media-empty|media-needed|marker-supply-empty|cover-open|door-open|offline-report"
# --- Suivi du job CUPS ---
MAX_WAIT=90
POLL_INTERVAL=3
elapsed=0
log_info "Suivi du job (max ${MAX_WAIT}s)..."
while [ $elapsed -lt $MAX_WAIT ]; do
sleep $POLL_INTERVAL
elapsed=$((elapsed + POLL_INTERVAL))
# 1. Succes CUPS
if lpstat -W completed 2>/dev/null | grep -q "^${JOB_ID} "; then
log_info "Job $JOB_ID termine avec succes (${elapsed}s)"
echo "PRINTED:${SELECTED_PRINTER}:${copies}"
[ "$FILE_TO_PRINT" = "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
exit 0
fi
# 2. Erreur IPP directe (papier absent, capot ouvert, etc.)
if [ -n "$IPP_URI" ]; then
ipp_reasons=$(ipptool -tv "$IPP_URI" "$IPP_TEST" 2>/dev/null \
| grep "printer-state-reasons" | head -1)
if echo "$ipp_reasons" | grep -qiE "$IPP_ERROR_REASONS"; then
reason=$(echo "$ipp_reasons" | grep -oiE "$IPP_ERROR_REASONS" | head -1)
log_error "Erreur imprimante via IPP: $reason (${elapsed}s)"
cancel "$JOB_ID" 2>/dev/null || true
echo "PRINT_ERROR:${SELECTED_PRINTER}:${reason}"
[ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
exit 1
fi
fi
# 3. Fallback CUPS stopped/disabled
printer_state=$(lpstat -p "$SELECTED_PRINTER" 2>/dev/null || true)
if echo "$printer_state" | grep -qiE "stopped|disabled"; then
reason=$(lpstat -l -p "$SELECTED_PRINTER" 2>/dev/null \
| grep -i "Reason:" | head -1 \
| sed 's/.*Reason:[[:space:]]*//' | xargs)
log_error "Imprimante stoppee CUPS: ${reason:-stopped} (${elapsed}s)"
cancel "$JOB_ID" 2>/dev/null || true
echo "PRINT_ERROR:${SELECTED_PRINTER}:${reason:-stopped}"
[ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
exit 1
fi
log_info "Impression en cours... (${elapsed}s / ${MAX_WAIT}s)"
done
log_error "Timeout impression apres ${MAX_WAIT}s (job: $JOB_ID)"
cancel "$JOB_ID" 2>/dev/null || true
echo "PRINT_ERROR:${SELECTED_PRINTER}:timeout_${MAX_WAIT}s"
[ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
exit 1
+233 -57
View File
@@ -1,110 +1,188 @@
"""API de gestion des actions photobooth-app + mapping bouton."""
"""API de gestion des actions photobooth-app + mapping bouton + presets."""
import copy
import json
import logging
import re
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Request, Body
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Action par défaut ────────────────────────────────────────────────────────
DEFAULT_ACTION: dict = {
"name": "Nouvelle action",
"jobcontrol": {"countdown_capture": 5.0},
"processing": {
"remove_background": False,
"fill_background_enable": False,
"fill_background_color": "#ededed",
"img_background_enable": False,
"img_background_file": None,
"image_filter": "original",
"img_frame_enable": False,
"img_frame_file": None,
"texts_enable": False,
"texts": [],
},
"trigger": {
"ui_trigger": {
"show_button": False,
"title": "Nouvelle action",
"icon": "Photo",
"use_custom_color": False,
"custom_color": "#016911",
},
"keyboard_trigger": {"keycode": ""},
"gpio_trigger": {"pin": "", "trigger_on": "pressed"},
},
}
def _presets_dir(request: Request) -> Path:
base = Path(request.app.state.config_service.path).parent.parent / "data" / "action_presets"
base.mkdir(parents=True, exist_ok=True)
return base
# ── Lecture ───────────────────────────────────────────────────────────────────
@router.get("/actions/photobooth")
async def get_pb_actions(request: Request):
"""Retourne les actions image de photobooth-app + le mapping bouton actuel."""
pb = request.app.state.photobooth_service
cfg = request.app.state.config
pb_config = await pb.read_pb_config()
actions = pb_config.get("actions", {}).get("image", [])
# Enrichit avec l'index
for i, action in enumerate(actions):
action["_index"] = i
return {
"actions": actions,
"button_mapping": cfg.button_actions,
}
return {"actions": actions, "button_mapping": cfg.button_actions}
@router.get("/actions/assets")
async def get_assets(request: Request):
"""Liste les cadres et fonds disponibles dans userdata."""
pb = request.app.state.photobooth_service
frames = await pb.list_userdata_frames()
backgrounds = await pb.list_userdata_backgrounds()
return {"frames": frames, "backgrounds": backgrounds}
@router.put("/actions/mapping")
async def update_button_mapping(
request: Request,
mapping: dict = Body(...),
):
"""
Met à jour le mapping clics → actions.
Body: { "1": {"label": "...", "photobooth_index": 0}, ... }
"""
config_svc = request.app.state.config_service
# ── CRUD actions ──────────────────────────────────────────────────────────────
# Validation basique
for k, v in mapping.items():
if not isinstance(v, dict) or "photobooth_index" not in v:
return JSONResponse({"error": f"Format invalide pour clic {k}"}, status_code=400)
@router.post("/actions/photobooth")
async def create_action(request: Request, body: dict = Body(default={})):
"""Crée une nouvelle action vide."""
pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config()
actions = pb_config.setdefault("actions", {}).setdefault("image", [])
new_action = copy.deepcopy(DEFAULT_ACTION)
if body.get("name"):
new_action["name"] = body["name"]
actions.append(new_action)
await pb.write_pb_config(pb_config)
idx = len(actions) - 1
logger.info("Nouvelle action créée: index %d, nom=%s", idx, new_action["name"])
return {"ok": True, "index": idx, "action": new_action}
config_svc.save_button_actions(mapping)
request.app.state.config.button_actions = mapping
# Met aussi à jour le button_service
btn = request.app.state.button_service
btn._btn_actions = mapping
@router.post("/actions/photobooth/{index}/clone")
async def clone_action(request: Request, index: int):
"""Clone une action (insérée juste après l'original)."""
pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config()
actions = pb_config.get("actions", {}).get("image", [])
if index < 0 or index >= len(actions):
return JSONResponse({"error": f"Index {index} invalide"}, status_code=400)
cloned = copy.deepcopy(actions[index])
cloned.pop("_index", None)
cloned["name"] = f"{cloned.get('name', 'Action')} (copie)"
actions.insert(index + 1, cloned)
await pb.write_pb_config(pb_config)
logger.info("Action %d clonée → %d: %s", index, index + 1, cloned["name"])
return {"ok": True, "index": index + 1, "action": cloned}
logger.info("Mapping bouton mis à jour: %s", mapping)
return {"ok": True, "mapping": mapping}
@router.delete("/actions/photobooth/{index}")
async def delete_action(request: Request, index: int):
"""Supprime une action."""
pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config()
actions = pb_config.get("actions", {}).get("image", [])
if index < 0 or index >= len(actions):
return JSONResponse({"error": f"Index {index} invalide"}, status_code=400)
if len(actions) <= 1:
return JSONResponse({"error": "Impossible de supprimer la dernière action"}, status_code=400)
removed = actions.pop(index)
await pb.write_pb_config(pb_config)
logger.info("Action %d supprimée: %s", index, removed.get("name"))
return {"ok": True, "removed_name": removed.get("name")}
@router.put("/actions/photobooth/reorder")
async def reorder_actions(request: Request, order: list = Body(...)):
"""Réordonne les actions. Body: [ancien_idx_0, ancien_idx_1, …]"""
pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config()
actions = pb_config.get("actions", {}).get("image", [])
if sorted(order) != list(range(len(actions))):
return JSONResponse({"error": "Ordre invalide"}, status_code=400)
pb_config["actions"]["image"] = [actions[i] for i in order]
await pb.write_pb_config(pb_config)
logger.info("Actions réordonnées: %s", order)
return {"ok": True, "order": order}
@router.put("/actions/photobooth/{index}")
async def update_pb_action(
request: Request,
index: int,
updates: dict = Body(...),
):
"""
Met à jour une action image de photobooth-app (cadre, fond, countdown, etc.)
updates peut contenir: countdown_capture, img_frame_file, img_background_file,
remove_background, image_filter, name
async def update_pb_action(request: Request, index: int, updates: dict = Body(...)):
"""Met à jour une action (tous les champs sauf GPIO/keyboard trigger).
Champs plats acceptés:
name, countdown_capture,
img_frame_enable, img_frame_file,
img_background_enable, img_background_file,
fill_background_enable, fill_background_color,
remove_background, image_filter, texts_enable,
ui_show_button, ui_title, ui_icon, ui_use_custom_color, ui_custom_color
"""
pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config()
actions = pb_config.get("actions", {}).get("image", [])
if index < 0 or index >= len(actions):
return JSONResponse({"error": f"Index {index} invalide (max: {len(actions)-1})"}, status_code=400)
return JSONResponse({"error": f"Index {index} invalide"}, status_code=400)
action = actions[index]
# Mise à jour des champs autorisés
allowed_root = {"name"}
allowed_processing = {"remove_background", "img_frame_file", "img_background_file",
"image_filter", "fill_background_enable", "fill_background_color",
"img_background_enable", "texts_enable"}
allowed_jobcontrol = {"countdown_capture"}
if "name" in updates:
action["name"] = updates["name"]
for key, value in updates.items():
if key in allowed_root:
action[key] = value
elif key in allowed_processing:
action.setdefault("processing", {})[key] = value
elif key in allowed_jobcontrol:
action.setdefault("jobcontrol", {})[key] = value
if "countdown_capture" in updates:
action.setdefault("jobcontrol", {})["countdown_capture"] = float(updates["countdown_capture"])
proc = action.setdefault("processing", {})
for key in ("img_frame_enable", "img_frame_file",
"img_background_enable", "img_background_file",
"fill_background_enable", "fill_background_color",
"remove_background", "image_filter", "texts_enable"):
if key in updates:
proc[key] = updates[key]
ui = action.setdefault("trigger", {}).setdefault("ui_trigger", {})
for key in ("show_button", "title", "icon", "use_custom_color", "custom_color"):
if f"ui_{key}" in updates:
ui[key] = updates[f"ui_{key}"]
pb_config["actions"]["image"][index] = action
await pb.write_pb_config(pb_config)
logger.info("Action %d mise à jour: %s", index, list(updates.keys()))
return {"ok": True, "index": index, "action": action}
# ── Déclenchement ─────────────────────────────────────────────────────────────
@router.post("/actions/trigger/{index}")
async def trigger_action(request: Request, index: int):
"""Déclenche une action directement (test)."""
@@ -119,3 +197,101 @@ async def trigger_action(request: Request, index: int):
led.play("error")
btn.relay_on()
return JSONResponse({"error": str(e)}, status_code=500)
# ── Mapping bouton ────────────────────────────────────────────────────────────
@router.put("/actions/mapping")
async def update_button_mapping(request: Request, mapping: dict = Body(...)):
"""Body: { "1": {"label": "", "photobooth_index": 0}, … }"""
config_svc = request.app.state.config_service
for k, v in mapping.items():
if not isinstance(v, dict) or "photobooth_index" not in v:
return JSONResponse({"error": f"Format invalide pour clic {k}"}, status_code=400)
config_svc.save_button_actions(mapping)
request.app.state.config.button_actions = mapping
request.app.state.button_service._btn_actions = mapping
logger.info("Mapping bouton mis à jour: %s", mapping)
return {"ok": True, "mapping": mapping}
# ── Presets ───────────────────────────────────────────────────────────────────
@router.get("/actions/presets")
async def list_presets(request: Request):
pdir = _presets_dir(request)
presets = []
for f in sorted(pdir.glob("*.json")):
try:
data = json.loads(f.read_text(encoding="utf-8"))
presets.append({
"name": f.stem,
"label": data.get("label", f.stem),
"description": data.get("description", ""),
"action_count": len(data.get("actions", [])),
"saved_at": data.get("saved_at", ""),
})
except Exception:
pass
return {"presets": presets}
@router.post("/actions/presets")
async def save_preset(request: Request, body: dict = Body(...)):
"""Sauvegarde les actions actuelles comme preset."""
label = body.get("name", "").strip()
description = body.get("description", "").strip()
if not label:
return JSONResponse({"error": "Nom requis"}, status_code=400)
safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", label).strip("_")
if not safe_name:
return JSONResponse({"error": "Nom invalide"}, status_code=400)
pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config()
actions = pb_config.get("actions", {}).get("image", [])
clean = [{k: v for k, v in copy.deepcopy(a).items() if k != "_index"} for a in actions]
preset = {
"label": label,
"description": description,
"saved_at": datetime.now().isoformat(timespec="seconds"),
"actions": clean,
}
(_presets_dir(request) / f"{safe_name}.json").write_text(
json.dumps(preset, indent=2, ensure_ascii=False), encoding="utf-8"
)
logger.info("Preset sauvegardé: %s (%d actions)", safe_name, len(clean))
return {"ok": True, "name": safe_name, "action_count": len(clean)}
@router.post("/actions/presets/{name}/restore")
async def restore_preset(request: Request, name: str):
"""Restaure un preset (remplace les actions actuelles)."""
path = _presets_dir(request) / f"{name}.json"
if not path.exists():
return JSONResponse({"error": f"Preset introuvable: {name}"}, status_code=404)
try:
preset = json.loads(path.read_text(encoding="utf-8"))
except Exception as e:
return JSONResponse({"error": f"Lecture preset: {e}"}, status_code=500)
actions = preset.get("actions", [])
if not actions:
return JSONResponse({"error": "Preset vide"}, status_code=400)
pb = request.app.state.photobooth_service
pb_config = await pb.read_pb_config()
pb_config.setdefault("actions", {})["image"] = actions
await pb.write_pb_config(pb_config)
logger.info("Preset restauré: %s (%d actions)", name, len(actions))
return {"ok": True, "name": name, "action_count": len(actions)}
@router.delete("/actions/presets/{name}")
async def delete_preset(request: Request, name: str):
path = _presets_dir(request) / f"{name}.json"
if not path.exists():
return JSONResponse({"error": f"Preset introuvable: {name}"}, status_code=404)
path.unlink()
logger.info("Preset supprimé: %s", name)
return {"ok": True, "name": name}
+18 -10
View File
@@ -1,6 +1,7 @@
"""Routes du dashboard admin -- authentification requise."""
import logging
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Request, Form
@@ -95,17 +96,8 @@ async def admin_actions(request: Request):
redirect = _require_auth(request)
if redirect:
return redirect
pb = request.app.state.photobooth_service
cfg = request.app.state.config
pb_config = await pb.read_pb_config()
actions = pb_config.get("actions", {}).get("image", [])
return _templates.TemplateResponse(request, "admin/actions.html", {
"config": cfg,
"pb_actions": actions,
"button_mapping": cfg.button_actions,
})
return _templates.TemplateResponse(request, "admin/actions.html", {"config": cfg})
@router.get("/admin/settings", response_class=HTMLResponse)
@@ -120,6 +112,15 @@ async def admin_settings(request: Request):
})
@router.get("/admin/events", response_class=HTMLResponse)
async def admin_events(request: Request):
redirect = _require_auth(request)
if redirect:
return redirect
cfg = request.app.state.config
return _templates.TemplateResponse(request, "admin/events.html", {"config": cfg})
@router.get("/admin/print", response_class=HTMLResponse)
async def admin_print(request: Request):
redirect = _require_auth(request)
@@ -132,6 +133,13 @@ async def admin_print(request: Request):
queue = await printer_svc.get_queue()
printers = await printer_svc.get_printers_status()
# Formate les dates pour le template Jinja2
for q in queue:
ts = q.get("requested_at")
q["requested_at_str"] = (
datetime.fromtimestamp(ts).strftime("%d/%m %H:%M") if ts else ""
)
return _templates.TemplateResponse(request, "admin/print.html", {
"config": cfg,
"queue": queue,
+69 -29
View File
@@ -1,7 +1,9 @@
"""API galerie admin — impression et suppression de photos."""
import logging
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Request, Query
from fastapi.responses import JSONResponse
@@ -20,20 +22,6 @@ def _get_id(item: dict) -> str:
return str(item.get("id", item.get("filename", item.get("uid", ""))))
def _find_file(media_dir: str, photo_id: str) -> Path | None:
"""Cherche un fichier image correspondant à l'identifiant dans le répertoire media."""
base = Path(media_dir)
if not base.exists():
return None
for ext in (".jpg", ".jpeg", ".png"):
f = base / f"{photo_id}{ext}"
if f.exists():
return f
matches = list(base.rglob(f"{photo_id}{ext}"))
if matches:
return matches[0]
return None
def _require_auth(request: Request):
return request.session.get("authenticated") is True
@@ -46,6 +34,9 @@ async def admin_get_photos(
request: Request,
page: int = Query(default=1, ge=1),
limit: int = Query(default=24, ge=1, le=100),
event_slug: str = Query(default=None),
date_from: float = Query(default=None),
date_to: float = Query(default=None),
):
"""Liste des photos pour la galerie admin, annotées avec leurs demandes d'impression."""
if not _require_auth(request):
@@ -53,37 +44,74 @@ async def admin_get_photos(
pb = request.app.state.photobooth_service
printer_svc = request.app.state.printer_service
cfg = request.app.state.config
event_svc = getattr(request.app.state, "event_service", None)
# Récupère toutes les photos
all_photos = await pb.get_media_collection(limit=500)
photos = [p for p in all_photos if _is_image(p)]
# Enrichit chaque photo avec son timestamp de création
# photobooth-app retourne 'created_at' en UTC (ex: "2026-07-17T14:15:52")
for p in photos:
pid = _get_id(p)
p["photo_id"] = pid
created_str = p.get("created_at", "")
try:
# Interprète created_at comme UTC → timestamp Unix correct
dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
mt = dt.timestamp()
except (ValueError, TypeError):
mt = 0.0
p["mtime"] = mt
# Date pour affichage : convertit UTC → heure locale
p["date_iso"] = datetime.fromtimestamp(mt).strftime("%Y-%m-%d") if mt else ""
p["date_label"] = datetime.fromtimestamp(mt).strftime("%d/%m/%Y %H:%M") if mt else ""
# Filtre par événement
if event_slug and event_svc:
ev = await event_svc.get_event_by_slug(event_slug)
if ev:
t0 = ev["started_at"] or 0.0
t1 = ev["ended_at"] or datetime.now().timestamp()
photos = [p for p in photos if t0 <= p["mtime"] <= t1]
# Filtre par plage de dates
if date_from is not None:
photos = [p for p in photos if p["mtime"] >= date_from]
if date_to is not None:
photos = [p for p in photos if p["mtime"] <= date_to]
total = len(photos)
start = (page - 1) * limit
page_photos = photos[start:start + limit]
# Construit les URLs
for p in page_photos:
pid = _get_id(p)
p["photo_id"] = pid
pid = p["photo_id"]
p["full_url"] = pb.media_url(pid)
p["thumb_url"] = pb.thumbnail_url(pid)
p["download_url"] = f"/api/gallery/download/{pid}"
# Croise avec la file d'impression en attente (une seule requête SQLite)
# Croise avec les stats d'impression (pending, printing, done)
try:
pending_map = await printer_svc.get_pending_by_photo_id()
stats_map = await printer_svc.get_print_stats_by_photo_id()
for p in page_photos:
pid = p.get("photo_id", "")
requests = pending_map.get(pid, [])
p["print_requests"] = requests
p["print_pending"] = len([r for r in requests if r["status"] == "pending"])
p["print_printing"] = len([r for r in requests if r["status"] == "printing"])
info = stats_map.get(pid, {})
p["print_requests"] = info.get("requests", [])
p["print_pending"] = info.get("pending", 0)
p["print_printing"] = info.get("printing", 0)
p["print_done"] = info.get("done", 0)
p["print_done_copies"] = info.get("copies_done", 0)
except Exception as e:
logger.warning("Impossible de croiser avec print_queue: %s", e)
for p in page_photos:
p["print_requests"] = []
p["print_pending"] = 0
p["print_printing"] = 0
p["print_requests"] = []
p["print_pending"] = 0
p["print_printing"] = 0
p["print_done"] = 0
p["print_done_copies"] = 0
return {
"photos": page_photos,
@@ -116,12 +144,24 @@ async def admin_print_photo(
ws = request.app.state.ws_manager
cfg = request.app.state.config
filename = _find_file(cfg.photobooth.media_dir, photo_id)
if not filename:
return JSONResponse({"error": f"Fichier introuvable pour {photo_id}"}, status_code=404)
# Récupère les détails de la photo via l'API photobooth-app
# (le champ 'processed' donne le chemin relatif réel : media/processed_full/YYYYMMDD-xxx.jpg)
item = await pb.get_media_item(photo_id)
if not item:
return JSONResponse({"error": f"Photo introuvable: {photo_id}"}, status_code=404)
file_path = pb.media_file_path(item)
if not file_path or not file_path.exists():
logger.error("Fichier manquant pour %s: %s", photo_id, file_path)
return JSONResponse(
{"error": f"Fichier manquant sur le disque: {file_path}"}, status_code=404
)
filename = file_path
logger.info("Fichier pour impression: %s%s", photo_id, filename)
thumb_url = pb.thumbnail_url(photo_id)
entry = await printer_svc.add_request(str(filename), thumb_url, copies)
entry = await printer_svc.add_request(str(filename), thumb_url, copies, photo_id=photo_id)
should_print_now = (cfg.print.mode == "direct") or immediate
+56 -3
View File
@@ -2,10 +2,14 @@
from __future__ import annotations
import io
import logging
import zipfile
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel
from backend.services.event_service import slugify
@@ -59,9 +63,9 @@ async def update_event(request: Request, payload: EventUpdate):
old_slug = cfg.event.slug
if payload.new_event and old_slug and old_slug != new_slug:
# Archive l'ancien événement
await event_svc.archive_event(old_slug)
if payload.new_event:
# Archive TOUS les événements encore ouverts (ended_at IS NULL)
await event_svc.archive_all_open()
# Crée ou met à jour la ligne dans la DB
await event_svc.ensure_event(new_slug, payload.name, now if payload.new_event else cfg.event.started_at or now)
@@ -81,6 +85,55 @@ async def update_event(request: Request, payload: EventUpdate):
}
@router.get("/event/{slug}/export")
async def export_event_zip(request: Request, slug: str):
"""Exporte toutes les photos d'un événement dans un fichier ZIP.
Les photos sont sélectionnées par timestamp de fichier (mtime) entre
started_at et ended_at de l'événement. Pour l'événement en cours,
ended_at = maintenant.
"""
event_svc = request.app.state.event_service
cfg = request.app.state.config
ev = await event_svc.get_event_by_slug(slug)
if not ev:
return JSONResponse({"error": f"Événement '{slug}' introuvable"}, status_code=404)
started_at = ev["started_at"] or 0.0
ended_at = ev["ended_at"] or datetime.now().timestamp()
media_dir = Path(cfg.photobooth.media_dir)
if not media_dir.exists():
return JSONResponse({"error": "Dossier média introuvable"}, status_code=500)
# Sélectionner les photos dans la plage de l'événement
photos = []
for f in sorted(media_dir.iterdir()):
if f.suffix.lower() not in (".jpg", ".jpeg", ".png"):
continue
mtime = f.stat().st_mtime
if started_at <= mtime <= ended_at:
photos.append(f)
if not photos:
return JSONResponse({"error": "Aucune photo pour cet événement"}, status_code=404)
# Créer le ZIP en mémoire
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for photo in photos:
zf.write(photo, photo.name)
buf.seek(0)
filename = f"{slug}_By_LSDW_{datetime.fromtimestamp(started_at).strftime('%Y-%m-%d')}.zip"
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/event/history")
async def get_event_history(request: Request):
"""Retourne tous les événements passés avec leurs statistiques."""
+64 -8
View File
@@ -2,7 +2,7 @@
import asyncio
import logging
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
import httpx
@@ -34,12 +34,35 @@ async def api_gallery_photos(
request: Request,
page: int = Query(default=1, ge=1),
limit: int = Query(default=24, ge=1, le=100),
event_slug: str = Query(default=None, description="Filtrer par slug d'événement"),
):
pb = request.app.state.photobooth_service
cfg = request.app.state.config
all_photos = await pb.get_media_collection(limit=500)
photos = [p for p in all_photos if _is_image(p)]
# Filtre par événement si demandé
if event_slug:
event_svc = getattr(request.app.state, "event_service", None)
if event_svc:
ev = await event_svc.get_event_by_slug(event_slug)
if ev:
started_at = ev["started_at"] or 0.0
ended_at = ev["ended_at"] or datetime.now().timestamp()
def _photo_ts(p: dict) -> float:
"""Timestamp UTC de la photo depuis le champ created_at de photobooth-app."""
created_str = p.get("created_at", "")
try:
return datetime.fromisoformat(created_str).replace(
tzinfo=timezone.utc
).timestamp()
except (ValueError, TypeError):
return 0.0
photos = [p for p in photos if started_at <= _photo_ts(p) <= ended_at]
total = len(photos)
start = (page - 1) * limit
end = start + limit
@@ -51,29 +74,62 @@ async def api_gallery_photos(
p["thumb_url"] = pb.thumbnail_url(pid)
p["download_url"] = f"/api/gallery/download/{pid}"
# Ajoute les stats d'impression (copies réussies) pour les badges
printer_svc = getattr(request.app.state, "printer_service", None)
if printer_svc:
try:
stats_map = await printer_svc.get_print_stats_by_photo_id()
for p in page_photos:
pid = _get_id(p)
info = stats_map.get(pid, {})
p["print_done_copies"] = info.get("copies_done", 0)
except Exception:
for p in page_photos:
p["print_done_copies"] = 0
else:
for p in page_photos:
p["print_done_copies"] = 0
return {
"photos": page_photos,
"total": total,
"page": page,
"pages": max(1, (total + limit - 1) // limit),
"event_slug": event_slug,
}
@router.get("/api/gallery/download/{photo_id}")
async def download_photo(request: Request, photo_id: str):
"""Proxy la photo full-res avec un nom de fichier lie a l'evenement en cours."""
"""Proxy la photo full-res avec Content-Disposition et nom basé sur l'événement + date photo."""
pb = request.app.state.photobooth_service
cfg = request.app.state.config
event_svc = getattr(request.app.state, "event_service", None)
img_url = pb.media_url(photo_id)
# Toujours fetcher depuis localhost (base_url), pas l'URL publique
local_url = f"{pb._base.rstrip('/')}/media/full/{photo_id}"
# Nom de l'événement (lisible) + date de la photo depuis le disque
event_name = (cfg.event.name or cfg.event.slug or "Photomaton").replace(" ", "_")
slug = cfg.event.slug or "photomaton"
date_str = datetime.now().strftime("%Y-%m-%d")
filename = f"{slug}_By_LSDW_{date_str}.jpg"
# Tenter de récupérer la vraie date depuis le fichier
photo_date = datetime.now().strftime("%Y-%m-%d_%H-%M")
try:
media_dir = Path(cfg.photobooth.media_dir)
candidates = list(media_dir.glob(f"{photo_id}*"))
if candidates:
mtime = candidates[0].stat().st_mtime
photo_date = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d_%H-%M")
except Exception:
pass
safe_name = "".join(c if c.isalnum() or c in "-_." else "_" for c in event_name)
filename = f"{safe_name}_{photo_date}.jpg"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(img_url)
r = await client.get(local_url)
r.raise_for_status()
if event_svc:
@@ -90,8 +146,8 @@ async def download_photo(request: Request, photo_id: str):
},
)
except Exception as e:
logger.warning("Download proxy echoue (%s), fallback redirect: %s", photo_id, e)
return RedirectResponse(url=img_url)
logger.warning("Download proxy échoue (%s): %s", photo_id, e)
return RedirectResponse(url=pb.media_url(photo_id))
def _is_image(item: dict) -> bool:
+85
View File
@@ -1,5 +1,6 @@
"""API de contrôle des LEDs WS2812b."""
import asyncio
import logging
from fastapi import APIRouter, Request, Query
from fastapi.responses import JSONResponse
@@ -103,6 +104,90 @@ async def get_effects(request: Request):
}
@router.get("/leds/ambient-lux")
async def get_ambient_lux(request: Request):
"""Retourne la luminosité ambiante mesurée (dernière valeur connue)."""
led = request.app.state.led_service
lux = led._ambient_lux
return {
"ambient_lux": lux,
"known": lux >= 0,
"auto_brightness": request.app.state.config.leds.auto_brightness,
"description": (
"sombre (flash plein)" if lux < 50 and lux >= 0 else
"luminosité moyenne (flash adapté)" if lux < 180 and lux >= 0 else
"clair (flash réduit)" if lux >= 180 else
"non mesuré"
),
}
@router.post("/leds/measure-ambient")
async def measure_ambient_lux(request: Request):
"""Déclenche une mesure de luminosité ambiante via snapshot liveview.
Utile pour diagnostiquer si le flux liveview est accessible depuis JH-Photomaton.
"""
pb = request.app.state.photobooth_service
led = request.app.state.led_service
snapshot = await pb.get_liveview_snapshot()
if not snapshot:
return JSONResponse({
"ok": False,
"error": "Aucun endpoint liveview accessible sur photobooth-app. "
"Vérifiez que photobooth-app est démarré et que le flux vidéo est actif.",
}, status_code=503)
try:
from PIL import Image
import io
img = Image.open(io.BytesIO(snapshot)).convert("L").resize((80, 60))
pixels = list(img.getdata())
avg = sum(pixels) / len(pixels)
led.set_ambient_lux(avg)
return {
"ok": True,
"ambient_lux": round(avg, 1),
"description": (
"sombre → flash plein (255)" if avg < 50 else
f"moyen → flash {int(255 - (avg - 50) / 130 * 135)}" if avg < 180 else
"clair → flash réduit (80)"
),
}
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@router.get("/leds/flash-timing")
async def get_flash_timing(request: Request):
"""Retourne les paramètres de timing du flash."""
cfg = request.app.state.config.leds
return {
"pre_flash_advance": cfg.pre_flash_advance,
"countdown_duration": cfg.countdown_duration,
}
@router.put("/leds/flash-timing")
async def set_flash_timing(
request: Request,
pre_flash_advance: float = Query(..., ge=0.0, le=3.0, description="Secondes avant fin countdown pour démarrer le flash"),
save: bool = Query(default=True),
):
"""Règle combien de secondes avant la fin du countdown le flash démarre (03s)."""
cfg = request.app.state.config.leds
cfg.pre_flash_advance = pre_flash_advance
if save:
config_svc = request.app.state.config_service
with open(config_svc.path, encoding="utf-8") as f:
import yaml
raw = yaml.safe_load(f) or {}
raw.setdefault("leds", {})["pre_flash_advance"] = pre_flash_advance
with open(config_svc.path, "w", encoding="utf-8") as f:
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
return {"ok": True, "pre_flash_advance": pre_flash_advance}
@router.put("/leds/effect/{effect_name}")
async def update_effect(
request: Request,
+122 -1
View File
@@ -4,7 +4,7 @@ import asyncio
import logging
from pathlib import Path
from fastapi import APIRouter, Request, Query
from fastapi import APIRouter, Request, Query, Body
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
@@ -125,8 +125,129 @@ async def set_print_mode(request: Request, mode: str = Query(...)):
return {"ok": True, "mode": mode}
@router.post("/print/user-print-config")
async def set_user_print_config(request: Request, body: dict = Body(...)):
"""Configure l'impression depuis la galerie utilisateur."""
enabled = bool(body.get("user_print_enabled", False))
quota = max(0, int(body.get("user_print_quota", 0)))
max_copies = max(1, min(10, int(body.get("user_print_max_copies", 1))))
config_svc = request.app.state.config_service
config_svc.save_user_print_config(enabled, quota, max_copies)
logger.info("User print config: enabled=%s quota=%d max_copies=%d", enabled, quota, max_copies)
return {"ok": True, "user_print_enabled": enabled, "user_print_quota": quota, "user_print_max_copies": max_copies}
@router.get("/print/user-print-config")
async def get_user_print_config(request: Request):
"""Retourne la config impression galerie utilisateur."""
cfg = request.app.state.config.print
return {
"user_print_enabled": cfg.user_print_enabled,
"user_print_quota": cfg.user_print_quota,
"user_print_max_copies": cfg.user_print_max_copies,
}
@router.get("/print/user-print-status")
async def get_user_print_status(request: Request):
"""Statut d'impression de la session courante (quota restant, config active)."""
cfg = request.app.state.config.print
count = request.session.get("gallery_print_count", 0)
remaining = (cfg.user_print_quota - count) if cfg.user_print_quota > 0 else -1
return {
"user_print_enabled": cfg.user_print_enabled,
"user_print_quota": cfg.user_print_quota,
"user_print_max_copies": cfg.user_print_max_copies,
"session_print_count": count,
"remaining_quota": remaining, # -1 = illimité
}
@router.post("/print/request-gallery")
async def gallery_print_request(request: Request, body: dict = Body(...)):
"""Demande d'impression depuis la galerie publique.
Vérifie que l'impression est activée et que le quota session n'est pas dépassé.
"""
cfg_print = request.app.state.config.print
# ── Activation ────────────────────────────────────────────────────────────
if not cfg_print.user_print_enabled:
return JSONResponse({"error": "L'impression est désactivée"}, status_code=403)
photo_id = str(body.get("photo_id", "")).strip()
if not photo_id:
return JSONResponse({"error": "photo_id requis"}, status_code=400)
copies = max(1, min(cfg_print.user_print_max_copies, int(body.get("copies", 1))))
# ── Quota session ─────────────────────────────────────────────────────────
if cfg_print.user_print_quota > 0:
count = request.session.get("gallery_print_count", 0)
if count >= cfg_print.user_print_quota:
return JSONResponse({
"error": f"Quota atteint ({cfg_print.user_print_quota} impression(s) maximum par session)",
"quota_exceeded": True,
}, status_code=429)
# ── Résolution du fichier via l'API photobooth-app ───────────────────────
# On passe par get_media_item() comme le fait admin_print_photo — le champ
# "processed" contient le chemin réel sur le disque (ex: media/processed_full/xxx.jpg).
# Un simple glob sur photo_id ne marche pas car l'id interne ≠ nom de fichier.
pb = request.app.state.photobooth_service
item = await pb.get_media_item(photo_id)
if not item:
return JSONResponse({"error": "Photo introuvable"}, status_code=404)
file_path = pb.media_file_path(item)
if not file_path or not file_path.exists():
logger.error("Fichier manquant pour %s: %s", photo_id, file_path)
return JSONResponse({"error": "Fichier photo introuvable sur le disque"}, status_code=404)
filepath = str(file_path)
# ── Enqueue ───────────────────────────────────────────────────────────────
printer_svc = request.app.state.printer_service
ws = request.app.state.ws_manager
led = request.app.state.led_service
thumb_url = pb.thumbnail_url(photo_id)
entry = await printer_svc.add_request(filepath, thumb_url, copies)
await ws.broadcast({"type": "print_request", "entry": entry})
# ── Incrémenter compteur session ──────────────────────────────────────────
new_count = request.session.get("gallery_print_count", 0) + 1
request.session["gallery_print_count"] = new_count
remaining = (cfg_print.user_print_quota - new_count) if cfg_print.user_print_quota > 0 else -1
# ── Stats événement ───────────────────────────────────────────────────────
event_svc = getattr(request.app.state, "event_service", None)
if event_svc:
slug = request.app.state.config.event.slug
asyncio.create_task(event_svc.increment(slug, "print_requests"))
# ── LED si mode direct ────────────────────────────────────────────────────
if request.app.state.config.print.mode == "direct":
led.play("printing")
logger.info("Impression galerie: photo_id=%s copies=%d session_count=%d", photo_id, copies, new_count)
return {
"ok": True,
"id": entry["id"],
"mode": request.app.state.config.print.mode,
"remaining_quota": remaining,
}
# ── Gestion avancée des imprimantes CUPS ──────────────────────────────────────
@router.post("/print/printers/{printer_name}/set-excluded")
async def set_printer_excluded(request: Request, printer_name: str, excluded: bool = Query(...)):
"""Exclut (ou réintègre) une imprimante du load-balancing JH-Photomaton.
N'agit pas sur CUPS — sert uniquement à ignorer une imprimante temporairement hors ligne."""
config_svc = request.app.state.config_service
config_svc.save_printer_excluded(printer_name, excluded)
logger.info("Imprimante %s : excluded=%s", printer_name, excluded)
return {"ok": True, "printer": printer_name, "excluded": excluded}
@router.post("/print/printers/{printer_name}/enable")
async def printer_enable(request: Request, printer_name: str):
"""Active une imprimante CUPS (cupsenable + cupsaccept)."""
+81 -4
View File
@@ -1,5 +1,6 @@
"""API de surveillance des ressources système."""
import asyncio
import logging
from typing import Optional
from fastapi import APIRouter, Request, Body
@@ -9,6 +10,16 @@ from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter()
# Services autorisés pour le restart individuel (jh-photomaton a son propre endpoint)
_ALLOWED_SERVICES = {
"photobooth-app",
"photobooth-kiosk",
"zoraxy",
"hostapd",
"dnsmasq",
"cups",
}
@router.get("/system/stats")
async def system_stats(request: Request):
@@ -68,7 +79,6 @@ async def set_delete_button(request: Request, visible: bool):
Écrit userdata/private.css puis redémarre photobooth-app pour que le CSS
soit pris en compte.
"""
import asyncio, subprocess
pb = request.app.state.photobooth_service
config_svc = request.app.state.config_service
@@ -82,7 +92,6 @@ async def set_delete_button(request: Request, visible: bool):
async def _restart_photobooth():
import asyncio
await asyncio.sleep(0.5)
proc = await asyncio.create_subprocess_exec(
"sudo", "systemctl", "restart", "photobooth-app.service",
@@ -90,7 +99,75 @@ async def _restart_photobooth():
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
logger.info("photobooth-app redémarré après changement CSS")
logger.info("photobooth-app redémarré")
@router.post("/system/restart-photobooth")
async def restart_photobooth(request: Request):
"""Redémarre photobooth-app.service pour recharger la config (actions, etc.)."""
asyncio.create_task(_restart_photobooth())
return {"ok": True, "message": "Redémarrage de photobooth-app en cours…"}
# ── Gestion services & système ────────────────────────────────────────────────
@router.get("/system/ping")
async def ping():
"""Endpoint de health-check — utilisé par le polling de reconnexion côté client."""
return {"ok": True}
@router.post("/system/service/{name}/restart")
async def restart_service(name: str):
"""Redémarre un service systemd autorisé."""
if name not in _ALLOWED_SERVICES:
return JSONResponse({"ok": False, "error": f"Service '{name}' non autorisé"}, status_code=403)
asyncio.create_task(_run_systemctl_restart(name))
return {"ok": True, "message": f"Redémarrage de {name} en cours…"}
async def _run_systemctl_restart(name: str):
proc = await asyncio.create_subprocess_exec(
"sudo", "systemctl", "restart", f"{name}.service",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
logger.info("%s redémarré (exit %d)", name, proc.returncode)
@router.post("/system/restart-self")
async def restart_self():
"""Redémarre le service jh-photomaton lui-même (avec délai pour que la réponse parte d'abord)."""
asyncio.create_task(_restart_self_delayed())
return {"ok": True, "message": "Redémarrage de JH-Photomaton en cours…"}
async def _restart_self_delayed():
await asyncio.sleep(1.5)
proc = await asyncio.create_subprocess_exec(
"sudo", "systemctl", "restart", "jh-photomaton.service",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
@router.post("/system/reboot")
async def reboot_pi():
"""Redémarre le Raspberry Pi (avec délai pour que la réponse parte d'abord)."""
asyncio.create_task(_reboot_delayed())
return {"ok": True, "message": "Redémarrage du Pi en cours…"}
async def _reboot_delayed():
await asyncio.sleep(2)
proc = await asyncio.create_subprocess_exec(
"sudo", "reboot",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
@router.post("/system/screen/refresh")
@@ -101,7 +178,7 @@ async def screen_refresh(request: Request):
# Cherche le display Wayland du user pi (UID 1000)
# Essaie wayland-0 puis wayland-1
xdg_runtime = "/run/user/1000"
cmd_wtype = f"WAYLAND_DISPLAY=wayland-1 XDG_RUNTIME_DIR={xdg_runtime} wtype -k F5"
cmd_wtype = f"WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR={xdg_runtime} wtype -k F5"
cmd_x11 = f"DISPLAY=:0 XAUTHORITY=/home/pi/.Xauthority xdotool key F5"
for cmd in [cmd_wtype, cmd_x11]:
+28 -5
View File
@@ -8,8 +8,6 @@ from fastapi import APIRouter, Request, Query
logger = logging.getLogger(__name__)
router = APIRouter()
DEFAULT_COUNTDOWN = 5.0
@router.get("/webhook/photobooth")
async def photobooth_webhook(
@@ -20,19 +18,36 @@ async def photobooth_webhook(
led = request.app.state.led_service
btn = request.app.state.button_service
ws = request.app.state.ws_manager
cfg = request.app.state.config.leds
logger.info("Webhook photobooth: event=%s type=%s", event_key, mediaitem_type)
await ws.broadcast({"type": "photobooth_event", "event": event_key, "media_type": mediaitem_type})
match event_key:
case "counting":
led.play("countdown", countdown_duration=DEFAULT_COUNTDOWN)
if request.app.state.config.leds.auto_brightness:
led.play("countdown", countdown_duration=cfg.countdown_duration)
# Annuler le pre-flash précédent si toujours en attente
prev = getattr(request.app.state, "pre_flash_task", None)
if prev and not prev.done():
prev.cancel()
# Déclencher le flash X secondes avant la fin du countdown
delay = max(0.0, cfg.countdown_duration - cfg.pre_flash_advance)
task = asyncio.create_task(_delayed_flash(led, delay))
request.app.state.pre_flash_task = task
logger.info(
"Pre-flash schedulé dans %.2fs (countdown=%.1fs advance=%.1fs)",
delay, cfg.countdown_duration, cfg.pre_flash_advance,
)
if cfg.auto_brightness:
pb = request.app.state.photobooth_service
asyncio.create_task(_analyze_ambient_lux(led, pb))
case "capture":
led.play("capture")
# Flash déjà lancé par le pre-flash depuis counting ; ne pas redémarrer
logger.debug("Event capture recu (pre-flash déjà actif)")
case "captured":
led.play("captured")
@@ -57,6 +72,14 @@ async def photobooth_webhook(
return {"ok": True, "event": event_key}
async def _delayed_flash(led, delay: float):
"""Attend `delay` secondes puis déclenche l'effet flash."""
if delay > 0:
await asyncio.sleep(delay)
led.play("capture")
logger.info("Pre-flash déclenché (délai=%.2fs)", delay)
async def _delayed_relay_on(btn, delay: float):
await asyncio.sleep(delay)
if btn:
+47 -6
View File
@@ -64,6 +64,8 @@ class LEDConfig:
dma: int = 10
strip_type: str = "WS2812"
effects: dict = field(default_factory=dict)
countdown_duration: float = 3.0 # Durée du countdown photobooth-app (secondes)
pre_flash_advance: float = 0.5 # Démarrer le flash X sec avant la fin du countdown
def get_effect(self, name: str) -> LEDEffectConfig:
raw = self.effects.get(name, {})
@@ -83,6 +85,10 @@ class PrintConfig:
script_path: str = "/home/pi/photobooth-data/script/script_print.sh"
default_copies: int = 1
printers: list = field(default_factory=list)
# Impression depuis la galerie utilisateur
user_print_enabled: bool = False
user_print_quota: int = 0 # 0 = illimité
user_print_max_copies: int = 1 # copies max par demande
@dataclass
@@ -128,13 +134,13 @@ class ConfigService:
cfg = Config()
if "app" in raw:
cfg.app = AppConfig(**{k: v for k, v in raw["app"].items() if hasattr(AppConfig, k)})
cfg.app = AppConfig(**{k: v for k, v in raw["app"].items() if k in AppConfig.__dataclass_fields__})
if "photobooth" in raw:
cfg.photobooth = PhotoboothConfig(**{k: v for k, v in raw["photobooth"].items() if hasattr(PhotoboothConfig, k)})
cfg.photobooth = PhotoboothConfig(**{k: v for k, v in raw["photobooth"].items() if k in PhotoboothConfig.__dataclass_fields__})
if "button" in raw:
cfg.button = ButtonConfig(**{k: v for k, v in raw["button"].items() if hasattr(ButtonConfig, k)})
cfg.button = ButtonConfig(**{k: v for k, v in raw["button"].items() if k in ButtonConfig.__dataclass_fields__})
if "leds" in raw:
led_raw = raw["leds"]
@@ -147,16 +153,18 @@ class ConfigService:
dma=led_raw.get("dma", 10),
strip_type=led_raw.get("strip_type", "WS2812"),
effects=led_raw.get("effects", {}),
countdown_duration=led_raw.get("countdown_duration", 3.0),
pre_flash_advance=led_raw.get("pre_flash_advance", 0.5),
)
if "print" in raw:
cfg.print = PrintConfig(**{k: v for k, v in raw["print"].items() if hasattr(PrintConfig, k)})
cfg.print = PrintConfig(**{k: v for k, v in raw["print"].items() if k in PrintConfig.__dataclass_fields__})
if "gallery" in raw:
cfg.gallery = GalleryConfig(**{k: v for k, v in raw["gallery"].items() if hasattr(GalleryConfig, k)})
cfg.gallery = GalleryConfig(**{k: v for k, v in raw["gallery"].items() if k in GalleryConfig.__dataclass_fields__})
if "event" in raw:
cfg.event = EventConfig(**{k: v for k, v in raw["event"].items() if hasattr(EventConfig, k)})
cfg.event = EventConfig(**{k: v for k, v in raw["event"].items() if k in EventConfig.__dataclass_fields__})
cfg.button_actions = raw.get("button_actions", {
1: {"label": "Photo normale", "photobooth_index": 0},
@@ -209,6 +217,39 @@ class ConfigService:
self._config.event.slug = slug
self._config.event.started_at = started_at
def save_user_print_config(self, enabled: bool, quota: int, max_copies: int):
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
raw.setdefault("print", {}).update({
"user_print_enabled": enabled,
"user_print_quota": quota,
"user_print_max_copies": max_copies,
})
with open(self.path, "w", encoding="utf-8") as f:
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
if self._config:
self._config.print.user_print_enabled = enabled
self._config.print.user_print_quota = quota
self._config.print.user_print_max_copies = max_copies
def save_printer_excluded(self, printer_name: str, excluded: bool):
"""Marque (ou démarque) une imprimante comme exclue du load-balancing."""
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
printers = raw.get("print", {}).get("printers", [])
for p in printers:
if isinstance(p, dict) and p.get("name") == printer_name:
p["excluded"] = excluded
break
raw.setdefault("print", {})["printers"] = printers
with open(self.path, "w", encoding="utf-8") as f:
yaml.dump(raw, f, allow_unicode=True, default_flow_style=False)
if self._config:
for p in self._config.print.printers:
if isinstance(p, dict) and p.get("name") == printer_name:
p["excluded"] = excluded
break
def save_print_mode(self, mode: str):
with open(self.path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
+19
View File
@@ -80,6 +80,14 @@ class EventService:
)
await self._db.commit()
async def archive_all_open(self):
"""Marque TOUS les événements ouverts comme terminés."""
await self._db.execute(
"UPDATE events SET ended_at = ? WHERE ended_at IS NULL",
(datetime.now().timestamp(),),
)
await self._db.commit()
async def increment(self, slug: str, counter: str):
"""Incrémente un compteur de l'événement identifié par son slug.
@@ -97,6 +105,17 @@ class EventService:
)
await self._db.commit()
async def get_event_by_slug(self, slug: str) -> dict | None:
"""Retourne un événement complet par son slug."""
async with self._db.execute(
"""SELECT id, name, slug, started_at, ended_at,
photos_taken, print_requests, prints_done, downloads
FROM events WHERE slug = ?""",
(slug,),
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def get_stats(self, slug: str) -> dict:
"""Retourne les stats pour un slug donné."""
async with self._db.execute(
+54 -12
View File
@@ -190,25 +190,60 @@ class LEDService:
step += 1
def _effect_countdown(self, duration: float = 5.0):
cfg = self._cfg.get_effect("countdown")
c = cfg.color
n = self._cfg.count
blank = WS_Color(0, 0, 0)
color = WS_Color(c[0], c[1], c[2])
self._fill(blank)
# Mode sombre : rampe progressive avec couleur flash pour stabiliser AWB caméra
lux = self._ambient_lux
dark_mode = self._cfg.auto_brightness and lux >= 0 and lux < 50
if dark_mode:
flash_cfg = self._cfg.get_effect("capture")
fc = flash_cfg.color
color = WS_Color(fc[0], fc[1], fc[2])
# Brightness cible du flash
target_br = 255 if lux < 50 else (int(255 - (lux - 50) / 130 * 135) if lux < 180 else 80)
# Rampe : atteindre target_br quand il reste pre_flash_advance secondes
ramp_duration = max(0.1, duration - self._cfg.pre_flash_advance)
orig_br = self._cfg.brightness
logger.info("Countdown mode sombre: rampe 0→%d sur %.1fs puis stable", target_br, ramp_duration)
if HAS_WS281X and self._strip:
self._strip.setBrightness(0)
self._fill(color)
else:
cfg = self._cfg.get_effect("countdown")
c = cfg.color
color = WS_Color(c[0], c[1], c[2])
self._fill(blank)
t_start = time.time()
while not self._cmd_event.is_set() and not self._stop_event.is_set():
elapsed = time.time() - t_start
ratio = min(elapsed / duration, 1.0)
leds_on = int(ratio * n)
for i in range(n):
self._strip.setPixelColor(i, color if i < leds_on else blank)
self._strip.show()
if dark_mode:
# Rampe de 0 → target_br jusqu'à ramp_duration, puis stable à target_br
if elapsed < ramp_duration:
br = int(target_br * elapsed / ramp_duration)
else:
br = target_br
if HAS_WS281X and self._strip:
self._strip.setBrightness(br)
self._strip.show()
else:
leds_on = int(ratio * n)
for i in range(n):
self._strip.setPixelColor(i, color if i < leds_on else blank)
self._strip.show()
if ratio >= 1.0:
break
time.sleep(0.04)
# En mode sombre, restaurer brightness normale (capture le fera aussi, mais par sécurité)
if dark_mode and HAS_WS281X and self._strip:
self._strip.setBrightness(orig_br)
def _effect_capture(self):
"""Flash photo.
@@ -241,15 +276,22 @@ class LEDService:
if HAS_WS281X and self._strip:
self._strip.setBrightness(flash_brightness)
# Le flash démarre pre_flash_advance secondes AVANT la photo.
# La durée ON doit couvrir ce délai + la durée d'exposition.
# Sinon les LEDs s'éteignent avant que la photo soit prise.
effective_on_time = self._cfg.pre_flash_advance + cfg.flash_duration
try:
for _ in range(cfg.flashes):
for i in range(cfg.flashes):
if self._cmd_event.is_set():
break
self._fill(flash_color)
time.sleep(cfg.flash_duration)
# Premier flash : durée étendue pour couvrir le délai + expo
# Flashs suivants : durée normale (décoratif post-capture)
on_time = effective_on_time if i == 0 else cfg.flash_duration
time.sleep(on_time)
self._fill(off)
# Pause inter-flash plus courte que le flash lui-meme
if _ < cfg.flashes - 1:
if i < cfg.flashes - 1:
time.sleep(cfg.flash_duration * 0.4)
finally:
# Toujours restaurer la luminosite normale meme en cas d'erreur
+47 -7
View File
@@ -53,6 +53,29 @@ class PhotoboothService:
items = await self.get_media_collection(limit=1)
return items[0] if items else None
async def get_media_item(self, media_id: str) -> dict | None:
"""Retourne les détails complets d'un item (id, created_at, processed, ...)."""
try:
r = await self._client.get(f"/api/mediacollection/{media_id}")
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
except Exception as e:
logger.error("Erreur récupération item %s: %s", media_id, e)
return None
def media_file_path(self, item: dict) -> Path | None:
"""Retourne le chemin absolu du fichier traité depuis le champ 'processed' d'un item.
photobooth-app retourne 'processed': 'media/processed_full/YYYYMMDD-HHMMSS-xxx.jpg'
On préfixe avec data_dir pour obtenir le chemin complet.
"""
processed = item.get("processed")
if not processed:
return None
return Path(self._cfg.data_dir) / processed
async def delete_media(self, media_id: str) -> bool:
"""Supprime une photo via l'API photobooth-app."""
try:
@@ -168,26 +191,41 @@ class PhotoboothService:
"""Tente de recuperer une frame JPEG du liveview de photobooth-app.
Essaie dans l'ordre :
1. /api/stream/snapshot — endpoint snapshot direct (si disponible)
2. /stream.mjpg — flux MJPEG classique, extrait la premiere frame
3. /api/stream — flux MJPEG alternatif
1. Endpoints snapshot direct (retour JPEG immédiat)
2. Flux MJPEG — extrait la premiere frame
Retourne des bytes JPEG ou None si rien n'est disponible.
"""
# 1. Snapshot direct
for path in ("/api/stream/snapshot",):
# 1. Endpoints snapshot direct (differentes versions de photobooth-app)
snapshot_paths = (
"/api/stream/snapshot",
"/api/video/preview/snapshot",
"/api/livestream/snapshot",
"/api/cam/stream/snapshot",
)
for path in snapshot_paths:
try:
async with httpx.AsyncClient(timeout=2.0) as client:
r = await client.get(f"{self._base}{path}")
if r.status_code == 200:
ct = r.headers.get("content-type", "")
if "jpeg" in ct or "image" in ct:
logger.debug("Snapshot OK via %s", path)
return r.content
except Exception as e:
logger.debug("Snapshot %s: %s", path, e)
# 2. Premiere frame d'un flux MJPEG
for path in ("/stream.mjpg", "/api/stream"):
# 2. Premiere frame d'un flux MJPEG (differentes versions)
mjpeg_paths = (
"/api/aquisition/stream.mjpg", # photobooth-app v4+ (typo intentionnelle dans leur code)
"/stream.mjpg",
"/api/stream",
"/api/video/stream",
"/api/video/preview",
"/api/livestream",
"/livestream",
)
for path in mjpeg_paths:
try:
async with httpx.AsyncClient(timeout=3.0) as client:
async with client.stream("GET", f"{self._base}{path}") as resp:
@@ -200,12 +238,14 @@ class PhotoboothService:
if start >= 0:
end = buf.find(b"\xff\xd9", start)
if end >= 0:
logger.debug("MJPEG frame OK via %s", path)
return buf[start:end + 2]
if len(buf) > 500_000:
break
except Exception as e:
logger.debug("MJPEG %s: %s", path, e)
logger.debug("Aucun endpoint liveview disponible sur %s", self._base)
return None
async def close(self):
+326 -41
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import logging
import re
import subprocess
import time
import uuid
@@ -21,6 +22,7 @@ PrintStatus = Literal["pending", "printing", "done", "cancelled", "error"]
CREATE_SQL = """
CREATE TABLE IF NOT EXISTS print_queue (
id TEXT PRIMARY KEY,
photo_id TEXT,
filename TEXT NOT NULL,
thumb_url TEXT,
copies INTEGER DEFAULT 1,
@@ -39,6 +41,11 @@ class PrinterService:
self._db_path: Path | None = None
self._db: aiosqlite.Connection | None = None
self._lock = asyncio.Lock()
self._led = None # injecté après démarrage via set_led_service()
def set_led_service(self, led_svc) -> None:
"""Injecte le service LED pour que execute_print puisse mettre à jour les LEDs."""
self._led = led_svc
async def init_db(self, db_path: Path):
db_path.parent.mkdir(parents=True, exist_ok=True)
@@ -46,6 +53,12 @@ class PrinterService:
self._db = await aiosqlite.connect(str(db_path))
self._db.row_factory = aiosqlite.Row
await self._db.execute(CREATE_SQL)
# Migration : ajoute photo_id si la colonne n'existe pas encore
try:
await self._db.execute("ALTER TABLE print_queue ADD COLUMN photo_id TEXT")
logger.info("Migration : colonne photo_id ajoutée à print_queue")
except Exception:
pass # Colonne déjà présente
await self._db.commit()
logger.info("Base print_queue initialisée: %s", db_path)
@@ -55,20 +68,23 @@ class PrinterService:
# ── File d'attente ────────────────────────────────────────────────────────
async def add_request(self, filename: str, thumb_url: str = "", copies: int = 1) -> dict:
async def add_request(
self, filename: str, thumb_url: str = "", copies: int = 1, photo_id: str = ""
) -> dict:
"""Ajoute une demande d'impression dans la file. Retourne l'entrée créée."""
entry_id = str(uuid.uuid4())
now = time.time()
await self._db.execute(
"INSERT INTO print_queue (id, filename, thumb_url, copies, status, requested_at) "
"VALUES (?, ?, ?, ?, 'pending', ?)",
(entry_id, filename, thumb_url, copies, now),
"INSERT INTO print_queue (id, photo_id, filename, thumb_url, copies, status, requested_at) "
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
(entry_id, photo_id, filename, thumb_url, copies, now),
)
await self._db.commit()
entry = {
"id": entry_id,
"photo_id": photo_id,
"filename": filename,
"thumb_url": thumb_url,
"copies": copies,
@@ -101,29 +117,53 @@ class PrinterService:
return await self.get_queue("pending")
async def get_pending_by_photo_id(self) -> dict[str, list]:
"""Retourne un dict {photo_id_stem: [entries]} pour toutes les demandes actives.
"""Retourne {photo_id: [entries actives]}. Conservé pour compatibilité."""
stats = await self.get_print_stats_by_photo_id()
return {pid: info["requests"] for pid, info in stats.items() if info["requests"]}
Permet à la galerie admin de savoir quelles photos ont une demande en attente
sans modifier le schéma SQLite — on match par stem du filename.
async def get_print_stats_by_photo_id(self) -> dict[str, dict]:
"""Stats complètes d'impression par photo_id.
Retourne {photo_id: {requests, pending, printing, done, copies_done}}.
'requests' contient uniquement les entrées pending/printing (pour la lightbox).
'done' et 'copies_done' comptent les impressions réussies.
"""
rows = await self.get_queue("pending")
# Aussi inclure celles "printing" (en cours d'impression)
rows += await self.get_queue("printing")
async with self._db.execute(
"SELECT id, photo_id, filename, status, copies, requested_at, thumb_url "
"FROM print_queue WHERE status IN ('pending','printing','done') "
"ORDER BY requested_at"
) as cur:
rows = await cur.fetchall()
result: dict[str, list] = {}
result: dict[str, dict] = {}
for r in rows:
from pathlib import Path
stem = Path(r["filename"]).stem
result.setdefault(stem, []).append(r)
key = r["photo_id"] or Path(r["filename"]).stem
if key not in result:
result[key] = {
"requests": [],
"pending": 0,
"printing": 0,
"done": 0,
"copies_done": 0,
}
if r["status"] in ("pending", "printing"):
result[key]["requests"].append(dict(r))
if r["status"] == "pending":
result[key]["pending"] += 1
elif r["status"] == "printing":
result[key]["printing"] += 1
elif r["status"] == "done":
result[key]["done"] += 1
result[key]["copies_done"] += r["copies"] or 1
return result
async def cancel_by_photo_id(self, photo_stem: str) -> int:
"""Annule toutes les demandes pending pour un photo_id donné. Retourne le nb annulé."""
async def cancel_by_photo_id(self, photo_id: str) -> int:
"""Annule toutes les demandes pending pour un photo_id (UUID) donné."""
pending = await self.get_queue("pending")
from pathlib import Path
cancelled = 0
for r in pending:
if Path(r["filename"]).stem == photo_stem:
key = r.get("photo_id") or Path(r["filename"]).stem
if key == photo_id:
ok = await self.cancel(r["id"])
if ok:
cancelled += 1
@@ -189,6 +229,12 @@ class PrinterService:
await self._db.commit()
logger.info("Impression %s: %s", entry_id, "OK" if result["success"] else result.get("error"))
# Mise à jour LED après impression (utile surtout en mode direct / background task)
if self._led:
effect = "finished" if result["success"] else "error"
self._led.play(effect)
return result
def _run_print_script(self, script: str, filename: str, copies: int) -> dict:
@@ -198,23 +244,113 @@ class PrinterService:
if not Path(filename).exists():
return {"success": False, "error": f"Fichier introuvable: {filename}"}
cmd = ["/bin/bash", script, filename, "image", "default", str(copies)]
# Imprimantes exclues du load-balancing (flag excluded dans config)
excluded_list = ",".join(
self._printer_name(p)
for p in self._cfg.printers
if isinstance(p, dict) and p.get("excluded", False)
)
cmd = ["/bin/bash", script, filename, "image", "default", str(copies), excluded_list]
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
cmd, capture_output=True, text=True, timeout=120
)
stdout = proc.stdout.strip()
if proc.returncode == 0 and "PRINTED:" in stdout:
parts = stdout.split(":")
printer = parts[1] if len(parts) > 1 else ""
return {"success": True, "printer": printer, "output": stdout}
else:
return {"success": False, "error": proc.stderr.strip() or stdout, "printer": ""}
# Cherche PRINTED: ou PRINT_ERROR: dans la dernière ligne significative
for line in reversed(stdout.splitlines()):
line = line.strip()
if line.startswith("PRINTED:"):
parts = line.split(":")
printer = parts[1] if len(parts) > 1 else ""
return {"success": True, "printer": printer, "output": stdout}
if line.startswith("PRINT_ERROR:"):
parts = line.split(":", 2)
printer = parts[1] if len(parts) > 1 else ""
reason = parts[2] if len(parts) > 2 else ""
err_msg = f"Erreur imprimante {printer}: {reason}" if reason else f"Erreur imprimante {printer}"
return {"success": False, "error": err_msg, "printer": printer}
# Aucun marqueur reconnu
return {"success": False, "error": proc.stderr.strip() or stdout or "Script sans sortie reconnue", "printer": ""}
except subprocess.TimeoutExpired:
return {"success": False, "error": "Timeout impression (60s)"}
return {"success": False, "error": "Timeout impression (120s)"}
except Exception as e:
return {"success": False, "error": str(e)}
# ── IPP direct ───────────────────────────────────────────────────────────
def _get_printer_ip(self, printer_name: str) -> str:
"""Extrait l'IP de l'imprimante depuis l'URI CUPS (lpstat -v)."""
try:
r = subprocess.run(
["lpstat", "-v", printer_name],
capture_output=True, text=True, timeout=5
)
m = re.search(r"(\d+\.\d+\.\d+\.\d+)", r.stdout)
return m.group(1) if m else ""
except Exception:
return ""
def _query_ipp_direct(self, printer_ip: str) -> dict:
"""Interroge directement le serveur IPP natif de l'imprimante (sans auth).
Retourne un dict avec keys: state, state_reasons, accepting, markers.
"""
ipp_test = "/usr/share/cups/ipptool/get-printer-attributes.test"
empty: dict = {"state": "", "state_reasons": [], "accepting": None, "markers": []}
if not printer_ip:
return empty
try:
r = subprocess.run(
["ipptool", "-tv", f"ipp://{printer_ip}:631/ipp/print", ipp_test],
capture_output=True, text=True, timeout=8
)
out = r.stdout
except Exception as e:
logger.debug("ipptool direct échec %s: %s", printer_ip, e)
return empty
result: dict = {"state": "", "state_reasons": [], "accepting": None, "markers": []}
# printer-state
m = re.search(r"printer-state \(enum\)\s*=\s*(\S+)", out)
if m:
result["state"] = m.group(1).lower() # idle / processing / stopped
# printer-state-reasons
m = re.search(r"printer-state-reasons \([^)]+\)\s*=\s*(.+)", out)
if m:
reasons = [r.strip() for r in m.group(1).split(",")]
result["state_reasons"] = [r for r in reasons if r and r != "none"]
# printer-is-accepting-jobs
m = re.search(r"printer-is-accepting-jobs \(boolean\)\s*=\s*(\S+)", out)
if m:
result["accepting"] = m.group(1).lower() == "true"
# marker-levels (encre/ruban)
names_m = re.search(r"marker-names \([^)]+\)\s*=\s*(.+)", out)
levels_m = re.search(r"marker-levels \([^)]+\)\s*=\s*(.+)", out)
colors_m = re.search(r"marker-colors \([^)]+\)\s*=\s*(.+)", out)
types_m = re.search(r"marker-types \([^)]+\)\s*=\s*(.+)", out)
if names_m and levels_m:
names = [n.strip().strip('"') for n in names_m.group(1).split(",")]
levels = [l.strip() for l in levels_m.group(1).split(",")]
colors = [c.strip().strip('"') for c in colors_m.group(1).split(",")] if colors_m else []
types = [t.strip().strip('"') for t in types_m.group(1).split(",")] if types_m else []
for i, name in enumerate(names):
try:
lvl = int(levels[i]) if i < len(levels) else -1
except ValueError:
lvl = -1
result["markers"].append({
"name": name,
"level": lvl,
"color": colors[i] if i < len(colors) else "",
"type": types[i] if i < len(types) else "",
})
return result
# ── Helpers ───────────────────────────────────────────────────────────────
def _printer_name(self, p) -> str:
@@ -231,27 +367,52 @@ class PrinterService:
statuses = []
for p in self._cfg.printers:
name = self._printer_name(p)
excluded = isinstance(p, dict) and bool(p.get("excluded", False))
status = await asyncio.to_thread(self._get_printer_status, name)
stats = await self._get_printer_db_stats(name)
statuses.append({
"name": name,
"label": self._printer_label(p),
"excluded": excluded,
**status,
**stats,
})
return statuses
def _get_printer_status(self, printer_name: str) -> dict:
"""Statut CUPS complet pour une imprimante (état, jobs, accepting)."""
async def _get_printer_db_stats(self, printer_name: str) -> dict:
"""Retourne les stats de la file SQLite pour une imprimante donnée."""
try:
# État de l'imprimante
cursor = await self._db.execute(
"SELECT status, COUNT(*) as cnt FROM print_queue WHERE printer = ? GROUP BY status",
(printer_name,)
)
rows = await cursor.fetchall()
counts = {r["status"]: r["cnt"] for r in rows}
# Aussi compter les jobs sans printer assigné (mode direct)
cursor2 = await self._db.execute(
"SELECT COUNT(*) as total FROM print_queue WHERE status='done'"
)
r2 = await cursor2.fetchone()
return {
"stats_done": counts.get("done", 0),
"stats_error": counts.get("error", 0),
"stats_cancelled": counts.get("cancelled", 0),
}
except Exception:
return {"stats_done": 0, "stats_error": 0, "stats_cancelled": 0}
def _get_printer_status(self, printer_name: str) -> dict:
"""Statut complet : CUPS pour les jobs, IPP direct pour état/erreurs/encre."""
try:
# ── Etat de base via lpstat ──
r_state = subprocess.run(
["lpstat", "-p", printer_name],
capture_output=True, text=True, timeout=5
)
out = r_state.stdout.lower()
if r_state.returncode != 0 or "not found" in (r_state.stderr or "").lower():
return {"state": "offline", "accepting": False, "jobs": [], "jobs_count": 0}
out = r_state.stdout.lower()
if "idle" in out:
state = "idle"
elif "printing" in out or "processing" in out:
@@ -261,25 +422,149 @@ class PrinterService:
else:
state = "unknown"
# Est-ce que l'imprimante accepte les nouveaux jobs ?
# Est-ce que l'imprimante accepte les nouveaux jobs (CUPS) ?
r_accept = subprocess.run(
["lpstat", "-a", printer_name],
capture_output=True, text=True, timeout=5
)
accepting = "accepting" in r_accept.stdout.lower()
# Liste des jobs CUPS en cours
# Jobs CUPS en cours
jobs = self._get_cups_jobs(printer_name)
# URI du périphérique
uri = ""
printer_ip = ""
try:
r_uri = subprocess.run(
["lpstat", "-v", printer_name],
capture_output=True, text=True, timeout=5
)
for line in r_uri.stdout.splitlines():
if "device for" in line.lower():
parts = line.split(":", 2)
uri = (parts[1].strip() + ":" + parts[2].strip()) if len(parts) > 2 else ""
break
m = re.search(r"(\d+\.\d+\.\d+\.\d+)", r_uri.stdout)
printer_ip = m.group(1) if m else ""
except Exception:
pass
# Modèle / location via lpstat -l -p
model = ""
location = ""
reasons: list[str] = []
try:
r_info = subprocess.run(
["lpstat", "-l", "-p", printer_name],
capture_output=True, text=True, timeout=5
)
for line in r_info.stdout.splitlines():
l = line.strip()
if l.startswith("Description:"):
model = l.split(":", 1)[1].strip()
elif l.startswith("Location:"):
location = l.split(":", 1)[1].strip()
elif l.lower().startswith("reason:"):
raw = l.split(":", 1)[1].strip()
if raw and raw.lower() not in ("none", ""):
reasons.append(self._REASON_LABELS.get(raw, raw))
except Exception:
pass
# ── Sondage IPP direct sur l'imprimante (source de vérité) ──
markers: list[dict] = []
if printer_ip:
ipp = self._query_ipp_direct(printer_ip)
if ipp["state"]:
# Mappe les états IPP → notre convention
ipp_state_map = {
"idle": "idle",
"processing": "printing",
"stopped": "disabled",
}
state = ipp_state_map.get(ipp["state"], ipp["state"])
if ipp["accepting"] is not None:
accepting = ipp["accepting"]
if ipp["state_reasons"]:
reasons = [
self._REASON_LABELS.get(r, r)
for r in ipp["state_reasons"]
]
if ipp["markers"]:
markers = ipp["markers"]
else:
markers = self._get_marker_levels(printer_name)
return {
"state": state,
"accepting": accepting,
"jobs": jobs,
"state": state,
"accepting": accepting,
"jobs": jobs,
"jobs_count": len(jobs),
"uri": uri,
"model": model,
"location": location,
"reasons": reasons,
"markers": markers,
}
except Exception as e:
return {"state": "error", "accepting": False, "jobs": [], "jobs_count": 0, "error": str(e)}
# Traduction des Reason CUPS en français
_REASON_LABELS: dict[str, str] = {
"input-tray-missing": "⚠️ Bac papier absent",
"media-empty": "❌ Plus de papier",
"media-low": "⚠️ Papier presque épuisé",
"media-needed": "⚠️ Papier requis",
"marker-supply-empty": "❌ Cartouche/ruban vide",
"marker-supply-low": "⚠️ Cartouche/ruban faible",
"marker-supply-low-warning": "⚠️ Ruban faible",
"cover-open": "❌ Capot ouvert",
"door-open": "❌ Porte ouverte",
"offline-report": "❌ Imprimante hors ligne",
"connecting-to-device": "🔄 Connexion en cours…",
"toner-empty": "❌ Toner vide",
"toner-low": "⚠️ Toner faible",
"output-tray-missing": "⚠️ Bac de sortie absent",
"output-area-full": "⚠️ Bac de sortie plein",
"paused": "⏸ Imprimante en pause",
}
def _get_marker_levels(self, printer_name: str) -> list[dict]:
"""Récupère les niveaux d'encre/ruban via lpstat ou ipptool."""
markers = []
try:
# Essai via ipptool si disponible
r = subprocess.run(
["ipptool", "-tv",
f"ipp://localhost:631/printers/{printer_name}",
"/usr/share/cups/ipptool/get-printer-attributes.test"],
capture_output=True, text=True, timeout=8
)
lines = r.stdout.splitlines()
names, levels, colors, types_ = [], [], [], []
for line in lines:
l = line.strip()
if "marker-names" in l:
names = [x.strip().strip('"') for x in l.split("=", 1)[-1].split(",") if x.strip()]
elif "marker-levels" in l:
levels = [x.strip() for x in l.split("=", 1)[-1].split(",") if x.strip()]
elif "marker-colors" in l:
colors = [x.strip().strip('"') for x in l.split("=", 1)[-1].split(",") if x.strip()]
elif "marker-types" in l:
types_ = [x.strip().strip('"') for x in l.split("=", 1)[-1].split(",") if x.strip()]
for i, name in enumerate(names):
level = int(levels[i]) if i < len(levels) else -1
markers.append({
"name": name,
"level": level,
"color": colors[i] if i < len(colors) else "",
"type": types_[i] if i < len(types_) else "",
})
except Exception:
pass
return markers
def _get_cups_jobs(self, printer_name: str) -> list[dict]:
"""Retourne la liste des jobs CUPS en cours pour une imprimante."""
try:
@@ -342,8 +627,8 @@ class PrinterService:
async def enable_printer(self, printer_name: str) -> bool:
"""Active l'imprimante CUPS (cupsenable) et accepte les nouveaux jobs."""
try:
r1 = subprocess.run(["cupsenable", printer_name], capture_output=True, timeout=10)
r2 = subprocess.run(["cupsaccept", printer_name], capture_output=True, timeout=10)
r1 = subprocess.run(["sudo", "cupsenable", printer_name], capture_output=True, timeout=10)
r2 = subprocess.run(["sudo", "cupsaccept", printer_name], capture_output=True, timeout=10)
return r1.returncode == 0 and r2.returncode == 0
except Exception as e:
logger.error("Erreur enable printer %s: %s", printer_name, e)
@@ -352,7 +637,7 @@ class PrinterService:
async def disable_printer(self, printer_name: str) -> bool:
"""Désactive l'imprimante CUPS (cupsdisable)."""
try:
result = subprocess.run(["cupsdisable", printer_name], capture_output=True, timeout=10)
result = subprocess.run(["sudo", "cupsdisable", printer_name], capture_output=True, timeout=10)
return result.returncode == 0
except Exception as e:
logger.error("Erreur disable printer %s: %s", printer_name, e)
@@ -361,7 +646,7 @@ class PrinterService:
async def reject_jobs(self, printer_name: str) -> bool:
"""Refuse les nouveaux jobs (cupsreject) sans stopper l'impression en cours."""
try:
result = subprocess.run(["cupsreject", printer_name], capture_output=True, timeout=10)
result = subprocess.run(["sudo", "cupsreject", printer_name], capture_output=True, timeout=10)
return result.returncode == 0
except Exception as e:
logger.error("Erreur reject printer %s: %s", printer_name, e)
+4
View File
@@ -98,6 +98,10 @@ class SystemService:
services = [
("photobooth-app", "Photobooth App"),
("jh-photomaton", "JH Photomaton"),
("photobooth-kiosk", "Kiosk (Chromium)"),
("zoraxy", "Zoraxy (Reverse Proxy)"),
("hostapd", "WiFi Hotspot (hostapd)"),
("dnsmasq", "DNS/DHCP (dnsmasq)"),
("cups", "CUPS (Impression)"),
]
result = []
+23 -21
View File
@@ -15,30 +15,31 @@ button:
print_enabled: false
relay_pin: 12
button_actions:
1:
'1':
label: Photo normale
photobooth_index: 0
2:
'2':
label: Photo etoile
photobooth_index: 1
3:
label: Photo cailloux
'3':
label: Photo nuage
photobooth_index: 2
4:
label: Photo soiree
'4':
label: Photo feu
photobooth_index: 3
event:
name: Evenement
slug: evenement
started_at: 0
name: CLEM - 30 ans
slug: clem_30_ans
started_at: 1784292610.030078
gallery:
photos_per_page: 24
public_enabled: true
qr_base_url: https://photomaton-galerie.lessapinsduweb.com
leds:
auto_brightness: true
brightness: 180
brightness: 20
count: 35
countdown_duration: 5.0 # Durée du countdown photobooth-app (secondes)
dma: 10
effects:
capture:
@@ -46,18 +47,18 @@ leds:
- 255
- 200
- 80
flash_duration: 0.3
flashes: 2
flash_duration: 1.0
flashes: 1
mode: flash
captured:
color:
- 150
- 0
- 200
- 255
- 0
mode: solid
countdown:
color:
- 0
- 255
- 200
- 80
mode: fill_progressive
@@ -83,31 +84,32 @@ leds:
mode: solid
idle:
color:
- 0
- 50
- 30
- 80
- 10
mode: solid
speed: 0.025
printing:
color:
- 150
- 0
- 120
- 255
- 200
mode: spin
speed: 0.05
freq_hz: 800000
pin: 18
pre_flash_advance: 2.5 # Démarrer le flash X secondes avant la fin du countdown
strip_type: WS2812
photobooth:
public_url: https://photomaton.lessapinsduweb.com
config_file: /home/pi/photobooth-data/config/config.json
data_dir: /home/pi/photobooth-data
media_dir: /home/pi/photobooth-data/media/processed_full
public_url: https://photomaton.lessapinsduweb.com
show_delete_button: false
userdata_dir: /home/pi/photobooth-data/userdata
print:
default_copies: 1
mode: validation
mode: gallery
printers:
- label: Selphy Blanche (WiFi)
name: Selphy_Blanche_WiFi
+3 -3
View File
@@ -265,11 +265,11 @@ select.form-control option { background: var(--surface2); }
}
.led-ring.idle { border-color: #1e4080; box-shadow: 0 0 15px rgba(30,64,128,0.5); color: #4080d0; }
.led-ring.countdown { border-color: var(--led-green); box-shadow: 0 0 20px rgba(34,197,94,0.6); color: var(--led-green); animation: pulse-green 1s infinite; }
.led-ring.countdown { border-color: #ffc850; box-shadow: 0 0 20px rgba(255,200,80,0.7); color: #ffc850; animation: pulse-green 1s infinite; }
.led-ring.capture { border-color: #fff; box-shadow: 0 0 30px rgba(255,255,255,0.8); color: #fff; }
.led-ring.captured { border-color: var(--led-purple); box-shadow: 0 0 20px rgba(168,85,247,0.6); color: var(--led-purple); }
.led-ring.captured { border-color: var(--led-green); box-shadow: 0 0 20px rgba(34,197,94,0.6); color: var(--led-green); }
.led-ring.finished { border-color: var(--led-blue); box-shadow: 0 0 20px rgba(59,130,246,0.6); color: var(--led-blue); }
.led-ring.printing { border-color: var(--led-blue); box-shadow: 0 0 20px rgba(59,130,246,0.4); color: var(--led-blue); animation: spin-ring 1s linear infinite; }
.led-ring.printing { border-color: var(--led-purple); box-shadow: 0 0 20px rgba(168,85,247,0.5); color: var(--led-purple); animation: spin-ring 1s linear infinite; }
.led-ring.error { border-color: var(--error); box-shadow: 0 0 20px rgba(239,68,68,0.6); color: var(--error); animation: blink 0.3s infinite; }
.led-ring.disabled { border-color: #4a0000; box-shadow: 0 0 10px rgba(100,0,0,0.3); color: #800000; }
.led-ring.off { border-color: var(--border); }
+591 -175
View File
@@ -1,151 +1,175 @@
{% extends "base.html" %}
{% block title %}Actions — JH Photomaton{% endblock %}
{% block nav_links %}
<a href="/admin">Dashboard</a>
<a href="/admin/gallery">Galerie</a>
<a href="/admin/actions" class="active">Actions</a>
<a href="/admin/print">Impression</a>
<a href="/admin/settings">Réglages</a>
<a href="/admin/logout">Déconnexion</a>
{% block head %}
<style>
/* ── Cards actions ──────────────────────────────────────────────────── */
.action-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: .6rem;
transition: box-shadow .15s, opacity .15s;
cursor: default;
}
.action-card.drag-over {
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary);
}
.action-card.dragging { opacity: .35; }
.ac-header {
display: flex;
align-items: center;
gap: .6rem;
padding: .75rem 1rem;
flex-wrap: wrap;
}
.drag-handle {
cursor: grab;
font-size: 1.2rem;
color: var(--text-muted);
user-select: none;
flex-shrink: 0;
letter-spacing: .05em;
}
.drag-handle:active { cursor: grabbing; }
.ac-idx {
font-size: .7rem;
background: var(--bg-muted);
color: var(--text-muted);
border-radius: 4px;
padding: .1rem .4rem;
flex-shrink: 0;
}
.ac-name {
font-weight: 600;
min-width: 120px;
}
.ac-chips {
display: flex;
flex-wrap: wrap;
gap: .35rem;
flex: 1;
}
.ac-chip {
font-size: .72rem;
background: var(--bg-muted);
border-radius: 10px;
padding: .1rem .5rem;
color: var(--text-muted);
white-space: nowrap;
}
.ac-chip.hl { background: var(--primary-muted, rgba(99,102,241,.15)); color: var(--primary); }
.ac-btns { display: flex; gap: .25rem; flex-shrink: 0; }
/* ── Edit form ──────────────────────────────────────────────────────── */
.ac-edit {
border-top: 1px solid var(--border);
padding: 1rem;
}
.edit-section { margin-bottom: 1rem; }
.edit-section-title {
font-size: .75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .07em;
color: var(--text-muted);
margin-bottom: .5rem;
padding-bottom: .25rem;
border-bottom: 1px solid var(--border);
}
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: .75rem;
}
/* ── Presets ────────────────────────────────────────────────────────── */
.preset-card {
display: flex;
align-items: center;
gap: .75rem;
padding: .6rem .9rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: .4rem;
}
.preset-info { flex: 1; }
.preset-name { font-weight: 600; }
.preset-meta { font-size: .75rem; color: var(--text-muted); }
/* ── Reorder bar ────────────────────────────────────────────────────── */
#reorder-bar {
display: none;
background: rgba(245,158,11,.1);
border: 1px solid rgba(245,158,11,.4);
border-radius: var(--radius);
padding: .5rem .75rem;
font-size: .85rem;
margin-bottom: .75rem;
align-items: center;
gap: .75rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<h1 class="page-title">Gestion des actions & mapping bouton</h1>
<!-- Mapping clics → actions -->
<!-- Header -->
<div class="flex items-center justify-between mb-2">
<h1 class="page-title" style="margin:0">Gestion des actions</h1>
<div class="flex gap-1">
<button class="btn btn-ghost btn-sm" id="restart-btn" onclick="restartPhotobooth()" style="display:none">
🔄 Redémarrer photobooth-app
</button>
<button class="btn btn-primary" onclick="newAction()">+ Nouvelle action</button>
</div>
</div>
<!-- ── Actions ──────────────────────────────────────────────────── -->
<div class="section">
<div class="card-title">Mapping bouton → actions photobooth-app</div>
<div class="card-title">Actions image photobooth-app <span class="text-muted text-sm" id="action-count"></span></div>
<!-- Barre "ordre modifié" -->
<div id="reorder-bar">
<span>↕ Ordre modifié</span>
<button class="btn btn-primary btn-sm" onclick="saveOrder()">💾 Sauvegarder l'ordre</button>
<button class="btn btn-ghost btn-sm" onclick="loadAll()">✕ Annuler</button>
</div>
<div id="actions-list">
<div class="empty-state"><div class="icon"></div>Chargement…</div>
</div>
</div>
<!-- ── Mapping bouton ───────────────────────────────────────────── -->
<div class="section">
<div class="card-title">Mapping bouton → actions</div>
<div class="card">
<p class="text-sm text-muted mb-2">Associe chaque nombre de clics à une action dans photobooth-app. L'index correspond à la position dans la liste des actions (0 = première action).</p>
<table class="table">
<thead>
<tr>
<th>Clics</th>
<th>Label affiché</th>
<th>Index action (photobooth)</th>
<th>Action correspondante</th>
<th></th>
</tr>
</thead>
<tbody>
{% for n in range(1, 5) %}
{% set mapping = button_mapping.get(n) or button_mapping.get(n|string) or {} %}
<tr>
<td class="font-bold">
{% if n == 1 %}1 clic{% else %}{{ n }} clics{% endif %}
</td>
<td>
<input type="text" id="label-{{ n }}" class="form-control" style="max-width:200px"
value="{{ mapping.get('label', '') }}" placeholder="Label…">
</td>
<td>
<select id="index-{{ n }}" class="form-control" style="max-width:80px">
{% for i in range(pb_actions|length) %}
<option value="{{ i }}" {% if mapping.get('photobooth_index') == i %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
</td>
<td class="text-sm text-muted" id="action-name-{{ n }}">
{% set idx = mapping.get('photobooth_index', 0) %}
{% if pb_actions and idx < pb_actions|length %}
{{ pb_actions[idx].name }}
{% else %}{% endif %}
</td>
<td>
<button class="btn btn-ghost btn-sm" onclick="testAction({{ n }})">▶ Test</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="text-sm text-muted mb-2">Associe chaque nombre de clics à une action.</p>
<div id="mapping-table">
<div class="empty-state"><div class="icon"></div></div>
</div>
<button class="btn btn-primary mt-2" onclick="saveMapping()">💾 Sauvegarder le mapping</button>
</div>
</div>
<!-- Actions photobooth-app -->
<!-- ── Presets ──────────────────────────────────────────────────── -->
<div class="section">
<div class="card-title">Actions image photobooth-app ({{ pb_actions|length }} au total)</div>
<div id="actions-list">
{% for action in pb_actions %}
<div class="card mb-1" id="action-{{ loop.index0 }}" style="margin-bottom:0.75rem">
<div class="flex items-center justify-between mb-1">
<div>
<span class="badge badge-muted text-xs">Action {{ loop.index0 }}</span>
<span class="font-bold" style="margin-left:0.5rem">{{ action.name }}</span>
</div>
<div class="flex gap-1">
<button class="btn btn-ghost btn-sm" onclick="toggleEdit({{ loop.index0 }})">✏ Modifier</button>
<button class="btn btn-ghost btn-sm" onclick="triggerAction({{ loop.index0 }})">▶ Déclencher</button>
</div>
</div>
<!-- Infos résumées -->
<div class="flex gap-2 text-sm text-muted" id="summary-{{ loop.index0 }}">
<span>⏱ {{ action.jobcontrol.get('countdown_capture', '?') }}s</span>
{% if action.processing.get('img_frame_file') %}
<span>🖼 {{ action.processing.img_frame_file.split('/')[-1] }}</span>
{% endif %}
{% if action.processing.get('remove_background') %}
<span class="badge badge-info">Remove BG</span>
{% endif %}
{% if action.processing.get('img_background_file') %}
<span>🌄 {{ action.processing.img_background_file.split('/')[-1] }}</span>
{% endif %}
</div>
<!-- Formulaire d'édition (masqué par défaut) -->
<div class="edit-form mt-2" id="edit-{{ loop.index0 }}" style="display:none;border-top:1px solid var(--border);padding-top:1rem">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Nom de l'action</label>
<input type="text" class="form-control" id="e-name-{{ loop.index0 }}" value="{{ action.name }}">
</div>
<div class="form-group">
<label class="form-label">Countdown (secondes)</label>
<input type="number" class="form-control" id="e-countdown-{{ loop.index0 }}"
value="{{ action.jobcontrol.get('countdown_capture', 5) }}" min="1" max="30" step="0.5">
</div>
<div class="form-group">
<label class="form-label">Cadre (img_frame_file)</label>
<input type="text" class="form-control" id="e-frame-{{ loop.index0 }}"
value="{{ action.processing.get('img_frame_file', '') or '' }}" placeholder="userdata/…/cadre.png">
</div>
<div class="form-group">
<label class="form-label">Fond (img_background_file)</label>
<input type="text" class="form-control" id="e-bg-{{ loop.index0 }}"
value="{{ action.processing.get('img_background_file', '') or '' }}" placeholder="userdata/…/fond.jpg">
</div>
<div class="form-group">
<label class="form-label">Filtre image</label>
<select class="form-control" id="e-filter-{{ loop.index0 }}">
<option value="original" {% if action.processing.get('image_filter','original') == 'original' %}selected{% endif %}>original</option>
<option value="FilterPilgram2.earlybird" {% if 'earlybird' in (action.processing.get('image_filter','')) %}selected{% endif %}>Earlybird</option>
<option value="FilterPilgram2.reyes" {% if 'reyes' in (action.processing.get('image_filter','')) %}selected{% endif %}>Reyes</option>
<option value="FilterPilgram2.moon" {% if 'moon' in (action.processing.get('image_filter','')) %}selected{% endif %}>Moon</option>
</select>
</div>
<div class="form-group">
<label class="form-label flex items-center gap-1">
<input type="checkbox" id="e-rmbg-{{ loop.index0 }}" {% if action.processing.get('remove_background') %}checked{% endif %}>
Remove Background (MODNet — ~400Mo RAM)
</label>
<label class="form-label flex items-center gap-1 mt-1">
<input type="checkbox" id="e-bgena-{{ loop.index0 }}" {% if action.processing.get('img_background_enable') %}checked{% endif %}>
Activer le fond
</label>
</div>
</div>
<div class="flex gap-1">
<button class="btn btn-primary btn-sm" onclick="saveAction({{ loop.index0 }})">💾 Sauvegarder</button>
<button class="btn btn-ghost btn-sm" onclick="toggleEdit({{ loop.index0 }})">Annuler</button>
</div>
</div>
<div class="card-title">Presets d'actions</div>
<div class="card">
<p class="text-sm text-muted mb-2">Sauvegarder la configuration actuelle pour la restaurer plus tard (ex : mariage, baptême, anniversaire…).</p>
<div class="flex gap-1 mb-2" style="flex-wrap:wrap">
<input type="text" class="form-control" id="preset-name" placeholder="Nom du preset…" style="max-width:220px">
<input type="text" class="form-control" id="preset-desc" placeholder="Description (optionnel)" style="max-width:280px">
<button class="btn btn-primary" onclick="savePreset()">💾 Sauvegarder le preset</button>
</div>
<div id="presets-list">
<div class="empty-state text-sm">Aucun preset sauvegardé</div>
</div>
{% else %}
<div class="empty-state"><div class="icon">⚙️</div>Aucune action configurée dans photobooth-app</div>
{% endfor %}
</div>
</div>
@@ -154,72 +178,464 @@
{% block scripts %}
<script>
// ── Formulaires d'édition ─────────────────────────────────────────────────────
function toggleEdit(idx) {
const el = document.getElementById('edit-' + idx);
const sum = document.getElementById('summary-' + idx);
const open = el.style.display === 'none';
el.style.display = open ? 'block' : 'none';
sum.style.display = open ? 'none' : 'flex';
// ── État global ───────────────────────────────────────────────────────────────
let actions = [];
let assets = { frames: [], backgrounds: [] };
let mapping = {};
let orderChanged = false;
const FILTERS = [
['original', 'Original'],
['FilterPilgram2.earlybird', 'Earlybird'],
['FilterPilgram2.reyes', 'Reyes'],
['FilterPilgram2.moon', 'Moon'],
['FilterPilgram2.gingham', 'Gingham'],
['FilterPilgram2.mayfair', 'Mayfair'],
['FilterPilgram2.nashville', 'Nashville'],
['FilterPilgram2.inkwell', 'Inkwell (N&B)'],
];
// ── Chargement ────────────────────────────────────────────────────────────────
async function loadAll() {
orderChanged = false;
document.getElementById('reorder-bar').style.display = 'none';
try {
const [actData, assetData] = await Promise.all([
api('GET', '/api/actions/photobooth'),
api('GET', '/api/actions/assets'),
]);
actions = actData.actions || [];
mapping = actData.button_mapping || {};
assets = assetData;
renderActions();
renderMapping();
loadPresets();
} catch(e) {
document.getElementById('actions-list').innerHTML =
`<div class="empty-state"><div class="icon">⚠️</div>Erreur chargement: ${e.message}</div>`;
}
}
// ── Rendu des actions ─────────────────────────────────────────────────────────
function renderActions() {
const el = document.getElementById('actions-list');
document.getElementById('action-count').textContent = `(${actions.length})`;
if (!actions.length) {
el.innerHTML = '<div class="empty-state"><div class="icon">⚙️</div>Aucune action</div>';
return;
}
el.innerHTML = actions.map((a, i) => cardHTML(a, i)).join('');
attachDragListeners();
}
function cardHTML(action, idx) {
const name = esc(action.name || 'Sans nom');
const proc = action.processing || {};
const jc = action.jobcontrol || {};
const chips = [
`<span class="ac-chip">⏱ ${jc.countdown_capture ?? '?'}s</span>`,
];
if (proc.img_frame_enable && proc.img_frame_file)
chips.push(`<span class="ac-chip">🖼 ${esc(proc.img_frame_file.split('/').pop())}</span>`);
if (proc.img_background_enable && proc.img_background_file)
chips.push(`<span class="ac-chip">🌄 ${esc(proc.img_background_file.split('/').pop())}</span>`);
if (proc.fill_background_enable)
chips.push(`<span class="ac-chip" style="background:${esc(proc.fill_background_color||'#eee')};color:#000">⬛ fond couleur</span>`);
if (proc.remove_background)
chips.push('<span class="ac-chip hl">Remove BG</span>');
if (proc.image_filter && proc.image_filter !== 'original')
chips.push(`<span class="ac-chip hl">🎨 ${esc(proc.image_filter.split('.').pop())}</span>`);
return `
<div class="action-card" draggable="true" data-idx="${idx}" id="ac-${idx}">
<div class="ac-header">
<span class="drag-handle" title="Glisser pour réordonner">⠿⠿</span>
<span class="ac-idx">#${idx}</span>
<span class="ac-name">${name}</span>
<div class="ac-chips">${chips.join('')}</div>
<div class="ac-btns">
<button class="btn btn-ghost btn-xs" onclick="toggleEdit(${idx})" title="Modifier">✏</button>
<button class="btn btn-ghost btn-xs" onclick="cloneAction(${idx})" title="Dupliquer">⧉</button>
<button class="btn btn-ghost btn-xs" onclick="triggerAction(${idx})" title="Déclencher">▶</button>
<button class="btn btn-danger btn-xs" onclick="deleteAction(${idx})" title="Supprimer">✕</button>
</div>
</div>
<div class="ac-edit" id="edit-${idx}" style="display:none"></div>
</div>`;
}
// ── Formulaire d'édition (rendu lazy au premier toggle) ───────────────────────
function editFormHTML(action, idx) {
const proc = action.processing || {};
const jc = action.jobcontrol || {};
const frameSel = assetSelect(`ef-frame-sel-${idx}`, proc.img_frame_file || '', assets.frames, '— aucun cadre —', `ef-frame-${idx}`);
const bgSel = assetSelect(`ef-bg-sel-${idx}`, proc.img_background_file || '', assets.backgrounds, '— aucun fond —', `ef-bg-${idx}`);
const filterOpts = FILTERS.map(([v, l]) =>
`<option value="${esc(v)}" ${(proc.image_filter||'original')===v?'selected':''}>${esc(l)}</option>`).join('');
return `
<div class="edit-section">
<div class="edit-section-title">Général</div>
<div class="grid-3">
<div class="form-group">
<label class="form-label">Nom de l'action</label>
<input type="text" class="form-control" id="ef-name-${idx}" value="${esc(action.name||'')}">
</div>
<div class="form-group">
<label class="form-label">Countdown (secondes)</label>
<input type="number" class="form-control" id="ef-countdown-${idx}"
value="${jc.countdown_capture??5}" min="1" max="60" step="0.5">
</div>
</div>
</div>
<div class="edit-section">
<div class="edit-section-title">Cadre</div>
<div class="grid-3">
<div class="form-group">
<label class="form-label flex items-center gap-1">
<input type="checkbox" id="ef-frame-ena-${idx}" ${proc.img_frame_enable?'checked':''}> Activer le cadre
</label>
${frameSel}
<input type="text" class="form-control mt-1" id="ef-frame-${idx}"
value="${esc(proc.img_frame_file||'')}" placeholder="userdata/…/cadre.png" style="font-size:.8rem">
</div>
</div>
</div>
<div class="edit-section">
<div class="edit-section-title">Fond</div>
<div class="grid-3">
<div class="form-group">
<label class="form-label flex items-center gap-1">
<input type="checkbox" id="ef-bg-ena-${idx}" ${proc.img_background_enable?'checked':''}> Activer le fond image
</label>
${bgSel}
<input type="text" class="form-control mt-1" id="ef-bg-${idx}"
value="${esc(proc.img_background_file||'')}" placeholder="userdata/…/fond.jpg" style="font-size:.8rem">
</div>
<div class="form-group">
<label class="form-label flex items-center gap-1">
<input type="checkbox" id="ef-fill-ena-${idx}" ${proc.fill_background_enable?'checked':''}> Activer le fond couleur
</label>
<input type="color" class="form-control mt-1" id="ef-fill-color-${idx}"
value="${esc(proc.fill_background_color||'#ededed')}" style="height:36px;max-width:80px">
</div>
</div>
</div>
<div class="edit-section">
<div class="edit-section-title">Traitement image</div>
<div class="grid-3">
<div class="form-group">
<label class="form-label flex items-center gap-1">
<input type="checkbox" id="ef-rmbg-${idx}" ${proc.remove_background?'checked':''}>
Remove Background (MODNet)
</label>
</div>
<div class="form-group">
<label class="form-label">Filtre</label>
<select class="form-control" id="ef-filter-${idx}">${filterOpts}</select>
</div>
</div>
</div>
<div class="flex gap-1">
<button class="btn btn-primary btn-sm" onclick="saveAction(${idx})">💾 Sauvegarder</button>
<button class="btn btn-ghost btn-sm" onclick="toggleEdit(${idx})">Annuler</button>
</div>`;
}
function assetSelect(selId, current, options, placeholder, textId) {
const opts = options.map(f => {
const label = f.replace(/^userdata\//, '');
return `<option value="${esc(f)}" ${f===current?'selected':''}>${esc(label)}</option>`;
}).join('');
return `<select class="form-control" id="${selId}"
onchange="document.getElementById('${textId}').value=this.value">
<option value="">${esc(placeholder)}</option>${opts}
</select>`;
}
// ── Toggle édition ────────────────────────────────────────────────────────────
function toggleEdit(idx) {
const el = document.getElementById(`edit-${idx}`);
if (el.style.display === 'none') {
if (!el.dataset.rendered) {
el.innerHTML = editFormHTML(actions[idx], idx);
el.dataset.rendered = '1';
}
el.style.display = 'block';
} else {
el.style.display = 'none';
}
}
// ── Sauvegarde action ─────────────────────────────────────────────────────────
async function saveAction(idx) {
const realIdx = actions[idx]._index ?? idx;
const updates = {
name: document.getElementById('e-name-' + idx).value,
countdown_capture: parseFloat(document.getElementById('e-countdown-' + idx).value),
img_frame_file: document.getElementById('e-frame-' + idx).value || null,
img_background_file: document.getElementById('e-bg-' + idx).value || null,
image_filter: document.getElementById('e-filter-' + idx).value,
remove_background: document.getElementById('e-rmbg-' + idx).checked,
img_background_enable: document.getElementById('e-bgena-' + idx).checked,
name: document.getElementById(`ef-name-${idx}`).value,
countdown_capture: parseFloat(document.getElementById(`ef-countdown-${idx}`).value),
img_frame_enable: document.getElementById(`ef-frame-ena-${idx}`).checked,
img_frame_file: document.getElementById(`ef-frame-${idx}`).value || null,
img_background_enable: document.getElementById(`ef-bg-ena-${idx}`).checked,
img_background_file: document.getElementById(`ef-bg-${idx}`).value || null,
fill_background_enable: document.getElementById(`ef-fill-ena-${idx}`).checked,
fill_background_color: document.getElementById(`ef-fill-color-${idx}`).value,
remove_background: document.getElementById(`ef-rmbg-${idx}`).checked,
image_filter: document.getElementById(`ef-filter-${idx}`).value,
};
try {
await api('PUT', `/api/actions/photobooth/${idx}`, updates);
showToast('✅ Action sauvegardée — redémarrage photobooth-app requis', 'success', 6000);
toggleEdit(idx);
await api('PUT', `/api/actions/photobooth/${realIdx}`, updates);
showToast('✅ Action sauvegardée', 'success');
showRestartBtn();
await loadAll();
} catch(e) {
showToast('❌ Erreur sauvegarde', 'error');
showToast(`❌ Erreur: ${e.message}`, 'error');
}
}
// ── Clone / Delete / Trigger ──────────────────────────────────────────────────
async function cloneAction(idx) {
const realIdx = actions[idx]._index ?? idx;
try {
await api('POST', `/api/actions/photobooth/${realIdx}/clone`);
showToast('⧉ Action dupliquée', 'success');
showRestartBtn();
await loadAll();
} catch(e) { showToast(`${e.message}`, 'error'); }
}
async function deleteAction(idx) {
const name = actions[idx].name || `Action ${idx}`;
if (!confirm(`Supprimer « ${name} » ?\nCette action sera définitivement retirée de photobooth-app.`)) return;
const realIdx = actions[idx]._index ?? idx;
try {
await api('DELETE', `/api/actions/photobooth/${realIdx}`);
showToast(`🗑 « ${name} » supprimée`, 'info');
showRestartBtn();
await loadAll();
} catch(e) { showToast(`${e.message}`, 'error'); }
}
async function newAction() {
const name = prompt('Nom de la nouvelle action :', 'Nouvelle action');
if (name === null) return;
try {
await api('POST', '/api/actions/photobooth', { name: name.trim() || 'Nouvelle action' });
showToast('✅ Action créée', 'success');
showRestartBtn();
await loadAll();
} catch(e) { showToast(`${e.message}`, 'error'); }
}
async function triggerAction(idx) {
if (!confirm(`Déclencher l'action ${idx} (prise de photo) ?`)) return;
const realIdx = actions[idx]._index ?? idx;
const name = actions[idx].name || `Action ${idx}`;
if (!confirm(`Déclencher « ${name} » (prise de photo) ?`)) return;
try {
await api('POST', `/api/actions/trigger/${idx}`);
showToast(`Action ${idx} déclenchée`, 'info');
} catch(e) { showToast('Erreur déclenchement', 'error'); }
await api('POST', `/api/actions/trigger/${realIdx}`);
showToast(`« ${name} » déclenchée`, 'info');
} catch(e) { showToast(`${e.message}`, 'error'); }
}
// ── Mapping bouton ─────────────────────────────────────────────────────────────
async function saveMapping() {
const mapping = {};
// ── Drag & Drop ───────────────────────────────────────────────────────────────
let dragSrcIdx = null;
function attachDragListeners() {
document.querySelectorAll('.action-card').forEach(card => {
const idx = parseInt(card.dataset.idx);
card.addEventListener('dragstart', e => {
dragSrcIdx = idx;
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => card.classList.add('dragging'), 0);
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
document.querySelectorAll('.action-card').forEach(c => c.classList.remove('drag-over'));
});
card.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
});
card.addEventListener('dragenter', e => {
e.preventDefault();
if (parseInt(card.dataset.idx) !== dragSrcIdx) {
document.querySelectorAll('.action-card').forEach(c => c.classList.remove('drag-over'));
card.classList.add('drag-over');
}
});
card.addEventListener('dragleave', e => {
if (!card.contains(e.relatedTarget)) card.classList.remove('drag-over');
});
card.addEventListener('drop', e => {
e.preventDefault();
const targetIdx = parseInt(card.dataset.idx);
if (dragSrcIdx === null || dragSrcIdx === targetIdx) return;
// Réordonne en mémoire
const [moved] = actions.splice(dragSrcIdx, 1);
actions.splice(targetIdx, 0, moved);
dragSrcIdx = null;
orderChanged = true;
renderActions();
document.getElementById('reorder-bar').style.display = 'flex';
});
});
}
async function saveOrder() {
// _index contient l'index original côté serveur
const order = actions.map(a => a._index);
try {
await api('PUT', '/api/actions/photobooth/reorder', order);
showToast('✅ Ordre sauvegardé', 'success');
showRestartBtn();
await loadAll();
} catch(e) { showToast(`${e.message}`, 'error'); }
}
// ── Mapping bouton ────────────────────────────────────────────────────────────
function renderMapping() {
const el = document.getElementById('mapping-table');
const names = actions.map(a => a.name || '?');
let html = '<table class="table"><thead><tr><th>Clics</th><th>Label</th><th>Action</th><th></th></tr></thead><tbody>';
for (let n = 1; n <= 4; n++) {
mapping[n] = {
label: document.getElementById('label-' + n).value,
photobooth_index: parseInt(document.getElementById('index-' + n).value),
const m = mapping[n] || mapping[String(n)] || {};
const selectedIdx = m.photobooth_index ?? 0;
const opts = actions.map((a, i) =>
`<option value="${i}" ${i===selectedIdx?'selected':''}>#${i}${esc(a.name||'?')}</option>`
).join('');
html += `<tr>
<td class="font-bold">${n} clic${n>1?'s':''}</td>
<td><input type="text" id="ml-${n}" class="form-control" style="max-width:180px" value="${esc(m.label||'')}" placeholder="Label…"></td>
<td><select id="mi-${n}" class="form-control" style="max-width:280px">${opts}</select></td>
<td><button class="btn btn-ghost btn-xs" onclick="testMapping(${n})">▶ Test</button></td>
</tr>`;
}
html += '</tbody></table>';
el.innerHTML = html;
}
async function saveMapping() {
const m = {};
for (let n = 1; n <= 4; n++) {
m[n] = {
label: document.getElementById(`ml-${n}`).value,
photobooth_index: parseInt(document.getElementById(`mi-${n}`).value),
};
}
try {
await api('PUT', '/api/actions/mapping', mapping);
await api('PUT', '/api/actions/mapping', m);
showToast('✅ Mapping sauvegardé', 'success');
} catch(e) { showToast('❌ Erreur sauvegarde mapping', 'error'); }
mapping = m;
} catch(e) { showToast(`${e.message}`, 'error'); }
}
async function testAction(n) {
const idx = parseInt(document.getElementById('index-' + n).value);
if (!confirm(`Déclencher l'action ${idx} pour tester le ${n} clic ?`)) return;
async function testMapping(n) {
const idx = parseInt(document.getElementById(`mi-${n}`).value);
const realIdx = actions[idx]?._index ?? idx;
if (!confirm(`Déclencher action #${realIdx} (test ${n} clic) ?`)) return;
try {
await api('POST', `/api/actions/trigger/${idx}`);
showToast(`▶ Test ${n} clic → action ${idx}`, 'info');
} catch(e) { showToast('Erreur', 'error'); }
await api('POST', `/api/actions/trigger/${realIdx}`);
showToast(`▶ Test ${n} clic → action #${realIdx}`, 'info');
} catch(e) { showToast(`${e.message}`, 'error'); }
}
// Mise à jour du nom de l'action quand on change l'index
{% for n in range(1, 5) %}
document.getElementById('index-{{ n }}').addEventListener('change', function() {
const names = {{ pb_actions | map(attribute='name') | list | tojson }};
document.getElementById('action-name-{{ n }}').textContent = names[this.value] || '—';
});
{% endfor %}
// ── Presets ───────────────────────────────────────────────────────────────────
async function loadPresets() {
try {
const data = await api('GET', '/api/actions/presets');
renderPresets(data.presets || []);
} catch(e) {}
}
function renderPresets(presets) {
const el = document.getElementById('presets-list');
if (!presets.length) {
el.innerHTML = '<p class="text-sm text-muted">Aucun preset sauvegardé.</p>';
return;
}
el.innerHTML = presets.map(p => `
<div class="preset-card">
<div class="preset-info">
<div class="preset-name">${esc(p.label || p.name)}</div>
<div class="preset-meta">
${p.action_count} action(s)
${p.description ? ' · ' + esc(p.description) : ''}
${p.saved_at ? ' · ' + esc(p.saved_at.replace('T',' ')) : ''}
</div>
</div>
<div class="flex gap-1">
<button class="btn btn-primary btn-sm" onclick="restorePreset('${esc(p.name)}', '${esc(p.label||p.name)}')">↩ Restaurer</button>
<button class="btn btn-danger btn-xs" onclick="deletePreset('${esc(p.name)}', '${esc(p.label||p.name)}')">✕</button>
</div>
</div>`).join('');
}
async function savePreset() {
const name = document.getElementById('preset-name').value.trim();
const desc = document.getElementById('preset-desc').value.trim();
if (!name) { showToast('Saisissez un nom de preset', 'error'); return; }
try {
const r = await api('POST', '/api/actions/presets', { name, description: desc });
showToast(`✅ Preset « ${name} » sauvegardé (${r.action_count} actions)`, 'success');
document.getElementById('preset-name').value = '';
document.getElementById('preset-desc').value = '';
loadPresets();
} catch(e) { showToast(`${e.message}`, 'error'); }
}
async function restorePreset(name, label) {
if (!confirm(`Restaurer le preset « ${label} » ?\nLes actions actuelles seront remplacées.`)) return;
try {
const r = await api('POST', `/api/actions/presets/${encodeURIComponent(name)}/restore`);
showToast(`✅ Preset « ${label} » restauré (${r.action_count} actions)`, 'success');
showRestartBtn();
await loadAll();
} catch(e) { showToast(`${e.message}`, 'error'); }
}
async function deletePreset(name, label) {
if (!confirm(`Supprimer le preset « ${label} » ?`)) return;
try {
await api('DELETE', `/api/actions/presets/${encodeURIComponent(name)}`);
showToast(`🗑 Preset « ${label} » supprimé`, 'info');
loadPresets();
} catch(e) { showToast(`${e.message}`, 'error'); }
}
// ── Utilitaires ───────────────────────────────────────────────────────────────
function esc(s) {
if (s === null || s === undefined) return '';
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ── Restart photobooth-app ────────────────────────────────────────────────────
function showRestartBtn() {
document.getElementById('restart-btn').style.display = '';
}
async function restartPhotobooth() {
const btn = document.getElementById('restart-btn');
btn.disabled = true;
btn.textContent = '⏳ Redémarrage…';
try {
await api('POST', '/api/system/restart-photobooth');
showToast('🔄 photobooth-app redémarre — patientez ~10s', 'info', 8000);
btn.style.display = 'none';
} catch(e) {
showToast('❌ Erreur redémarrage: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = '🔄 Redémarrer photobooth-app';
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
loadAll();
</script>
{% endblock %}
+71 -1
View File
@@ -7,6 +7,7 @@
<a href="/admin/actions">Actions</a>
<a href="/admin/print">Impression</a>
<a href="/admin/settings">Réglages</a>
<a href="/admin/events">Événements</a>
<a href="/admin/logout">Déconnexion</a>
{% endblock %}
@@ -141,12 +142,19 @@
<div class="card-title">Services</div>
<div class="card">
<table class="table">
<thead><tr><th>Service</th><th>Statut</th></tr></thead>
<thead><tr><th>Service</th><th>Statut</th><th>Action</th></tr></thead>
<tbody id="services-tbody">
{% for svc in services %}
<tr>
<td>{{ svc.label }}</td>
<td><span class="badge {{ 'badge-success' if svc.active else 'badge-error' }}">{{ 'Actif' if svc.active else 'Arrêté' }}</span></td>
<td>
{% if svc.name == 'jh-photomaton' %}
<button class="btn btn-ghost btn-sm" onclick="restartSelf()">🔄 Restart</button>
{% else %}
<button class="btn btn-ghost btn-sm" onclick="restartService('{{ svc.name }}', '{{ svc.label }}')">🔄 Restart</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
@@ -154,7 +162,24 @@
</div>
</div>
<!-- Actions système -->
<div class="section">
<div class="card-title">Actions système</div>
<div class="card flex gap-2" style="flex-wrap:wrap;align-items:center">
<button class="btn btn-primary btn-sm" onclick="restartSelf()">🔄 Redémarrer JH-Photomaton</button>
<button class="btn btn-danger btn-sm" onclick="rebootPi()">⚡ Redémarrer le Raspberry Pi</button>
</div>
</div>
</div>
<!-- Overlay reconnexion -->
<div id="reconnect-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:9999;display:none;align-items:center;justify-content:center;flex-direction:column;gap:1rem">
<div style="color:#fff;font-size:1.2rem;font-weight:600" id="reconnect-msg">Redémarrage en cours…</div>
<div style="color:#aaa;font-size:.9rem">Reconnexion automatique dans quelques secondes…</div>
<div class="spinner" style="width:40px;height:40px;border:4px solid #444;border-top-color:#4f9cf9;border-radius:50%;animation:spin 1s linear infinite"></div>
</div>
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
{% endblock %}
{% block scripts %}
@@ -323,6 +348,50 @@ async function saveEvent(isNew) {
} catch(err) { showToast('Erreur sauvegarde événement', 'error'); }
}
// ── Restart services ─────────────────────────────────────────────────────────
async function restartService(name, label) {
if (!confirm(`Redémarrer ${label} ?\nLe service sera temporairement indisponible.`)) return;
try {
await api('POST', `/api/system/service/${name}/restart`);
showToast(`🔄 ${label} redémarre…`, 'info');
} catch(e) { showToast('Erreur restart ' + name, 'error'); }
}
async function restartSelf() {
if (!confirm('Redémarrer JH-Photomaton ?\nVous serez déconnecté quelques secondes.')) return;
try {
await api('POST', '/api/system/restart-self');
showReconnectOverlay('Redémarrage de JH-Photomaton…');
} catch(e) { showToast('Erreur restart', 'error'); }
}
async function rebootPi() {
if (!confirm('⚡ ATTENTION : Redémarrer le Raspberry Pi ?\n\nTout sera arrêté (photobooth, WiFi, impression).\nLe démarrage prend environ 30 secondes.\n\nConfirmer le redémarrage ?')) return;
try {
await api('POST', '/api/system/reboot');
showReconnectOverlay('Redémarrage du Raspberry Pi…');
} catch(e) { showToast('Erreur reboot', 'error'); }
}
function showReconnectOverlay(msg) {
const overlay = document.getElementById('reconnect-overlay');
document.getElementById('reconnect-msg').textContent = msg;
overlay.style.display = 'flex';
// Attendre 5s puis commencer à sonder /api/system/ping
setTimeout(pollReconnect, 5000);
}
async function pollReconnect() {
try {
const r = await fetch('/api/system/ping');
if (r.ok) {
window.location.reload();
return;
}
} catch(e) { /* serveur pas encore là */ }
setTimeout(pollReconnect, 2000);
}
// ── Init ──────────────────────────────────────────────────────────────────────
loadQueue();
loadEvent();
@@ -330,3 +399,4 @@ setInterval(loadQueue, 15000);
setInterval(loadEvent, 60000);
</script>
{% endblock %}
+221
View File
@@ -0,0 +1,221 @@
{% extends "base.html" %}
{% block title %}Événements — JH Photomaton{% endblock %}
{% block head %}
<style>
.events-container {
max-width: 1000px;
margin: 1.5rem auto;
padding: 0 1rem;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.card-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--primary);
margin-bottom: 1.25rem;
}
.event-row {
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 1rem;
align-items: center;
padding: .75rem 0;
border-bottom: 1px solid var(--border);
}
.event-row:last-child { border-bottom: none; }
.event-name { font-weight: 600; }
.event-meta { font-size: .78rem; color: var(--text-muted); margin-top: .2rem; }
.badge {
display: inline-block;
padding: .2rem .6rem;
border-radius: 20px;
font-size: .75rem;
font-weight: 600;
}
.badge-active { background: #1a3a1a; color: #4caf50; }
.badge-done { background: var(--surface-2, #222); color: var(--text-muted); }
.stats-row {
display: flex;
gap: 1.5rem;
font-size: .82rem;
color: var(--text-muted);
}
.stats-row span strong { color: var(--text); }
</style>
{% endblock %}
{% block content %}
<div class="events-container">
<h1 class="page-title">📅 Historique des événements</h1>
<!-- Événement en cours -->
<div class="card">
<div class="card-title">🟢 Événement en cours</div>
<div id="current-event-loading" style="color:var(--text-muted)">Chargement…</div>
<div id="current-event" style="display:none">
<div class="event-row">
<div>
<div class="event-name" id="cur-name"></div>
<div class="event-meta">Slug : <code id="cur-slug"></code> &nbsp;|&nbsp; Démarré le <span id="cur-started"></span></div>
<div class="stats-row" style="margin-top:.5rem">
<span>📷 <strong id="cur-photos">0</strong> photos</span>
<span>🖨️ <strong id="cur-prints">0</strong> impressions</span>
<span>⬇️ <strong id="cur-downloads">0</strong> téléchargements</span>
</div>
</div>
<button class="btn btn-ghost btn-sm" onclick="filterGallery(document.getElementById('cur-slug').textContent)">
🖼️ Voir photos
</button>
<a id="cur-export-btn" href="#" class="btn btn-primary btn-sm" download>
⬇️ Export ZIP
</a>
</div>
</div>
</div>
<!-- Filtre galerie -->
<div class="card" id="gallery-filter-card" style="display:none">
<div class="card-title">🖼️ Photos de l'événement : <span id="filter-event-name"></span></div>
<div id="gallery-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(120px,1fr)); gap:.5rem;"></div>
<div style="margin-top:.75rem; display:flex; gap:.5rem; align-items:center;">
<button class="btn btn-ghost btn-sm" onclick="closeGallery()">✕ Fermer</button>
<span id="gallery-count" style="font-size:.82rem; color:var(--text-muted)"></span>
</div>
</div>
<!-- Historique -->
<div class="card">
<div class="card-title">📋 Historique</div>
<div id="history-loading" style="color:var(--text-muted)">Chargement…</div>
<div id="history-list" style="display:none"></div>
</div>
</div>
<script>
async function api(method, url) {
const r = await fetch('/api' + url, { method });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
function showToast(msg, type='info') {
const t = document.createElement('div');
t.className = `toast toast-${type}`;
t.textContent = msg;
document.getElementById('toasts').appendChild(t);
setTimeout(() => t.remove(), 3000);
}
async function loadCurrentEvent() {
try {
const d = await api('GET', '/event');
document.getElementById('current-event-loading').style.display = 'none';
document.getElementById('current-event').style.display = 'block';
document.getElementById('cur-name').textContent = d.name;
document.getElementById('cur-slug').textContent = d.slug;
document.getElementById('cur-started').textContent = d.started_at_iso
? new Date(d.started_at * 1000).toLocaleString('fr-FR')
: '—';
document.getElementById('cur-photos').textContent = d.stats.photos_taken;
document.getElementById('cur-prints').textContent = d.stats.prints_done;
document.getElementById('cur-downloads').textContent = d.stats.downloads;
document.getElementById('cur-export-btn').href = `/api/event/${d.slug}/export`;
} catch(e) {
document.getElementById('current-event-loading').textContent = 'Erreur chargement';
}
}
let _currentSlug = '';
async function loadHistory() {
try {
// Récupère d'abord le slug courant pour l'affichage correct des badges
try { const cur = await api('GET', '/event'); _currentSlug = cur.slug || ''; } catch(e) {}
const d = await api('GET', '/event/history');
document.getElementById('history-loading').style.display = 'none';
const list = document.getElementById('history-list');
list.style.display = 'block';
if (!d.events.length) {
list.innerHTML = '<p style="color:var(--text-muted)">Aucun événement enregistré.</p>';
return;
}
list.innerHTML = d.events.map(ev => {
// "En cours" uniquement si c'est le slug actif ET sans ended_at
const isActive = !ev.ended_at && ev.slug === _currentSlug;
return `
<div class="event-row">
<div>
<div class="event-name">
${ev.name}
<span class="badge ${isActive ? 'badge-active' : 'badge-done'}">
${isActive ? 'En cours' : (ev.ended_at ? 'Terminé' : 'Archivé')}
</span>
</div>
<div class="event-meta">
${ev.started_at_iso || '—'}
${ev.ended_at_iso ? ' → ' + ev.ended_at_iso : ''}
</div>
<div class="stats-row" style="margin-top:.35rem">
<span>📷 <strong>${ev.photos_taken}</strong></span>
<span>🖨️ <strong>${ev.prints_done}</strong></span>
<span>⬇️ <strong>${ev.downloads}</strong></span>
</div>
</div>
<button class="btn btn-ghost btn-sm" onclick="filterGallery('${ev.slug}', '${ev.name}')">
🖼️ Photos
</button>
<a href="/api/event/${ev.slug}/export" class="btn btn-primary btn-sm" download>
⬇️ ZIP
</a>
</div>`;
}).join('');
} catch(e) {
document.getElementById('history-loading').textContent = 'Erreur chargement';
}
}
async function filterGallery(slug, name) {
const card = document.getElementById('gallery-filter-card');
const grid = document.getElementById('gallery-grid');
const countEl = document.getElementById('gallery-count');
document.getElementById('filter-event-name').textContent = name || slug;
card.style.display = 'block';
grid.innerHTML = '<div style="color:var(--text-muted)">Chargement…</div>';
card.scrollIntoView({ behavior: 'smooth' });
try {
const d = await fetch(`/api/gallery/photos?event_slug=${slug}&limit=100`).then(r => r.json());
countEl.textContent = `${d.total} photo(s)`;
if (!d.photos.length) {
grid.innerHTML = '<p style="color:var(--text-muted)">Aucune photo pour cet événement.</p>';
return;
}
grid.innerHTML = d.photos.map(p => `
<a href="${p.full_url}" target="_blank">
<img src="${p.thumb_url}" alt="" style="width:100%;border-radius:6px;object-fit:cover;aspect-ratio:1">
</a>
`).join('');
} catch(e) {
grid.innerHTML = '<p style="color:var(--text-muted)">Erreur chargement photos.</p>';
}
}
function closeGallery() {
document.getElementById('gallery-filter-card').style.display = 'none';
}
loadCurrentEvent();
loadHistory();
</script>
{% endblock %}
+509 -272
View File
@@ -1,79 +1,102 @@
{% extends "base.html" %}
{% block title %}Galerie admin — JH Photomaton{% endblock %}
{% block nav_links %}
<a href="/admin">Dashboard</a>
<a href="/admin/gallery" class="active">Galerie</a>
<a href="/admin/actions">Actions</a>
<a href="/admin/print">Impression</a>
<a href="/admin/settings">Réglages</a>
<a href="/admin/logout">Déconnexion</a>
{% endblock %}
{% block head %}
<style>
/* ── Photo grid ───────────────────────────────────────────────────────────── */
.gallery-wrap { max-width: 1300px; margin: 0 auto; padding: 1rem; }
.gallery-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; }
.gallery-title { font-size: 1.2rem; font-weight: 700; }
.gallery-controls { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.gallery-filters { display: flex; align-items: center; gap: .5rem; margin-bottom: .75rem; flex-wrap: wrap; }
.filter-btn { padding: .3rem .75rem; border-radius: 20px; border: 1px solid var(--border); background: transparent; color: var(--text-muted); cursor: pointer; font-size: .85rem; transition: all .2s; }
.filter-btn.active, .filter-btn:hover { background: var(--primary); border-color: var(--primary); color: #fff; }
.filter-badge { background: rgba(224,123,0,.2); color: #e07b00; padding: .15rem .45rem; border-radius: 10px; font-size: .75rem; font-weight: 700; }
/* ── Layout ───────────────────────────────────────────────────────────────── */
.gallery-wrap { max-width: 100%; margin: 0; padding: 1rem 1.5rem; }
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: .75rem; }
.photo-card {
position: relative;
aspect-ratio: 3/2;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
/* ── Barre de filtres ─────────────────────────────────────────────────────── */
.filter-bar {
background: var(--surface);
border: 2px solid transparent;
border: 1px solid var(--border);
border-radius: 12px;
padding: .85rem 1.1rem;
margin-bottom: 1rem;
display: flex;
flex-wrap: wrap;
gap: .75rem;
align-items: flex-end;
}
.filter-group { display: flex; flex-direction: column; gap: .3rem; }
.filter-label { font-size: .74rem; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.filter-chips { display: flex; gap: .35rem; flex-wrap: wrap; }
.chip {
padding: .28rem .7rem; border-radius: 20px; border: 1px solid var(--border);
background: transparent; color: var(--text-muted); cursor: pointer;
font-size: .82rem; transition: all .15s;
}
.chip.active, .chip:hover { background: var(--primary); border-color: var(--primary); color: #fff; }
.chip.danger.active { background: #7a0000; border-color: #7a0000; }
.filter-select {
background: var(--bg); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: .3rem .6rem; font-size: .85rem;
}
.filter-divider { width: 1px; background: var(--border); align-self: stretch; margin: 0 .2rem; }
.filter-summary { font-size: .82rem; color: var(--text-muted); margin-left: auto; align-self: center; }
/* ── Header ───────────────────────────────────────────────────────────────── */
.gallery-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .75rem; flex-wrap: wrap; }
.gallery-title { font-size: 1.15rem; font-weight: 700; }
.gallery-controls { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.copies-wrap { display: flex; align-items: center; gap: .4rem; font-size: .85rem; color: var(--text-muted); }
.copies-wrap input { width: 52px; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: .3rem .5rem; text-align: center; }
/* ── Grille photos ────────────────────────────────────────────────────────── */
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: .85rem; }
.photo-card {
position: relative; aspect-ratio: 3/2; border-radius: 8px; overflow: hidden;
cursor: pointer; background: var(--surface); border: 2px solid transparent;
transition: border-color .2s, transform .15s;
}
.photo-card:hover { border-color: var(--primary); transform: scale(1.02); }
.photo-card.has-print-request { border-color: #e07b00; }
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-card.has-print { border-color: #e07b00; }
/* Badge demande d'impression */
.print-badge {
position: absolute;
top: 5px; right: 5px;
background: rgba(224,123,0,.9);
color: #fff;
border-radius: 12px;
padding: .15rem .45rem;
font-size: .72rem;
font-weight: 700;
position: absolute; top: 5px; right: 5px;
background: rgba(224,123,0,.9); color: #fff;
border-radius: 12px; padding: .15rem .45rem;
font-size: .72rem; font-weight: 700;
display: flex; align-items: center; gap: .25rem;
backdrop-filter: blur(4px);
pointer-events: none;
}
.print-badge.printing { background: rgba(25,108,176,.9); }
.print-badge.done { background: rgba(30,160,70,.9); }
.print-badge.done-secondary {
position: absolute; top: 5px; left: 5px;
background: rgba(30,160,70,.85); color: #fff;
border-radius: 12px; padding: .15rem .45rem;
font-size: .72rem; font-weight: 700;
display: flex; align-items: center; gap: .2rem;
pointer-events: none;
}
.date-badge {
position: absolute; bottom: 0; left: 0; right: 0;
background: linear-gradient(transparent, rgba(0,0,0,.65));
color: rgba(255,255,255,.85);
font-size: .68rem; padding: .4rem .4rem .3rem;
pointer-events: none; text-align: right;
}
/* Overlay actions au hover */
.card-overlay {
position: absolute; inset: 0;
background: rgba(0,0,0,.55);
position: absolute; inset: 0; background: rgba(0,0,0,.55);
display: flex; align-items: flex-end; justify-content: center;
gap: .4rem; padding: .5rem;
opacity: 0; transition: opacity .2s;
gap: .4rem; padding: .5rem; opacity: 0; transition: opacity .2s;
}
.photo-card:hover .card-overlay { opacity: 1; }
.ov-btn {
padding: .3rem .5rem; border: none; border-radius: 6px;
cursor: pointer; font-size: .8rem; font-weight: 700;
backdrop-filter: blur(4px);
}
.ov-print { background: rgba(224,123,0,.85); color: #fff; }
.ov-cancel { background: rgba(192,0,0,.75); color: #fff; }
.ov-delete { background: rgba(80,80,80,.75); color: #fff; }
.ov-dl { background: rgba(40,40,40,.75); color: #ccc; text-decoration: none; }
.ov-print { background: rgba(224,123,0,.85); color: #fff; }
.ov-cancel { background: rgba(192,0,0,.75); color: #fff; }
.ov-delete { background: rgba(80,80,80,.75); color: #fff; }
.ov-dl { background: rgba(40,40,40,.75); color: #ccc; text-decoration: none; display: inline-block; }
/* Pagination */
.pagination { display: flex; align-items: center; gap: .75rem; justify-content: center; margin: 1rem 0; }
/* ── Pagination ───────────────────────────────────────────────────────────── */
.pagination { display: flex; align-items: center; gap: .75rem; justify-content: center; margin: .75rem 0; }
.pg-btn { padding: .4rem .9rem; border: 1px solid var(--border); background: var(--surface); border-radius: 6px; color: var(--text); cursor: pointer; }
.pg-btn:disabled { opacity: .35; cursor: default; }
.pg-info { color: var(--text-muted); font-size: .9rem; }
@@ -81,45 +104,88 @@
/* ── Lightbox ─────────────────────────────────────────────────────────────── */
.lightbox {
display: none; position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,.88); backdrop-filter: blur(6px);
flex-direction: column; align-items: center; justify-content: center;
gap: 1rem; padding: 1.5rem;
background: rgba(0,0,0,.92); backdrop-filter: blur(8px);
align-items: center; justify-content: center; padding: 1.5rem;
gap: 1rem;
}
.lightbox.open { display: flex; }
.lightbox-img-wrap { position: relative; max-width: 80vw; max-height: 70vh; }
.lightbox-img-wrap img { max-width: 80vw; max-height: 70vh; border-radius: 8px; object-fit: contain; display: block; }
.lb-close { position: absolute; top: -14px; right: -14px; width: 28px; height: 28px; border-radius: 50%; background: rgba(255,255,255,.15); border: none; color: #fff; font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.lb-info { text-align: center; }
.lb-id { font-size: .78rem; color: var(--text-muted); font-family: monospace; }
/* Bloc demandes en attente dans le lightbox */
.lb-print-status {
background: var(--surface); border-radius: 10px; padding: .85rem 1.25rem;
min-width: min(400px, 80vw); border: 1px solid var(--border);
.lb-nav-btn {
flex-shrink: 0; width: 44px; height: 44px; border-radius: 50%;
border: 1px solid rgba(255,255,255,.2); background: rgba(255,255,255,.08);
color: #fff; font-size: 1.3rem; cursor: pointer; display: flex;
align-items: center; justify-content: center; transition: background .15s;
user-select: none;
}
.lb-print-title { font-size: .85rem; font-weight: 700; margin-bottom: .6rem; display: flex; align-items: center; gap: .5rem; }
.lb-nav-btn:hover:not(:disabled) { background: rgba(255,255,255,.18); }
.lb-nav-btn:disabled { opacity: .2; cursor: default; }
.lb-modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: 16px; overflow: hidden;
max-width: min(1100px, calc(95vw - 110px)); width: 100%;
display: flex; flex-direction: column; max-height: 95vh;
}
.lb-counter {
font-size: .75rem; color: var(--text-muted); white-space: nowrap;
}
.lb-header {
display: flex; align-items: center; justify-content: space-between;
padding: .75rem 1.1rem; border-bottom: 1px solid var(--border);
}
.lb-header-info { display: flex; flex-direction: column; gap: .1rem; }
.lb-date { font-weight: 600; font-size: .95rem; }
.lb-id { font-size: .72rem; color: var(--text-muted); font-family: monospace; }
.lb-close-btn {
width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,.08);
border: 1px solid var(--border); color: var(--text); cursor: pointer;
font-size: 1.1rem; display: flex; align-items: center; justify-content: center;
}
.lb-img-wrap {
flex: 1; overflow: hidden; display: flex; align-items: center; justify-content: center;
background: #111; min-height: 200px;
}
.lb-img-wrap img { max-width: 100%; max-height: 72vh; object-fit: contain; display: block; }
/* Bloc demandes impression */
.lb-print-panel {
padding: .6rem 1.1rem; border-top: 1px solid var(--border);
background: rgba(224,123,0,.06); display: none;
}
.lb-print-panel.visible { display: block; }
.lb-print-title { font-size: .82rem; font-weight: 700; color: #e07b00; margin-bottom: .4rem; }
.lb-queue-entry {
display: flex; justify-content: space-between; align-items: center;
padding: .3rem 0; border-bottom: 1px solid rgba(255,255,255,.06);
font-size: .82rem; gap: .75rem;
display: flex; align-items: center; gap: .75rem; padding: .25rem 0;
border-bottom: 1px solid rgba(255,255,255,.05); font-size: .82rem;
}
.lb-queue-status { padding: .15rem .5rem; border-radius: 10px; font-size: .75rem; font-weight: 700; }
.s-pending { background: rgba(224,123,0,.2); color: #e07b00; }
.s-printing { background: rgba(25,108,176,.2); color: var(--primary); }
.lb-queue-entry:last-child { border-bottom: none; }
.s-pending { background: rgba(224,123,0,.2); color: #e07b00; padding: .15rem .45rem; border-radius: 10px; font-size: .75rem; font-weight: 700; }
.s-printing { background: rgba(25,108,176,.2); color: var(--primary); padding: .15rem .45rem; border-radius: 10px; font-size: .75rem; font-weight: 700; }
.lb-actions { display: flex; gap: .6rem; flex-wrap: wrap; justify-content: center; }
.lb-btn { padding: .5rem 1.1rem; border: none; border-radius: 8px; cursor: pointer; font-size: .9rem; font-weight: 600; transition: opacity .2s; }
.lb-btn:hover { opacity: .82; }
.lb-btn-print { background: var(--primary); color: #fff; }
.lb-btn-now { background: #1a7340; color: #fff; }
.lb-btn-cancel { background: rgba(192,0,0,.25); color: #e05050; border: 1px solid rgba(192,0,0,.3); }
.lb-btn-delete { background: #3d1f1f; color: #e05050; border: 1px solid rgba(192,0,0,.2); }
.lb-btn-dl { background: rgba(255,255,255,.08); color: var(--text); text-decoration: none; display: inline-flex; align-items: center; }
/* Actions lightbox */
.lb-actions {
padding: .85rem 1.1rem; border-top: 1px solid var(--border);
display: flex; gap: .6rem; flex-wrap: wrap; align-items: center;
}
.lb-copies-wrap { display: flex; align-items: center; gap: .4rem; font-size: .85rem; color: var(--text-muted); margin-right: auto; }
.lb-copies-wrap input { width: 52px; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: .3rem .5rem; text-align: center; }
/* copies input */
.copies-wrap { display: flex; align-items: center; gap: .4rem; font-size: .85rem; color: var(--text-muted); }
.copies-wrap input { width: 52px; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: .3rem .5rem; text-align: center; }
.lb-action-btn {
display: flex; flex-direction: column; align-items: center; gap: .2rem;
padding: .55rem .85rem; border: none; border-radius: 10px;
cursor: pointer; font-size: .78rem; font-weight: 600; transition: opacity .2s;
text-decoration: none; min-width: 60px;
}
.lb-action-btn:hover { opacity: .82; }
.lb-action-btn .icon { font-size: 1.3rem; line-height: 1; }
.lba-queue { background: rgba(224,123,0,.18); color: #e07b00; border: 1px solid rgba(224,123,0,.3); }
.lba-print { background: rgba(25,108,176,.2); color: var(--primary); border: 1px solid rgba(25,108,176,.3); }
.lba-dl { background: rgba(255,255,255,.07); color: var(--text); border: 1px solid var(--border); }
.lba-cancel { background: rgba(192,0,0,.15); color: #e05050; border: 1px solid rgba(192,0,0,.25); display: none; }
.lba-delete { background: rgba(80,20,20,.4); color: #e05050; border: 1px solid rgba(192,0,0,.2); }
</style>
{% endblock %}
@@ -130,23 +196,52 @@
<div class="gallery-title">🖼 Galerie — Administration</div>
<div class="gallery-controls">
<span class="text-sm text-muted" id="photo-count">Chargement…</span>
<div class="copies-wrap">
<label for="copies-input">Copies :</label>
<input type="number" id="copies-input" min="1" max="3" value="1">
</div>
<button class="pg-btn" onclick="refreshPhotos()"></button>
<button class="pg-btn" onclick="resetFilters()">✕ Réinitialiser</button>
<button class="pg-btn" onclick="loadPhotos(1)"></button>
</div>
</div>
<!-- Filtres -->
<div class="gallery-filters">
<button class="filter-btn active" onclick="setFilter('all', this)">Toutes</button>
<button class="filter-btn" onclick="setFilter('pending', this)">
🖨 À imprimer <span class="filter-badge" id="pending-count">0</span>
</button>
<!-- ── Barre de filtres ──────────────────────────────────────────────────── -->
<div class="filter-bar">
<!-- Filtre statut -->
<div class="filter-group">
<div class="filter-label">Statut</div>
<div class="filter-chips">
<button class="chip active" onclick="setStatusFilter('all', this)">Toutes</button>
<button class="chip danger" onclick="setStatusFilter('pending', this)">
🖨 À imprimer <span id="pending-count" style="font-weight:700">0</span>
</button>
</div>
</div>
<div class="filter-divider"></div>
<!-- Filtre date -->
<div class="filter-group">
<div class="filter-label">Date</div>
<div class="filter-chips">
<button class="chip" onclick="setDateFilter('all', this)">Toutes</button>
<button class="chip" onclick="setDateFilter('today', this)">Aujourd'hui</button>
<button class="chip" onclick="setDateFilter('week', this)">Cette semaine</button>
<button class="chip" onclick="setDateFilter('month', this)">Ce mois</button>
</div>
</div>
<div class="filter-divider"></div>
<!-- Filtre événement -->
<div class="filter-group">
<div class="filter-label">Événement</div>
<select class="filter-select" id="event-filter" onchange="setEventFilter(this.value)">
<option value="">Tous les événements</option>
</select>
</div>
<div class="filter-summary" id="filter-summary"></div>
</div>
<!-- Pagination -->
<!-- Pagination haut -->
<div class="pagination">
<button class="pg-btn" id="prev-btn" onclick="changePage(-1)" disabled>← Préc.</button>
<span class="pg-info">Page <span id="page-cur">1</span> / <span id="page-total">1</span></span>
@@ -166,49 +261,132 @@
</div>
<!-- ── Lightbox ─────────────────────────────────────────────────────────────── -->
<div class="lightbox" id="lightbox">
<div class="lightbox" id="lightbox" onclick="lbBackdropClose(event)">
<button class="lb-nav-btn" id="lb-prev" onclick="lbNavigate(-1);event.stopPropagation()"></button>
<div class="lb-modal">
<!-- En-tête -->
<div class="lb-header">
<div class="lb-header-info">
<div class="lb-date" id="lb-date"></div>
<div class="lb-id" id="lb-id"></div>
</div>
<span class="lb-counter" id="lb-counter"></span>
<button class="lb-close-btn" onclick="closeLightbox()"></button>
</div>
<!-- Image -->
<div class="lb-img-wrap">
<img id="lb-img" src="" alt="">
</div>
<!-- Demandes impression en attente -->
<div class="lb-print-panel" id="lb-print-panel">
<div class="lb-print-title">🖨 Demandes d'impression en attente</div>
<div id="lb-queue-list"></div>
</div>
<!-- Actions -->
<div class="lb-actions">
<div class="lb-copies-wrap">
<label for="lb-copies">Copies :</label>
<input type="number" id="lb-copies" min="1" max="3" value="1">
</div>
<button class="lb-action-btn lba-queue" onclick="lbAddToQueue()">
<span class="icon">📋</span>File d'attente
</button>
<button class="lb-action-btn lba-print" onclick="lbPrintNow()">
<span class="icon">🖨</span>Imprimer
</button>
<a class="lb-action-btn lba-dl" id="lb-btn-dl" href="#">
<span class="icon"></span>Télécharger
</a>
<button class="lb-action-btn lba-cancel" id="lb-btn-cancel" onclick="lbCancelAll()">
<span class="icon"></span>Annuler
</button>
<button class="lb-action-btn lba-delete" onclick="lbDelete()">
<span class="icon">🗑</span>Supprimer
</button>
</div>
<div class="lightbox-img-wrap">
<img id="lb-img" src="" alt="">
<button class="lb-close" onclick="closeLightbox()"></button>
</div>
<div class="lb-info">
<div class="lb-id" id="lb-id"></div>
</div>
<!-- Bloc demandes d'impression en attente -->
<div class="lb-print-status" id="lb-print-status" style="display:none">
<div class="lb-print-title">🖨 Demandes d'impression en attente</div>
<div id="lb-queue-list"></div>
</div>
<!-- Boutons d'action -->
<div class="lb-actions">
<button class="lb-btn lb-btn-print" id="lb-btn-queue" onclick="lbAddToQueue()">
📋 Ajouter à la file
</button>
<button class="lb-btn lb-btn-now" id="lb-btn-now" onclick="lbPrintNow()">
🖨 Imprimer maintenant
</button>
<button class="lb-btn lb-btn-cancel" id="lb-btn-cancel-all" onclick="lbCancelAll()" style="display:none">
✕ Annuler la demande
</button>
<a class="lb-btn lb-btn-dl" id="lb-btn-dl" download>⬇ Télécharger</a>
<button class="lb-btn lb-btn-delete" onclick="lbDelete()">🗑 Supprimer</button>
</div>
<button class="lb-nav-btn" id="lb-next" onclick="lbNavigate(1);event.stopPropagation()"></button>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentPage = 1;
let totalPages = 1;
let currentPhotoId = null;
let currentPage = 1;
let totalPages = 1;
let allPhotos = [];
let displayedPhotos = []; // photos actuellement dans la grille (après filtre statut)
let currentIndex = -1; // index dans displayedPhotos
let currentPhotoId = null;
let currentPhotoData = null;
let allPhotos = [];
let filterMode = 'all';
// Filtres actifs
let statusFilter = 'all';
let dateFilter = 'all';
let eventSlug = '';
// ════════════════════════════════════════════════════════════════════════════
// Filtres
// ════════════════════════════════════════════════════════════════════════════
function setStatusFilter(mode, btn) {
statusFilter = mode;
document.querySelectorAll('.filter-chips .chip').forEach(b => {
if (b.closest('.filter-group') === btn.closest('.filter-group')) b.classList.remove('active');
});
btn.classList.add('active');
loadPhotos(1);
}
function setDateFilter(mode, btn) {
dateFilter = mode;
document.querySelectorAll('.filter-chips .chip').forEach(b => {
if (b.closest('.filter-group') === btn.closest('.filter-group')) b.classList.remove('active');
});
btn.classList.add('active');
loadPhotos(1);
}
function setEventFilter(slug) {
eventSlug = slug;
loadPhotos(1);
}
function resetFilters() {
statusFilter = 'all';
dateFilter = 'all';
eventSlug = '';
document.getElementById('event-filter').value = '';
document.querySelectorAll('.chip').forEach(b => b.classList.remove('active'));
document.querySelector('.chip[onclick*="all"]').classList.add('active');
loadPhotos(1);
}
function _buildParams(page) {
const params = new URLSearchParams({ page, limit: 24 });
if (eventSlug) params.set('event_slug', eventSlug);
if (dateFilter !== 'all') {
const now = Date.now() / 1000;
const sod = new Date(); sod.setHours(0,0,0,0);
const sodTs = sod.getTime() / 1000;
if (dateFilter === 'today') {
params.set('date_from', sodTs);
params.set('date_to', now);
} else if (dateFilter === 'week') {
params.set('date_from', now - 7 * 86400);
params.set('date_to', now);
} else if (dateFilter === 'month') {
params.set('date_from', now - 30 * 86400);
params.set('date_to', now);
}
}
return params.toString();
}
// ════════════════════════════════════════════════════════════════════════════
// Chargement photos
@@ -218,158 +396,199 @@ async function loadPhotos(page = 1) {
grid.innerHTML = '<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">⏳ Chargement…</div>';
try {
const data = await api('GET', `/admin/api/gallery/photos?page=${page}&limit=24`);
allPhotos = data.photos || [];
currentPage = page;
totalPages = data.pages || 1;
const data = await api('GET', `/admin/api/gallery/photos?${_buildParams(page)}`);
allPhotos = data.photos || [];
currentPage = page;
totalPages = data.pages || 1;
// Filtre statut côté client
let photos = allPhotos;
if (statusFilter === 'pending') {
photos = allPhotos.filter(p => p.print_pending > 0 || p.print_printing > 0);
}
document.getElementById('photo-count').textContent =
`${photos.length} / ${data.total} photo(s)`;
document.getElementById('pending-count').textContent =
allPhotos.filter(p => p.print_pending > 0).length;
updatePagination();
document.getElementById('photo-count').textContent = `${data.total} photo(s)`;
// Compteur de demandes en attente
const pendingTotal = allPhotos.filter(p => p.print_pending > 0).length;
document.getElementById('pending-count').textContent = pendingTotal;
renderGrid();
renderGrid(photos);
updateFilterSummary(data.total);
} catch(e) {
grid.innerHTML = '<div style="color:#e05050;grid-column:1/-1;text-align:center;padding:2rem">❌ Erreur de chargement</div>';
}
}
function renderGrid() {
function updateFilterSummary(total) {
const parts = [];
if (dateFilter !== 'all') parts.push({ today: "Aujourd'hui", week: "Cette semaine", month: "Ce mois" }[dateFilter]);
if (eventSlug) parts.push('Événement : ' + (document.getElementById('event-filter').options[document.getElementById('event-filter').selectedIndex]?.text || eventSlug));
document.getElementById('filter-summary').textContent = parts.length ? `Filtre : ${parts.join(' · ')}${total} photo(s)` : '';
}
// ════════════════════════════════════════════════════════════════════════════
// Rendu grille
// ════════════════════════════════════════════════════════════════════════════
function renderGrid(photos) {
displayedPhotos = photos || [];
const grid = document.getElementById('photo-grid');
let photos = allPhotos;
if (filterMode === 'pending') {
photos = allPhotos.filter(p => p.print_pending > 0 || p.print_printing > 0);
}
if (!photos.length) {
grid.innerHTML = filterMode === 'pending'
? '<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">✅ Aucune demande d\'impression en attente</div>'
: '<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">📷 Aucune photo</div>';
if (!displayedPhotos.length) {
grid.innerHTML = `<div style="color:var(--text-muted);grid-column:1/-1;text-align:center;padding:2rem">
📷 Aucune photo pour ces filtres</div>`;
return;
}
grid.innerHTML = photos.map(p => {
const pid = p.photo_id || p.id || p.filename || '';
const hasPending = p.print_pending > 0;
// On utilise l'index (pas le JSON sérialisé) pour éviter les problèmes de quotes
grid.innerHTML = displayedPhotos.map((p, idx) => {
const pid = p.photo_id || p.id || '';
const hasPending = p.print_pending > 0;
const hasPrinting = p.print_printing > 0;
const hasDone = (p.print_done || 0) > 0;
const copies = p.print_done_copies || 0;
let badge = '';
if (hasPrinting) badge = `<div class="print-badge printing">🔵 Impression…</div>`;
else if (hasPending) badge = `<div class="print-badge">🖨 ${p.print_pending} en attente</div>`;
// Badge principal (coin haut droite) — priorité : impression en cours > attente > terminé
const badge = hasPrinting
? `<div class="print-badge printing">🔵 Impression…</div>`
: hasPending
? `<div class="print-badge">🖨 ${p.print_pending} att.</div>`
: hasDone
? `<div class="print-badge done">✅ ${copies} cop.</div>`
: '';
// Si en attente ET déjà imprimé → badge "imprimé" coin haut gauche aussi
const doneBadgeLeft = (hasPending || hasPrinting) && hasDone
? `<div class="print-badge done-secondary">✅ ${copies}</div>`
: '';
const dateLabel = p.date_label
? `<div class="date-badge">${p.date_label}</div>` : '';
return `
<div class="photo-card ${hasPending || hasPrinting ? 'has-print-request' : ''}"
id="card-${pid}"
onclick="openLightbox(${JSON.stringify(p)})">
<div class="photo-card ${hasPending || hasPrinting ? 'has-print' : ''}" id="card-${pid}"
onclick="openLightboxIdx(${idx})">
<img src="${p.thumb_url}" loading="lazy" alt="">
${badge}
${badge}${doneBadgeLeft}${dateLabel}
<div class="card-overlay">
${hasPending
? `<button class="ov-btn ov-cancel" onclick="event.stopPropagation();quickCancel('${pid}')">✕ Annuler</button>`
: `<button class="ov-btn ov-print" onclick="event.stopPropagation();quickQueue('${pid}')">🖨 File</button>`
}
? `<button class="ov-btn ov-cancel" onclick="event.stopPropagation();quickCancel('${pid}')">✕</button>`
: `<button class="ov-btn ov-print" onclick="event.stopPropagation();quickQueue('${pid}')">🖨</button>`}
<a class="ov-btn ov-dl" href="${p.download_url || p.full_url}" onclick="event.stopPropagation()">⬇</a>
<button class="ov-btn ov-delete" onclick="event.stopPropagation();deletePhoto('${pid}')">🗑</button>
<a class="ov-btn ov-dl" href="${p.full_url}" download onclick="event.stopPropagation()">⬇</a>
</div>
</div>
`;
</div>`;
}).join('');
}
function setFilter(mode, btn) {
filterMode = mode;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderGrid();
}
// ════════════════════════════════════════════════════════════════════════════
// Lightbox
// ════════════════════════════════════════════════════════════════════════════
function openLightbox(photo) {
function openLightboxIdx(idx) {
if (idx < 0 || idx >= displayedPhotos.length) return;
currentIndex = idx;
const photo = displayedPhotos[idx];
currentPhotoData = photo;
currentPhotoId = photo.photo_id || photo.id || photo.filename || '';
currentPhotoId = photo.photo_id || photo.id || '';
document.getElementById('lb-img').src = photo.full_url || photo.thumb_url;
document.getElementById('lb-id').textContent = currentPhotoId;
document.getElementById('lb-btn-dl').href = photo.full_url;
document.getElementById('lb-img').src = photo.full_url || photo.thumb_url;
document.getElementById('lb-date').textContent = photo.date_label || '—';
document.getElementById('lb-id').textContent = currentPhotoId;
document.getElementById('lb-btn-dl').href = photo.download_url || photo.full_url;
document.getElementById('lb-counter').textContent = `${idx + 1} / ${displayedPhotos.length}`;
// Mise à jour du bloc demandes
updateLightboxPrintStatus(photo);
// Boutons nav
document.getElementById('lb-prev').disabled = idx <= 0;
document.getElementById('lb-next').disabled = idx >= displayedPhotos.length - 1;
_updateLbPrintPanel(photo);
document.getElementById('lightbox').classList.add('open');
}
function updateLightboxPrintStatus(photo) {
const statusBlock = document.getElementById('lb-print-status');
const queueList = document.getElementById('lb-queue-list');
const btnCancelAll = document.getElementById('lb-btn-cancel-all');
function lbNavigate(dir) {
openLightboxIdx(currentIndex + dir);
}
const requests = photo.print_requests || [];
const activeRequests = requests.filter(r => r.status === 'pending' || r.status === 'printing');
function _updateLbPrintPanel(photo) {
const panel = document.getElementById('lb-print-panel');
const list = document.getElementById('lb-queue-list');
const btnCancel = document.getElementById('lb-btn-cancel');
const active = (photo.print_requests || []).filter(r => ['pending','printing'].includes(r.status));
if (activeRequests.length) {
statusBlock.style.display = 'block';
btnCancelAll.style.display = 'inline-flex';
queueList.innerHTML = activeRequests.map(r => `
if (active.length) {
panel.classList.add('visible');
btnCancel.style.display = 'flex';
list.innerHTML = active.map(r => `
<div class="lb-queue-entry">
<span>${r.copies} copie${r.copies > 1 ? 's' : ''}</span>
<span class="lb-queue-status ${r.status === 'printing' ? 's-printing' : 's-pending'}">
<span class="${r.status === 'printing' ? 's-printing' : 's-pending'}">
${r.status === 'printing' ? '🔵 En cours' : '⏳ En attente'}
</span>
<span style="font-size:.75rem;color:var(--text-muted)">${new Date(r.requested_at * 1000).toLocaleTimeString('fr-FR')}</span>
<span style="font-size:.75rem;color:var(--text-muted);margin-left:auto">
${new Date(r.requested_at * 1000).toLocaleTimeString('fr-FR')}
</span>
${r.status === 'pending'
? `<button class="ov-btn ov-cancel" style="padding:.2rem .5rem;font-size:.75rem"
onclick="cancelOneEntry('${r.id}')">✕</button>`
? `<button class="ov-btn ov-cancel" style="padding:.2rem .5rem;font-size:.75rem" onclick="cancelOneEntry('${r.id}')">✕</button>`
: ''}
</div>
`).join('');
</div>`).join('');
} else {
statusBlock.style.display = 'none';
btnCancelAll.style.display = 'none';
queueList.innerHTML = '';
panel.classList.remove('visible');
btnCancel.style.display = 'none';
list.innerHTML = '';
}
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('open');
currentPhotoId = null;
currentPhotoData = null;
document.getElementById('lb-img').src = '';
currentPhotoId = currentPhotoData = null;
currentIndex = -1;
}
document.getElementById('lightbox').addEventListener('click', e => {
function lbBackdropClose(e) {
if (e.target === document.getElementById('lightbox')) closeLightbox();
}
// Clavier : Échap + flèches
document.addEventListener('keydown', e => {
const lb = document.getElementById('lightbox');
if (!lb.classList.contains('open')) return;
if (e.key === 'Escape') { closeLightbox(); }
else if (e.key === 'ArrowLeft') { lbNavigate(-1); }
else if (e.key === 'ArrowRight') { lbNavigate(1); }
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
// Swipe tactile
let _touchX = 0;
document.getElementById('lightbox').addEventListener('touchstart', e => {
_touchX = e.touches[0].clientX;
}, { passive: true });
document.getElementById('lightbox').addEventListener('touchend', e => {
const dx = e.changedTouches[0].clientX - _touchX;
if (Math.abs(dx) > 50) lbNavigate(dx < 0 ? 1 : -1);
}, { passive: true });
// ════════════════════════════════════════════════════════════════════════════
// Actions impression
// ════════════════════════════════════════════════════════════════════════════
function getCopies() {
return parseInt(document.getElementById('copies-input').value) || 1;
}
function getLbCopies() { return parseInt(document.getElementById('lb-copies').value) || 1; }
async function lbAddToQueue() {
const copies = getCopies();
const copies = getLbCopies();
try {
const r = await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=false`);
await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=false`);
showToast(`📋 Ajouté à la file (${copies} copie${copies > 1 ? 's' : ''})`, 'info');
// Mettre à jour la photo locale et le lightbox
await refreshPhotoData(currentPhotoId);
await _refreshPhotoData(currentPhotoId);
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
}
async function lbPrintNow() {
const copies = getCopies();
if (!confirm(`Imprimer ${copies} copie(s) immédiatement ?`)) return;
const copies = getLbCopies();
if (!confirm(`Imprimer ${copies} copie(s) maintenant ?`)) return;
try {
const r = await api('POST', `/admin/api/gallery/print/${currentPhotoId}?copies=${copies}&immediate=true`);
if (r.success) showToast(`✅ Imprimé sur ${r.printer}`, 'success');
else showToast('❌ ' + (r.error || 'Erreur'), 'error');
await refreshPhotoData(currentPhotoId);
showToast(r.success ? `✅ Imprimé sur ${r.printer}` : `${r.error || 'Erreur'}`, r.success ? 'success' : 'error');
await _refreshPhotoData(currentPhotoId);
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
}
@@ -377,15 +596,15 @@ async function lbCancelAll() {
try {
const r = await api('DELETE', `/admin/api/gallery/print/${currentPhotoId}`);
showToast(`${r.cancelled} demande(s) annulée(s)`, 'info');
await refreshPhotoData(currentPhotoId);
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
await _refreshPhotoData(currentPhotoId);
} catch(e) { showToast('Erreur', 'error'); }
}
async function cancelOneEntry(entryId) {
try {
await api('POST', `/api/print/cancel/${entryId}`);
showToast('Demande annulée', 'info');
await refreshPhotoData(currentPhotoId);
await _refreshPhotoData(currentPhotoId);
} catch(e) { showToast('Erreur', 'error'); }
}
@@ -398,24 +617,22 @@ async function lbDelete() {
closeLightbox();
loadPhotos(currentPage);
} else showToast('❌ Erreur suppression', 'error');
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
} catch(e) { showToast('Erreur', 'error'); }
}
// Actions depuis la grille (sans ouvrir le lightbox)
async function quickQueue(pid) {
const copies = getCopies();
try {
await api('POST', `/admin/api/gallery/print/${pid}?copies=${copies}&immediate=false`);
await api('POST', `/admin/api/gallery/print/${pid}?copies=1&immediate=false`);
showToast('📋 Ajouté à la file', 'info');
await refreshPhotoData(pid);
await _refreshPhotoData(pid);
} catch(e) { showToast('Erreur', 'error'); }
}
async function quickCancel(pid) {
try {
const r = await api('DELETE', `/admin/api/gallery/print/${pid}`);
showToast(`${r.cancelled} demande(s) annulée(s)`, 'info');
await refreshPhotoData(pid);
showToast(`${r.cancelled} annulée(s)`, 'info');
await _refreshPhotoData(pid);
} catch(e) { showToast('Erreur', 'error'); }
}
@@ -430,87 +647,107 @@ async function deletePhoto(pid) {
// ════════════════════════════════════════════════════════════════════════════
// Helpers
// ════════════════════════════════════════════════════════════════════════════
async function refreshPhotoData(pid) {
// Re-fetch le statut des demandes pour cette photo et met à jour l'UI
async function _refreshPhotoData(pid) {
try {
const status = await api('GET', '/admin/api/gallery/print-status');
const count = status[pid] || 0;
const data = await api('GET', `/admin/api/gallery/photos?${_buildParams(currentPage)}`);
allPhotos = data.photos || [];
let photos = allPhotos;
if (statusFilter === 'pending') photos = allPhotos.filter(p => p.print_pending > 0 || p.print_printing > 0);
renderGrid(photos);
document.getElementById('pending-count').textContent = allPhotos.filter(p => p.print_pending > 0).length;
// Met à jour dans allPhotos
const idx = allPhotos.findIndex(p => (p.photo_id || p.id) === pid);
if (idx >= 0) {
allPhotos[idx].print_pending = count;
}
// Si le lightbox est ouvert pour cette photo, re-fetch les détails
// Si lightbox ouverte sur cette photo, mettre à jour le panel impression
if (currentPhotoId === pid) {
const data = await api('GET', `/admin/api/gallery/photos?page=${currentPage}&limit=24`);
const photo = (data.photos || []).find(p => (p.photo_id || p.id) === pid);
if (photo) {
currentPhotoData = photo;
updateLightboxPrintStatus(photo);
const newIdx = displayedPhotos.findIndex(p => (p.photo_id || p.id) === pid);
if (newIdx >= 0) {
currentIndex = newIdx;
currentPhotoData = displayedPhotos[newIdx];
_updateLbPrintPanel(currentPhotoData);
document.getElementById('lb-prev').disabled = newIdx <= 0;
document.getElementById('lb-next').disabled = newIdx >= displayedPhotos.length - 1;
document.getElementById('lb-counter').textContent = `${newIdx + 1} / ${displayedPhotos.length}`;
}
}
// Re-render la grille (met à jour le badge)
renderGrid();
const pendingTotal = allPhotos.filter(p => p.print_pending > 0).length;
document.getElementById('pending-count').textContent = pendingTotal;
} catch(e) {}
}
function updatePagination() {
for (const suffix of ['', '2']) {
const cur = document.getElementById(`page-cur${suffix}`);
const tot = document.getElementById(`page-total${suffix}`);
const prev = document.getElementById(`prev-btn${suffix}`);
const next = document.getElementById(`next-btn${suffix}`);
if (cur) cur.textContent = currentPage;
if (tot) tot.textContent = totalPages;
if (prev) prev.disabled = currentPage <= 1;
if (next) next.disabled = currentPage >= totalPages;
for (const s of ['', '2']) {
const c = document.getElementById(`page-cur${s}`);
const t = document.getElementById(`page-total${s}`);
const p = document.getElementById(`prev-btn${s}`);
const n = document.getElementById(`next-btn${s}`);
if (c) c.textContent = currentPage;
if (t) t.textContent = totalPages;
if (p) p.disabled = currentPage <= 1;
if (n) n.disabled = currentPage >= totalPages;
}
}
function changePage(delta) { loadPhotos(currentPage + delta); }
function refreshPhotos() { loadPhotos(currentPage); }
// ════════════════════════════════════════════════════════════════════════════
// Init — charge les événements pour le filtre
// ════════════════════════════════════════════════════════════════════════════
async function loadEventOptions() {
try {
const data = await api('GET', '/api/event/history');
const events = data.events || data || [];
const sel = document.getElementById('event-filter');
events.forEach(ev => {
const opt = document.createElement('option');
opt.value = ev.slug;
const date = ev.started_at ? new Date(ev.started_at * 1000).toLocaleDateString('fr-FR') : '';
opt.textContent = `${ev.name}${date ? ' — ' + date : ''}`;
sel.appendChild(opt);
});
// Ajouter aussi l'événement en cours s'il n'est pas dans l'historique
try {
const cur = await api('GET', '/api/event');
if (cur.slug && !events.find(e => e.slug === cur.slug)) {
const opt = document.createElement('option');
opt.value = cur.slug;
opt.textContent = `${cur.name} (en cours)`;
sel.insertBefore(opt, sel.children[1]);
}
} catch(e) {}
} catch(e) { console.warn('Impossible de charger les événements:', e); }
}
// ── WebSocket ─────────────────────────────────────────────────────────────────
function onWsMessage(msg) {
if (msg.type === 'photo_deleted') {
loadPhotos(currentPage);
}
if (msg.type === 'photo_deleted') loadPhotos(currentPage);
if (msg.type === 'print_request' && msg.photo_id) {
refreshPhotoData(msg.photo_id);
showToast(`🖨 Demande d'impression reçue`, 'info');
_refreshPhotoData(msg.photo_id);
showToast('🖨 Demande d\'impression reçue', 'info');
}
if (msg.type === 'print_result') {
const ok = msg.result && msg.result.success;
const ok = msg.result?.success;
showToast(ok ? `✅ Impression OK — ${msg.result.printer}` : `${msg.result?.error || 'Erreur'}`, ok ? 'success' : 'error');
if (msg.photo_id) refreshPhotoData(msg.photo_id);
}
if (msg.type === 'print_cancelled_for_photo' && msg.photo_id) {
refreshPhotoData(msg.photo_id);
if (msg.photo_id) _refreshPhotoData(msg.photo_id);
}
}
// Auto-refresh des badges toutes les 20s
// Auto-refresh badges toutes les 30s
setInterval(async () => {
try {
const status = await api('GET', '/admin/api/gallery/print-status');
let changed = false;
allPhotos.forEach(p => {
const pid = p.photo_id || p.id;
const newCount = status[pid] || 0;
if (p.print_pending !== newCount) {
p.print_pending = newCount;
changed = true;
}
const n = status[pid] || 0;
if (p.print_pending !== n) { p.print_pending = n; changed = true; }
});
if (changed) renderGrid();
if (changed) {
let photos = allPhotos;
if (statusFilter === 'pending') photos = allPhotos.filter(p => p.print_pending > 0 || p.print_printing > 0);
renderGrid(photos);
document.getElementById('pending-count').textContent = allPhotos.filter(p => p.print_pending > 0).length;
}
} catch(e) {}
}, 20000);
}, 30000);
loadEventOptions();
loadPhotos(1);
</script>
{% endblock %}
+218 -26
View File
@@ -7,6 +7,7 @@
<a href="/admin/actions">Actions</a>
<a href="/admin/print" class="active">Impression</a>
<a href="/admin/settings">Réglages</a>
<a href="/admin/events">Événements</a>
<a href="/admin/logout">Déconnexion</a>
{% endblock %}
@@ -51,10 +52,13 @@
.printer-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
.btn-xs { padding: .3rem .65rem; font-size: .8rem; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; transition: opacity .2s; }
.btn-xs:hover { opacity: .8; }
.btn-enable { background: #1a7340; color: #fff; }
.btn-disable { background: #784700; color: #fff; }
.btn-clear { background: #7a0000; color: #fff; }
.btn-reject { background: #555; color: #fff; }
.btn-enable { background: #1a7340; color: #fff; }
.btn-disable { background: #784700; color: #fff; }
.btn-clear { background: #7a0000; color: #fff; }
.btn-reject { background: #555; color: #fff; }
.btn-exclude { background: #4a2080; color: #fff; }
.btn-include { background: #205080; color: #fff; }
.bs-excluded { background: rgba(100,40,160,.2); color: #b085f0; border: 1px solid rgba(100,40,160,.3); }
/* Jobs CUPS détaillés */
.cups-jobs { font-size: .82rem; }
@@ -127,14 +131,17 @@
</div>
<div class="printer-states">
<span class="badge-printer bs-{{ p.state }}">
<span class="badge-printer badge-state bs-{{ p.state }}">
{% if p.state == 'idle' %}✅ Disponible
{% elif p.state == 'printing' %}🖨 En impression
{% elif p.state == 'disabled' %}⏸ Désactivée
{% elif p.state == 'disabled' %}⏸ Désactivée CUPS
{% elif p.state == 'offline' %}❌ Hors ligne
{% else %}❓ {{ p.state }}{% endif %}
</span>
<span class="badge-printer {{ 'bs-accept' if p.accepting else 'bs-reject' }}">
{% if p.excluded %}
<span class="badge-printer bs-excluded">🚫 Exclue du load-balancing</span>
{% endif %}
<span class="badge-printer badge-accept {{ 'bs-accept' if p.accepting else 'bs-reject' }}">
{{ '✓ Accepte les jobs' if p.accepting else '✗ Refuse les jobs' }}
</span>
{% if p.jobs_count %}
@@ -142,18 +149,70 @@
{% endif %}
</div>
<!-- Infos URI / modèle -->
<div style="font-size:.8rem;color:var(--text-muted);display:flex;flex-direction:column;gap:.2rem">
{% if p.uri %}<span>🔌 <code style="font-size:.78rem">{{ p.uri }}</code></span>{% endif %}
{% if p.model %}<span>🖨 {{ p.model }}</span>{% endif %}
{% if p.location %}<span>📍 {{ p.location }}</span>{% endif %}
</div>
<!-- Erreurs / raisons CUPS -->
<div class="printer-reasons" style="display:flex;flex-direction:column;gap:.3rem">
{% if p.reasons %}
{% for reason in p.reasons %}
<div style="background:rgba(192,0,0,.12);border:1px solid rgba(192,0,0,.25);border-radius:6px;padding:.3rem .65rem;font-size:.82rem;color:#e05050">
{{ reason }}
</div>
{% endfor %}
{% endif %}
</div>
<!-- Niveaux encre/ruban -->
{% if p.markers %}
<div style="display:flex;flex-direction:column;gap:.35rem">
{% for m in p.markers %}
{% set lvl = m.level | int %}
<div style="font-size:.8rem">
<div style="display:flex;justify-content:space-between;margin-bottom:.15rem">
<span style="color:var(--text-muted)">🎨 {{ m.name }}</span>
<span style="color:{{ '#e05050' if lvl < 10 else '#e07b00' if lvl < 25 else '#4caf50' }}">
{% if lvl < 0 %}N/A{% else %}{{ lvl }}%{% endif %}
</span>
</div>
{% if lvl >= 0 %}
<div style="background:rgba(255,255,255,.1);border-radius:4px;height:5px;overflow:hidden">
<div style="width:{{ [lvl, 100] | min }}%;height:100%;background:{{ '#e05050' if lvl < 10 else '#e07b00' if lvl < 25 else '#4caf50' }};border-radius:4px;transition:width .3s"></div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<!-- Stats impressions -->
<div style="display:flex;gap:.75rem;font-size:.82rem">
<span style="color:#4caf50">✅ {{ p.stats_done or 0 }} imprimé{{ 's' if (p.stats_done or 0) > 1 else '' }}</span>
{% if p.stats_error %}<span style="color:#e05050">❌ {{ p.stats_error }} erreur{{ 's' if p.stats_error > 1 else '' }}</span>{% endif %}
{% if p.stats_cancelled %}<span style="color:#888">✕ {{ p.stats_cancelled }} annulé{{ 's' if p.stats_cancelled > 1 else '' }}</span>{% endif %}
</div>
<!-- Actions imprimante -->
<div class="printer-actions">
{% if p.state == 'disabled' %}
<button class="btn-xs btn-enable" onclick="printerAction('{{ p.name }}','enable')">▶ Activer</button>
<button class="btn-xs btn-enable" onclick="printerAction('{{ p.name }}','enable')">▶ Activer CUPS</button>
{% else %}
<button class="btn-xs btn-disable" onclick="printerAction('{{ p.name }}','disable')">⏸ Désactiver</button>
<button class="btn-xs btn-disable" onclick="printerAction('{{ p.name }}','disable')">⏸ Désactiver CUPS</button>
{% endif %}
{% if p.accepting %}
<button class="btn-xs btn-reject" onclick="printerAction('{{ p.name }}','reject')">🚫 Refuser jobs</button>
{% else %}
<button class="btn-xs btn-enable" onclick="printerAction('{{ p.name }}','enable')">✓ Accepter jobs</button>
{% endif %}
{% if p.excluded %}
<button class="btn-xs btn-include" onclick="setExcluded('{{ p.name }}', false)">✓ Réintégrer</button>
{% else %}
<button class="btn-xs btn-exclude" onclick="setExcluded('{{ p.name }}', true)">🚫 Exclure</button>
{% endif %}
{% if p.jobs_count %}
<button class="btn-xs btn-clear" onclick="clearJobs('{{ p.name }}')">✕ Vider file</button>
{% endif %}
@@ -200,14 +259,59 @@
<label>🖼 File galerie</label>
</label>
</div>
<div class="mode-hint">
<b>Direct</b> : chaque demande est imprimée immédiatement (load-balancing automatique entre les Selphy).
<b>Validation</b> : l'admin doit valider chaque impression via cette page.
<b>File galerie</b> : les demandes s'accumulent sans impression automatique.
<div class="mode-hint" style="display:flex;flex-direction:column;gap:.5rem;margin-top:.5rem">
<div><span style="color:var(--text)">🚀 <b>Direct</b></span> chaque demande d'impression est envoyée immédiatement à l'imprimante disponible. Load-balancing automatique entre les Selphy. Aucune intervention requise.</div>
<div><span style="color:var(--text)"><b>Validation</b></span> — les demandes arrivent en file d'attente. L'admin doit valider (ou refuser) chaque impression depuis cette page avant qu'elle parte à l'imprimante.</div>
<div><span style="color:var(--text)">🖼 <b>File galerie</b></span> les demandes s'accumulent dans la file sans déclenchement automatique. Utile pour imprimer en batch à la fin d'un événement.</div>
</div>
</div>
</div>
<!-- ── IMPRESSION GALERIE UTILISATEUR ───────────────────────────────── -->
<div>
<div class="section-title">👥 Impression galerie utilisateur</div>
<div class="mode-card">
<p class="mode-hint" style="margin-bottom:.85rem">
Autorise (ou non) les visiteurs de la galerie publique à demander l'impression d'une photo,
avec un quota et un nombre de copies maximum par demande.
</p>
<div style="display:flex;flex-direction:column;gap:.75rem">
<label style="display:flex;align-items:center;gap:.65rem;cursor:pointer;font-size:.95rem">
<input type="checkbox" id="uprint-enabled"
{% if config.print.user_print_enabled %}checked{% endif %}
style="accent-color:var(--primary);width:1.1rem;height:1.1rem">
Permettre aux utilisateurs de demander une impression
</label>
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;align-items:flex-end">
<div class="form-group" style="margin:0">
<label class="mode-hint" style="display:block;margin-bottom:.3rem;font-weight:600">
Quota d'impressions par session
</label>
<div style="display:flex;align-items:center;gap:.5rem">
<input type="number" id="uprint-quota" class="copies-input"
value="{{ config.print.user_print_quota }}" min="0" max="99" style="width:70px">
<span class="mode-hint">0 = illimité</span>
</div>
</div>
<div class="form-group" style="margin:0">
<label class="mode-hint" style="display:block;margin-bottom:.3rem;font-weight:600">
Copies max par demande
</label>
<input type="number" id="uprint-max-copies" class="copies-input"
value="{{ config.print.user_print_max_copies }}" min="1" max="10" style="width:70px">
</div>
</div>
</div>
<button class="btn-sm btn-print" style="margin-top:.85rem" onclick="saveUserPrintConfig()">
💾 Sauvegarder
</button>
</div>
</div>
<!-- ── FILE D'ATTENTE ─────────────────────────────────────────────────── -->
<div>
<div class="queue-card">
@@ -260,7 +364,7 @@
</span>
</td>
<td style="font-size:.78rem;color:var(--text-muted)">
{{ q.requested_at|int|timestamp_to_date if q.requested_at else '—' }}
{{ q.requested_at_str }}
</td>
<td style="font-size:.82rem">{{ q.printer or '—' }}</td>
<td style="display:flex;gap:.4rem;align-items:center">
@@ -296,27 +400,93 @@ document.querySelectorAll('input[name="print-mode"]').forEach(r => {
});
// ── Imprimantes ────────────────────────────────────────────────────────────────
const STATE_LABELS = {
idle: '✅ Disponible',
printing: '🖨 En impression',
disabled: '⏸ Désactivée',
offline: '❌ Hors ligne',
error: '❌ Erreur',
unknown: '❓ Inconnu',
};
const STATE_CLASSES = {
idle: 'bs-idle', printing: 'bs-printing', disabled: 'bs-disabled',
offline: 'bs-offline', error: 'bs-offline', unknown: 'bs-unknown',
};
async function refreshPrinters() {
try {
const printers = await api('GET', '/api/print/printers');
printers.forEach(p => {
// Met à jour les badges et compteur de jobs
const card = document.getElementById('pc-' + p.name);
if (!card) return;
// Classe de la carte
card.className = `printer-card state-${p.state}`;
// Actualise les jobs CUPS
// Badge état
const stateBadge = card.querySelector('.badge-state');
if (stateBadge) {
stateBadge.className = `badge-printer badge-state ${STATE_CLASSES[p.state] || 'bs-unknown'}`;
stateBadge.textContent = STATE_LABELS[p.state] || p.state;
}
// Badge accepting
const acceptBadge = card.querySelector('.badge-accept');
if (acceptBadge) {
acceptBadge.className = `badge-printer badge-accept ${p.accepting ? 'bs-accept' : 'bs-reject'}`;
acceptBadge.textContent = p.accepting ? '✓ Accepte les jobs' : '✗ Refuse les jobs';
}
// Badge excluded
const statesDiv = card.querySelector('.printer-states');
if (statesDiv) {
const excBadge = statesDiv.querySelector('.bs-excluded');
if (p.excluded && !excBadge) {
const b = document.createElement('span');
b.className = 'badge-printer bs-excluded';
b.textContent = '🚫 Exclue du load-balancing';
statesDiv.appendChild(b);
} else if (!p.excluded && excBadge) {
excBadge.remove();
}
}
// Boutons actions
const actionsDiv = card.querySelector('.printer-actions');
if (actionsDiv) {
actionsDiv.innerHTML = `
${p.state === 'disabled'
? `<button class="btn-xs btn-enable" onclick="printerAction('${p.name}','enable')">▶ Activer CUPS</button>`
: `<button class="btn-xs btn-disable" onclick="printerAction('${p.name}','disable')">⏸ Désactiver CUPS</button>`}
${p.accepting
? `<button class="btn-xs btn-reject" onclick="printerAction('${p.name}','reject')">🚫 Refuser jobs</button>`
: `<button class="btn-xs btn-enable" onclick="printerAction('${p.name}','enable')">✓ Accepter jobs</button>`}
${p.excluded
? `<button class="btn-xs btn-include" onclick="setExcluded('${p.name}',false)">✓ Réintégrer</button>`
: `<button class="btn-xs btn-exclude" onclick="setExcluded('${p.name}',true)">🚫 Exclure</button>`}
${p.jobs_count ? `<button class="btn-xs btn-clear" onclick="clearJobs('${p.name}')">✕ Vider file</button>` : ''}
`;
}
// Jobs CUPS
const jobsDiv = document.getElementById('jobs-' + p.name);
if (jobsDiv) {
if (p.jobs && p.jobs.length) {
jobsDiv.innerHTML = p.jobs.map(j => `
<div class="cups-job-row">
<span class="cups-job-id">${j.id}</span>
<span class="cups-job-info">${j.user} — ${j.size}</span>
<button class="btn-xs btn-clear" style="padding:.2rem .5rem;font-size:.75rem" onclick="cancelJob('${j.id}')"></button>
</div>`).join('');
} else {
jobsDiv.innerHTML = '<div class="cups-empty">Aucun job en cours</div>';
}
jobsDiv.innerHTML = p.jobs && p.jobs.length
? p.jobs.map(j => `
<div class="cups-job-row">
<span class="cups-job-id">${j.id}</span>
<span class="cups-job-info">${j.user} — ${j.size}</span>
<button class="btn-xs btn-clear" style="padding:.2rem .5rem;font-size:.75rem" onclick="cancelJob('${j.id}')"></button>
</div>`).join('')
: '<div class="cups-empty">Aucun job en cours</div>';
}
// Erreurs/raisons
const reasonsDiv = card.querySelector('.printer-reasons');
if (reasonsDiv) {
reasonsDiv.innerHTML = p.reasons && p.reasons.length
? p.reasons.map(r => `<div style="background:rgba(192,0,0,.12);border:1px solid rgba(192,0,0,.25);border-radius:6px;padding:.3rem .65rem;font-size:.82rem;color:#e05050">${r}</div>`).join('')
: '';
}
});
} catch(err) {}
@@ -330,6 +500,14 @@ async function printerAction(name, action) {
} catch(err) { showToast('Erreur : ' + err.message, 'error'); }
}
async function setExcluded(name, excluded) {
try {
await api('POST', `/api/print/printers/${name}/set-excluded?excluded=${excluded}`);
showToast(excluded ? `🚫 ${name} exclue du load-balancing` : `✓ ${name} réintégrée`, excluded ? 'info' : 'success');
setTimeout(refreshPrinters, 400);
} catch(err) { showToast('Erreur : ' + err.message, 'error'); }
}
async function clearJobs(printer) {
if (!confirm(`Vider toute la file CUPS de ${printer} ?`)) return;
try {
@@ -389,6 +567,19 @@ async function cancelPrint(id) {
} catch(err) { showToast('Erreur', 'error'); }
}
// ── Config impression utilisateur ─────────────────────────────────────────────
async function saveUserPrintConfig() {
const payload = {
user_print_enabled: document.getElementById('uprint-enabled').checked,
user_print_quota: parseInt(document.getElementById('uprint-quota').value) || 0,
user_print_max_copies: parseInt(document.getElementById('uprint-max-copies').value) || 1,
};
try {
await api('POST', '/api/print/user-print-config', payload);
showToast('✅ Configuration sauvegardée', 'success');
} catch(e) { showToast('❌ Erreur : ' + e.message, 'error'); }
}
// ── WebSocket + auto-refresh ───────────────────────────────────────────────────
function onWsMessage(msg) {
if (['print_request','print_result','print_cancelled'].includes(msg.type)) {
@@ -401,3 +592,4 @@ setInterval(refreshQueue, 10000);
setInterval(refreshPrinters, 15000);
</script>
{% endblock %}
+161 -101
View File
@@ -2,14 +2,6 @@
{% block title %}Réglages bouton — JH Photomaton{% endblock %}
{% block nav_links %}
<a href="/admin">Dashboard</a>
<a href="/admin/gallery">Galerie</a>
<a href="/admin/actions">Actions</a>
<a href="/admin/print">Impression</a>
<a href="/admin/settings" class="active">Réglages</a>
<a href="/admin/logout">Déconnexion</a>
{% endblock %}
{% block head %}
<style>
@@ -402,6 +394,16 @@ select.field {
<p id="auto-brightness-status" style="font-size:.78rem; color:var(--text-muted); margin-top:.5rem;">
&#x23F3; Chargement…
</p>
<div style="display:flex; gap:.5rem; align-items:center; margin-top:.5rem; flex-wrap:wrap;">
<button class="btn btn-ghost btn-sm" onclick="measureAmbient()" id="btn-measure-lux">
&#x1F4F7; Tester la mesure
</button>
<span id="lux-status" style="font-size:.8rem; color:var(--text-muted);"></span>
</div>
<div id="lux-info" style="font-size:.78rem; margin-top:.4rem; color:var(--text-muted); display:none;">
Luminosité mesurée : <strong id="lux-val"></strong> / 255 &nbsp;&nbsp;
<span id="lux-desc"></span>
</div>
</div>
</div>
</div>
@@ -464,6 +466,19 @@ select.field {
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,220,120,0.25,2)">Blanc neutre</button>
<button class="btn btn-ghost btn-sm" onclick="setFlashPreset(255,180,40,0.35,2)">Ambre</button>
</div>
<div style="margin-top:1rem; padding-top:.75rem; border-top:1px solid var(--border);">
<label style="font-size:.82rem; color:var(--text-muted)">⏱ Déclencher le flash
<strong id="pre-flash-val">0.5</strong> s avant la fin du countdown</label>
<input type="range" id="pre-flash-advance" min="0" max="3" step="0.1" value="0.5"
oninput="document.getElementById('pre-flash-val').textContent=parseFloat(this.value).toFixed(1)"
style="width:100%; margin-top:.4rem">
<div style="display:flex; justify-content:space-between; font-size:.72rem; color:var(--text-muted)">
<span>0 s (au moment de la capture)</span><span>3 s (très en avance)</span>
</div>
<button class="btn btn-primary btn-sm" style="margin-top:.5rem" onclick="savePreFlash()">&#x1F4BE; Sauvegarder</button>
<span id="pre-flash-status" style="font-size:.82rem; color:var(--text-muted); margin-left:.5rem"></span>
</div>
</div>
</div>
@@ -801,120 +816,165 @@ async function loadBrightnessConfig() {
document.getElementById('toggle-auto-brightness').checked = auto;
document.getElementById('auto-brightness-status').textContent = auto
? '✅ Auto-flash actif — le flash s\'adapte à l\'ambiance'
: '⏸ Auto-flash désactivé — luminosité flash fixe à 100 %';
} catch(e) { console.warn('loadBrightnessConfig:', e); }
: '⏸ Auto-flash désactivé — luminosité fixe';
} catch(e) {
console.warn('brightness config:', e);
}
}
async function previewBrightness() {
const v = parseInt(document.getElementById('brightness-slider').value);
await api('PUT', `/api/leds/brightness?value=${v}&save=false`);
showToast(`Luminosité ${v}/255 appliquée`, 'info');
const val = parseInt(document.getElementById('brightness-slider').value);
document.getElementById('brightness-val').textContent = val;
try {
await api('PUT', `/api/leds/brightness?value=${val}&save=false`);
showToast(`👁 Aperçu luminosité : ${val}`, 'info');
} catch(e) {
showToast('Erreur aperçu : ' + e.message, 'error');
}
}
async function saveBrightness() {
const v = parseInt(document.getElementById('brightness-slider').value);
const status = document.getElementById('brightness-status');
const val = parseInt(document.getElementById('brightness-slider').value);
try {
await api('PUT', `/api/leds/brightness?value=${v}&save=true`);
status.textContent = '✅ Sauvegardé';
showToast(`Luminosité ${v}/255 sauvegardée`, 'success');
await api('PUT', `/api/leds/brightness?value=${val}`);
showToast(`✅ Luminosité ${val} sauvegardée`, 'success');
} catch(e) {
status.textContent = '❌ Erreur';
showToast('Erreur : ' + e.message, 'error');
}
setTimeout(() => { status.textContent = ''; }, 3000);
}
async function previewFlash() {
try {
await api('POST', '/api/leds/effect?effect=capture');
showToast('💡 Flash déclenché', 'info');
} catch(e) {
showToast('Erreur flash : ' + e.message, 'error');
}
}
function setFlashPreset(r, g, b, duration, flashes) {
document.getElementById('flash-r').value = r;
document.getElementById('flash-g').value = g;
document.getElementById('flash-b').value = b;
document.getElementById('flash-duration').value = duration;
document.getElementById('flash-dur-val').textContent = duration.toFixed(2);
document.getElementById('flash-count').value = flashes;
document.getElementById('flash-preview').style.background = `rgb(${r},${g},${b})`;
const hex = '#' + [r,g,b].map(v => v.toString(16).padStart(2,'0')).join('');
document.getElementById('flash-colorpicker').value = hex;
}
async function saveFlash() {
const r = parseInt(document.getElementById('flash-r').value) || 255;
const g = parseInt(document.getElementById('flash-g').value) || 200;
const b = parseInt(document.getElementById('flash-b').value) || 80;
const duration = parseFloat(document.getElementById('flash-duration').value) || 0.3;
const flashes = parseInt(document.getElementById('flash-count').value) || 2;
try {
await api('PUT', `/api/leds/effect/capture?r=${r}&g=${g}&b=${b}&flash_duration=${duration}&flashes=${flashes}`);
showToast('✅ Flash sauvegardé', 'success');
} catch(e) {
showToast('Erreur : ' + e.message, 'error');
}
}
function colorPickerToRgb(hex) {
const r = parseInt(hex.slice(1,3), 16);
const g = parseInt(hex.slice(3,5), 16);
const b = parseInt(hex.slice(5,7), 16);
document.getElementById('flash-r').value = r;
document.getElementById('flash-g').value = g;
document.getElementById('flash-b').value = b;
document.getElementById('flash-preview').style.background = `rgb(${r},${g},${b})`;
}
async function loadFlashConfig() {
try {
const effects = await api('GET', '/api/leds/effects');
const cap = effects.capture;
if (!cap) return;
if (cap.color) {
document.getElementById('flash-r').value = cap.color[0] ?? 255;
document.getElementById('flash-g').value = cap.color[1] ?? 200;
document.getElementById('flash-b').value = cap.color[2] ?? 80;
document.getElementById('flash-preview').style.background =
`rgb(${cap.color[0]},${cap.color[1]},${cap.color[2]})`;
}
if (cap.flash_duration !== undefined) {
document.getElementById('flash-duration').value = cap.flash_duration;
document.getElementById('flash-dur-val').textContent = cap.flash_duration.toFixed(2);
}
if (cap.flashes !== undefined) {
document.getElementById('flash-count').value = cap.flashes;
}
} catch(e) { console.warn('loadFlashConfig:', e); }
// Charger le timing pre-flash
try {
const t = await api('GET', '/api/leds/flash-timing');
const v = t.pre_flash_advance ?? 0.5;
document.getElementById('pre-flash-advance').value = v;
document.getElementById('pre-flash-val').textContent = v.toFixed(1);
} catch(e) { console.warn('loadFlashTiming:', e); }
}
async function savePreFlash() {
const v = parseFloat(document.getElementById('pre-flash-advance').value);
const status = document.getElementById('pre-flash-status');
try {
await api('PUT', `/api/leds/flash-timing?pre_flash_advance=${v}`);
status.textContent = `✅ Sauvegardé (${v.toFixed(1)} s)`;
setTimeout(() => status.textContent = '', 3000);
} catch(e) {
status.textContent = '❌ Erreur';
}
}
async function measureAmbient() {
const btn = document.getElementById('btn-measure-lux');
const status = document.getElementById('lux-status');
const info = document.getElementById('lux-info');
btn.disabled = true;
status.textContent = '⏳ Mesure en cours…';
status.style.color = 'var(--text-muted)';
try {
const r = await api('POST', '/api/leds/measure-ambient');
if (r.ok) {
document.getElementById('lux-val').textContent = r.ambient_lux;
document.getElementById('lux-desc').textContent = r.description;
info.style.display = '';
status.textContent = '✅ Mesure réussie';
status.style.color = '#4caf50';
showToast(`✅ Luminosité : ${r.ambient_lux} — ${r.description}`, 'success');
} else {
status.textContent = '❌ ' + (r.error || 'Erreur inconnue');
status.style.color = '#e05050';
showToast('❌ Mesure impossible : ' + r.error, 'error');
}
} catch(e) {
status.textContent = '❌ Liveview inaccessible';
status.style.color = '#e05050';
showToast('❌ Liveview inaccessible (endpoint 404). Vérifiez que photobooth-app est démarré.', 'error');
} finally {
btn.disabled = false;
setTimeout(() => { status.textContent = ''; }, 6000);
}
}
async function setAutoBrightness(enabled) {
const statusEl = document.getElementById('auto-brightness-status');
try {
await api('PUT', `/api/leds/auto-brightness?enabled=${enabled}&save=true`);
statusEl.textContent = enabled
await api('PUT', `/api/leds/auto-brightness?enabled=${enabled}`);
document.getElementById('auto-brightness-status').textContent = enabled
? '✅ Auto-flash actif — le flash s\'adapte à l\'ambiance'
: '⏸ Auto-flash désactivé — luminosité flash fixe à 100 %';
showToast(enabled ? '✅ Auto-luminosité activée' : '⏸ Auto-luminosité désactivée', 'success');
: '⏸ Auto-flash désactivé — luminosité fixe';
showToast(enabled ? '✅ Auto-flash activé' : '⏸ Auto-flash désactivé', 'success');
} catch(e) {
showToast('Erreur : ' + e.message, 'error');
document.getElementById('toggle-auto-brightness').checked = !enabled;
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
loadBrightnessConfig();
// ════════════════════════════════════════════════════════════════════════════
// Flash LED
// ════════════════════════════════════════════════════════════════════════════
async function loadFlashConfig() {
try {
const effects = await api('GET', '/api/leds/effects');
const cap = effects.capture;
if (!cap) return;
document.getElementById('flash-r').value = cap.color[0] ?? 255;
document.getElementById('flash-g').value = cap.color[1] ?? 200;
document.getElementById('flash-b').value = cap.color[2] ?? 80;
document.getElementById('flash-duration').value = cap.flash_duration ?? 0.30;
document.getElementById('flash-dur-val').textContent = parseFloat(cap.flash_duration ?? 0.30).toFixed(2);
document.getElementById('flash-count').value = cap.flashes ?? 2;
updateFlashPreview();
} catch(e) { console.warn('loadFlashConfig:', e); }
}
function updateFlashPreview() {
const r = parseInt(document.getElementById('flash-r').value) || 0;
const g = parseInt(document.getElementById('flash-g').value) || 0;
const b = parseInt(document.getElementById('flash-b').value) || 0;
document.getElementById('flash-preview').style.background = `rgb(${r},${g},${b})`;
['flash-r','flash-g','flash-b'].forEach(id =>
document.getElementById(id).addEventListener('input', updateFlashPreview, {once:true})
);
}
function colorPickerToRgb(hex) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
document.getElementById('flash-r').value = r;
document.getElementById('flash-g').value = g;
document.getElementById('flash-b').value = b;
updateFlashPreview();
}
function setFlashPreset(r, g, b, dur, flashes) {
document.getElementById('flash-r').value = r;
document.getElementById('flash-g').value = g;
document.getElementById('flash-b').value = b;
document.getElementById('flash-duration').value = dur;
document.getElementById('flash-dur-val').textContent = dur.toFixed(2);
document.getElementById('flash-count').value = flashes;
updateFlashPreview();
}
async function previewFlash() {
await api('POST', '/api/leds/effect?effect=capture');
showToast('Flash test declenche', 'info');
}
async function saveFlash() {
const r = parseInt(document.getElementById('flash-r').value);
const g = parseInt(document.getElementById('flash-g').value);
const b = parseInt(document.getElementById('flash-b').value);
const dur = parseFloat(document.getElementById('flash-duration').value);
const flashes = parseInt(document.getElementById('flash-count').value);
try {
await api('PUT', `/api/leds/effect/capture?r=${r}&g=${g}&b=${b}&flash_duration=${dur}&flashes=${flashes}&save=true`);
showToast('Flash sauvegarde', 'success');
updateFlashPreview();
} catch(e) { showToast('Erreur : ' + e.message, 'error'); }
}
// Liaison input -> preview
['flash-r','flash-g','flash-b'].forEach(id => {
document.getElementById(id).addEventListener('input', updateFlashPreview);
});
loadFlashConfig();
// Init dots
showDots(0);
</script>
{% endblock %}
{% endblock %}
+14 -1
View File
@@ -13,7 +13,20 @@
<nav class="navbar">
<div class="navbar-brand">📷 <span>JH</span> Photomaton</div>
<div class="navbar-links">
{% block nav_links %}{% endblock %}
{% set p = request.url.path %}
{% if p.startswith('/admin') %}
<a href="/admin" class="{{ 'active' if p == '/admin' or p == '/admin/' else '' }}">Dashboard</a>
<a href="/admin/gallery" class="{{ 'active' if p.startswith('/admin/gallery') else '' }}">Galerie</a>
<a href="/admin/actions" class="{{ 'active' if p.startswith('/admin/actions') else '' }}">Actions</a>
<a href="/admin/print" class="{{ 'active' if p.startswith('/admin/print') else '' }}">Impression</a>
<a href="/admin/settings" class="{{ 'active' if p.startswith('/admin/settings') else '' }}">Réglages</a>
<a href="/admin/events" class="{{ 'active' if p.startswith('/admin/events') else '' }}">Événements</a>
<a href="/admin/logout">Déconnexion</a>
{% elif p.startswith('/gallery') or p == '/' %}
<a href="/gallery" class="{{ 'active' if p.startswith('/gallery') else '' }}">📷 Galerie photos</a>
{% else %}
{% block nav_links %}{% endblock %}
{% endif %}
</div>
<div class="navbar-status" id="ws-status">
<span class="dot dot-gray" id="ws-dot"></span>
+265 -18
View File
@@ -1,8 +1,70 @@
{% extends "base.html" %}
{% block title %}Galerie photos — Photomaton LSDW{% endblock %}
{% block nav_links %}
<a href="/gallery" class="active">📷 Galerie photos</a>
{% block head %}
<style>
.pub-print-badge {
position: absolute; top: 6px; left: 6px;
background: rgba(30,160,70,.9); color: #fff;
border-radius: 12px; padding: .15rem .5rem;
font-size: .72rem; font-weight: 700;
pointer-events: none;
}
.photo-card { position: relative; }
/* ── Bouton imprimer sur la card ── */
.photo-card-actions { display: flex; gap: .35rem; flex-wrap: wrap; }
/* ── Bandeau quota ── */
#quota-bar {
display: none;
align-items: center;
gap: .6rem;
background: rgba(25,108,176,.12);
border: 1px solid rgba(25,108,176,.3);
border-radius: 8px;
padding: .45rem .85rem;
font-size: .88rem;
margin-bottom: .75rem;
}
#quota-bar.quota-warn {
background: rgba(224,123,0,.12);
border-color: rgba(224,123,0,.35);
}
#quota-bar.quota-empty {
background: rgba(192,0,0,.1);
border-color: rgba(192,0,0,.3);
}
/* ── Modal copies ── */
.modal-overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,.65);
z-index: 2000;
align-items: center;
justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.5rem;
max-width: 340px;
width: 90%;
display: flex;
flex-direction: column;
gap: 1rem;
}
.modal-title { font-size: 1.05rem; font-weight: 700; }
.modal-preview { width: 100%; border-radius: 8px; object-fit: cover; max-height: 180px; }
.copies-row { display: flex; align-items: center; gap: .75rem; }
.copies-btn { background: var(--bg-muted); border: 1px solid var(--border); color: var(--text);
width: 2rem; height: 2rem; border-radius: 6px; cursor: pointer; font-size: 1.1rem;
display: flex; align-items: center; justify-content: center; }
.copies-val { font-size: 1.2rem; font-weight: 700; min-width: 1.5rem; text-align: center; }
</style>
{% endblock %}
{% block content %}
@@ -18,6 +80,12 @@
Retrouvez vos photos ci-dessous. Scannez le QR code depuis la galerie du photomaton pour télécharger votre photo directement sur votre téléphone.
</p>
<!-- Bandeau quota -->
<div id="quota-bar">
<span id="quota-icon">🖨</span>
<span id="quota-text"></span>
</div>
<!-- Navigation pages -->
<div class="flex items-center gap-2 mb-2">
<button class="btn btn-ghost btn-sm" id="prev-btn" onclick="changePage(-1)" disabled>← Préc.</button>
@@ -35,17 +103,84 @@
<button class="lightbox-close">&times;</button>
<img id="lightbox-img" src="" alt="Photo">
<div class="lightbox-actions">
<a class="btn btn-primary" id="lb-download" download>⬇ Télécharger la photo</a>
<a class="btn btn-primary" id="lb-download">⬇ Télécharger la photo</a>
<button class="btn btn-primary" id="lb-print" style="display:none" onclick="openPrintModal()">🖨 Imprimer</button>
</div>
<div class="text-xs text-muted">Connectez-vous au WiFi Photomaton pour télécharger</div>
</div>
<!-- Modal sélection copies -->
<div class="modal-overlay" id="print-modal" onclick="closePrintModal(event)">
<div class="modal-box" onclick="event.stopPropagation()">
<div class="modal-title">🖨 Demande d'impression</div>
<img id="pm-preview" src="" alt="" class="modal-preview">
<div id="pm-copies-row" class="copies-row" style="display:none">
<span style="flex:1;font-size:.9rem">Nombre de copies</span>
<button class="copies-btn" onclick="changeCopies(-1)"></button>
<span class="copies-val" id="pm-copies">1</span>
<button class="copies-btn" onclick="changeCopies(1)">+</button>
</div>
<p class="text-sm text-muted" id="pm-quota-info" style="margin:0"></p>
<div class="flex gap-1">
<button class="btn btn-primary" onclick="confirmPrint()" id="pm-confirm">🖨 Confirmer</button>
<button class="btn btn-ghost" onclick="closePrintModal()">Annuler</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ── État ──────────────────────────────────────────────────────────────────────
let currentPage = 1;
let totalPages = 1;
let printCfg = { user_print_enabled: false, user_print_quota: 0,
user_print_max_copies: 1, remaining_quota: -1 };
let currentPrintPhotoId = null;
let currentPrintThumb = null;
let currentCopies = 1;
// ── Quota ─────────────────────────────────────────────────────────────────────
async function loadPrintStatus() {
try {
const s = await api('GET', '/api/print/user-print-status');
printCfg = s;
updateQuotaBar();
} catch(e) {}
}
function updateQuotaBar() {
const bar = document.getElementById('quota-bar');
const text = document.getElementById('quota-text');
const icon = document.getElementById('quota-icon');
if (!printCfg.user_print_enabled) { bar.style.display = 'none'; return; }
bar.style.display = 'flex';
bar.className = '';
if (printCfg.user_print_quota === 0) {
// Illimité — pas besoin d'afficher le bandeau
bar.style.display = 'none';
return;
}
const rem = printCfg.remaining_quota;
if (rem <= 0) {
bar.classList.add('quota-empty');
icon.textContent = '🚫';
text.textContent = `Vous avez utilisé votre quota d'impression (${printCfg.user_print_quota} max).`;
} else if (rem === 1) {
bar.classList.add('quota-warn');
icon.textContent = '⚠️';
text.textContent = `Dernière impression disponible pour cette session.`;
} else {
icon.textContent = '🖨';
text.textContent = `${rem} impression(s) restante(s) sur ${printCfg.user_print_quota}.`;
}
}
// ── Photos ────────────────────────────────────────────────────────────────────
async function loadPhotos(page = 1) {
try {
const data = await api('GET', `/api/gallery/photos?page=${page}&limit=24`);
@@ -71,14 +206,30 @@ async function loadPhotos(page = 1) {
return;
}
grid.innerHTML = photos.map(p => `
<div class="photo-card" onclick="openLightbox('${p.full_url}', '${p.download_url}')">
<img src="${p.thumb_url}" loading="lazy" alt="Photo" onerror="this.src='${p.full_url}'">
<div class="photo-card-actions">
<a class="btn btn-primary btn-sm" href="${p.download_url}" download onclick="event.stopPropagation()">⬇ Télécharger</a>
</div>
</div>
`).join('');
const canPrint = printCfg.user_print_enabled &&
(printCfg.user_print_quota === 0 || printCfg.remaining_quota > 0);
grid.innerHTML = photos.map(p => {
const copies = p.print_done_copies || 0;
const printedBadge = copies > 0
? `<div class="pub-print-badge">🖨 ${copies}×</div>`
: '';
const printBtn = printCfg.user_print_enabled
? `<button class="btn btn-sm ${canPrint ? 'btn-ghost' : ''}"
style="${canPrint ? '' : 'opacity:.4;cursor:not-allowed'}"
onclick="event.stopPropagation();${canPrint ? `askPrint('${p.id}','${p.thumb_url}')` : 'showToast(\'Quota atteint\',\'error\')'}"
title="Imprimer cette photo">🖨</button>`
: '';
return `
<div class="photo-card" onclick="openLightbox('${p.full_url}','${p.download_url}','${p.id}','${p.thumb_url}')">
<img src="${p.thumb_url}" loading="lazy" alt="Photo" onerror="this.src='${p.full_url}'">
${printedBadge}
<div class="photo-card-actions">
<a class="btn btn-primary btn-sm" href="${p.download_url}" onclick="event.stopPropagation()">⬇ Télécharger</a>
${printBtn}
</div>
</div>`;
}).join('');
} catch(e) {
document.getElementById('photo-grid').innerHTML = `
<div class="empty-state" style="grid-column:1/-1">
@@ -90,9 +241,22 @@ async function loadPhotos(page = 1) {
function changePage(delta) { loadPhotos(currentPage + delta); }
function openLightbox(fullUrl, downloadUrl) {
// ── Lightbox ──────────────────────────────────────────────────────────────────
function openLightbox(fullUrl, downloadUrl, photoId, thumbUrl) {
document.getElementById('lightbox-img').src = fullUrl;
document.getElementById('lb-download').href = downloadUrl;
const lbPrint = document.getElementById('lb-print');
if (printCfg.user_print_enabled) {
lbPrint.style.display = '';
const canPrint = printCfg.user_print_quota === 0 || printCfg.remaining_quota > 0;
lbPrint.disabled = !canPrint;
lbPrint.title = canPrint ? 'Demander une impression' : 'Quota atteint';
lbPrint.onclick = () => askPrint(photoId, thumbUrl);
} else {
lbPrint.style.display = 'none';
}
document.getElementById('lightbox').classList.add('open');
}
@@ -101,19 +265,102 @@ function closeLightbox(e) {
document.getElementById('lightbox').classList.remove('open');
}
}
document.querySelector('.lightbox-close').addEventListener('click', closeLightbox);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox({}); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeLightbox({}); closePrintModal(); } });
// ── Modal impression ──────────────────────────────────────────────────────────
function askPrint(photoId, thumbUrl) {
currentPrintPhotoId = photoId;
currentPrintThumb = thumbUrl;
currentCopies = 1;
document.getElementById('pm-preview').src = thumbUrl;
document.getElementById('pm-copies').textContent = '1';
// Afficher le sélecteur de copies seulement si max > 1
const copiesRow = document.getElementById('pm-copies-row');
copiesRow.style.display = printCfg.user_print_max_copies > 1 ? 'flex' : 'none';
// Info quota
const info = document.getElementById('pm-quota-info');
if (printCfg.user_print_quota > 0) {
const rem = printCfg.remaining_quota;
info.textContent = `Il vous reste ${rem} impression(s) pour cette session.`;
} else {
info.textContent = '';
}
document.getElementById('print-modal').classList.add('open');
}
function openPrintModal() { /* appelé depuis le bouton lightbox — déjà configuré via askPrint */ }
function closePrintModal(e) {
if (!e || e.target.id === 'print-modal') {
document.getElementById('print-modal').classList.remove('open');
}
}
function changeCopies(delta) {
currentCopies = Math.max(1, Math.min(printCfg.user_print_max_copies, currentCopies + delta));
document.getElementById('pm-copies').textContent = currentCopies;
}
async function confirmPrint() {
if (!currentPrintPhotoId) return;
const btn = document.getElementById('pm-confirm');
btn.disabled = true;
btn.textContent = '⏳ Envoi…';
try {
const r = await api('POST', '/api/print/request-gallery', {
photo_id: currentPrintPhotoId,
copies: currentCopies,
});
// Mettre à jour le quota local
printCfg.remaining_quota = r.remaining_quota;
printCfg.session_print_count = (printCfg.session_print_count || 0) + 1;
updateQuotaBar();
const modeMsg = r.mode === 'direct'
? 'L\'impression va démarrer.'
: 'Votre demande a bien été transmise à l\'admin.';
showToast(`✅ Demande envoyée ! ${modeMsg}`, 'success', 5000);
closePrintModal();
document.getElementById('lightbox').classList.remove('open');
// Recharger les photos pour mettre à jour le badge imprimé
setTimeout(() => loadPhotos(currentPage), 1500);
} catch(e) {
const msg = e.message || 'Erreur';
if (msg.includes('429')) {
showToast('🚫 Quota atteint — vous ne pouvez plus imprimer pour cette session.', 'error', 6000);
printCfg.remaining_quota = 0;
updateQuotaBar();
} else if (msg.includes('403')) {
showToast('L\'impression est désactivée.', 'error');
} else {
showToast('❌ Erreur : ' + msg, 'error');
}
closePrintModal();
} finally {
btn.disabled = false;
btn.textContent = '🖨 Confirmer';
}
}
// ── WebSocket ─────────────────────────────────────────────────────────────────
function onWsMessage(msg) {
// Rafraîchit la galerie quand une nouvelle photo est ajoutée
if (msg.type === 'photobooth_event' && msg.event === 'finished') {
setTimeout(() => loadPhotos(1), 2000);
}
}
loadPhotos(1);
// Auto-refresh toutes les 30 secondes
setInterval(() => loadPhotos(1), 30000);
// ── Init ──────────────────────────────────────────────────────────────────────
(async () => {
await loadPrintStatus();
loadPhotos(1);
})();
setInterval(() => loadPhotos(currentPage), 30000);
</script>
{% endblock %}
+38
View File
@@ -33,6 +33,9 @@ logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
# httpx log tous les appels HTTP en INFO — trop verbeux pour nos probes internes
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).parent
@@ -68,6 +71,9 @@ async def lifespan(app: FastAPI):
button_svc = ButtonService(config, led_svc, photobooth_svc, ws_manager, loop)
button_svc.start()
# Injection du service LED dans le printer_service (pour LED après impression background)
printer_svc.set_led_service(led_svc)
app.state.config = config
app.state.config_service = config_svc
app.state.ws_manager = ws_manager
@@ -79,6 +85,7 @@ async def lifespan(app: FastAPI):
app.state.event_service = event_svc
stats_task = asyncio.create_task(_system_stats_loop(system_svc, ws_manager))
printer_task = asyncio.create_task(_printer_monitor_loop(printer_svc, led_svc, ws_manager))
logger.info("JH Photomaton demarre -- port %d", config.app.port)
led_svc.play("idle")
@@ -86,6 +93,7 @@ async def lifespan(app: FastAPI):
yield
stats_task.cancel()
printer_task.cancel()
button_svc.stop()
led_svc.stop()
await photobooth_svc.close()
@@ -94,6 +102,36 @@ async def lifespan(app: FastAPI):
logger.info("JH Photomaton arrete")
async def _printer_monitor_loop(printer_svc: PrinterService, led_svc: LEDService, ws: WSManager):
"""Surveille les imprimantes toutes les 30s et joue l'effet error si une est en panne."""
# Effets qui ne doivent pas être écrasés par le moniteur
ACTIVE_EFFECTS = {"countdown", "capture", "captured", "printing", "finished"}
await asyncio.sleep(15) # délai initial pour laisser les services démarrer
while True:
try:
statuses = await printer_svc.get_printers_status()
# "offline" = imprimante absente du réseau → normal si non branchée
# On alerte uniquement si l'imprimante est joignable MAIS en panne
# (state=disabled/stopped avec des raisons d'erreur concrètes)
has_error = any(
s.get("state") in ("disabled", "error") and bool(s.get("reasons"))
for s in statuses
)
current = led_svc.current_effect
if current not in ACTIVE_EFFECTS:
if has_error and current != "error":
led_svc.play("error")
await ws.broadcast({"type": "led_effect", "effect": "error"})
logger.info("Moniteur imprimante: erreur détectée → LED error")
elif not has_error and current == "error":
led_svc.play("idle")
await ws.broadcast({"type": "led_effect", "effect": "idle"})
logger.info("Moniteur imprimante: erreur résolue → LED idle")
except Exception as e:
logger.debug("Printer monitor: %s", e)
await asyncio.sleep(30)
async def _system_stats_loop(sys_svc: SystemService, ws: WSManager):
while True:
try:
+1 -1
View File
@@ -503,7 +503,7 @@
"backends": {
"enable_livestream": true,
"retry_capture": 3,
"countdown_camera_capture_offset": 0.2,
"countdown_camera_capture_offset": 0.5,
"index_backend_stills": 0,
"index_backend_video": 0,
"index_backend_multicam": 0,
+760
View File
@@ -0,0 +1,760 @@
2026-04-27 22:07:57,1845,1002,622,87,373,842
2026-04-27 22:07:59,1845,1009,615,95,381,835
2026-04-27 22:08:01,1845,1010,614,90,377,835
2026-04-27 22:08:03,1845,1018,606,98,385,827
2026-04-27 22:08:05,1845,1012,613,94,381,833
2026-04-27 22:08:07,1845,986,638,69,356,858
2026-04-27 22:08:09,1845,985,640,65,352,860
2026-04-27 22:08:12,1845,984,640,65,352,860
2026-04-27 22:08:14,1845,990,634,73,360,854
2026-04-27 22:08:16,1845,986,638,69,356,859
2026-04-27 22:08:18,1845,995,629,77,363,849
2026-04-27 22:08:20,1845,995,629,72,359,849
2026-04-27 22:08:22,1845,988,634,62,351,857
2026-04-27 22:08:24,1845,988,633,62,351,856
2026-04-27 22:08:27,1845,992,630,62,351,853
2026-04-27 22:08:29,1845,1008,614,74,363,837
2026-04-27 22:08:31,1845,1008,613,70,359,837
2026-04-27 22:08:33,1845,1013,609,78,367,832
2026-04-27 22:08:35,1845,1013,608,74,363,832
2026-04-27 22:08:37,1845,999,622,61,351,846
2026-04-27 22:08:40,1845,995,627,59,348,850
2026-04-27 22:08:42,1845,771,841,59,358,1073
2026-04-27 22:08:44,1845,795,757,66,425,1049
2026-04-27 22:08:46,1845,852,691,62,430,992
2026-04-27 22:30:21,1845,1008,649,51,305,836
2026-04-27 22:30:23,1845,1014,642,59,314,830
2026-04-27 22:30:25,1845,1017,640,55,310,828
2026-04-27 22:30:27,1845,1033,613,77,342,812
2026-04-27 22:30:29,1845,1047,599,83,348,798
2026-04-27 22:30:32,1845,1061,585,90,355,784
2026-04-27 22:30:34,1845,1081,565,98,363,764
2026-04-27 22:30:36,1845,1093,553,100,365,751
2026-04-27 22:30:38,1845,1106,540,107,372,738
2026-04-27 22:30:40,1845,1123,523,115,380,722
2026-04-27 22:30:42,1845,1141,505,122,387,703
2026-04-27 22:30:45,1845,1160,486,129,394,685
2026-04-27 22:30:47,1845,1179,467,136,401,666
2026-04-27 22:30:49,1845,1187,459,135,400,657
2026-04-27 22:30:51,1845,1203,443,143,408,642
2026-04-27 22:30:53,1845,1218,428,150,415,627
2026-04-27 22:30:55,1845,1239,407,158,423,606
2026-04-27 22:30:57,1845,1254,392,165,430,591
2026-04-27 22:31:00,1845,1284,374,172,437,561
2026-04-27 22:31:02,1845,1310,352,180,445,534
2026-04-27 22:31:04,1845,1326,336,188,453,519
2026-04-27 22:31:06,1845,1342,320,197,462,502
2026-04-27 22:31:08,1845,1361,301,203,469,483
2026-04-27 22:31:10,1845,1378,284,211,476,467
2026-04-27 22:31:12,1845,1396,266,218,484,448
2026-04-27 22:31:15,1845,1413,249,226,492,432
2026-04-27 22:31:17,1845,1433,229,234,499,412
2026-04-27 22:31:19,1845,1443,219,234,499,402
2026-04-27 22:31:21,1845,1461,201,241,506,384
2026-04-27 22:31:23,1845,1486,176,254,520,359
2026-04-27 22:31:25,1845,1501,160,258,523,343
2026-04-27 22:31:27,1845,1516,150,261,526,329
2026-04-27 22:31:30,1845,1486,183,216,482,359
2026-04-27 22:31:32,1845,1468,201,186,452,376
2026-04-27 22:31:34,1845,1485,187,194,460,359
2026-04-27 22:31:36,1845,1505,167,203,469,339
2026-04-27 22:31:38,1845,1521,151,210,476,324
2026-04-27 22:31:40,1845,1535,137,216,481,310
2026-04-27 22:31:42,1845,1544,116,225,487,301
2026-04-27 22:31:45,1845,1538,129,232,476,307
2026-04-27 22:31:47,1845,1550,117,240,484,295
2026-04-27 22:31:49,1845,1594,165,246,433,250
2026-04-27 22:31:51,1845,1548,173,252,441,297
2026-04-27 22:31:53,1845,1553,182,259,434,291
2026-04-27 22:31:56,1845,1608,163,262,438,236
2026-04-27 22:31:58,1845,1622,148,261,438,223
2026-04-27 22:32:00,1845,1546,200,215,380,298
2026-04-27 22:32:02,1845,1529,217,184,349,316
2026-04-27 22:32:04,1845,1579,180,193,364,265
2026-04-27 22:32:06,1845,1597,162,201,372,248
2026-04-27 22:32:09,1845,1617,141,209,380,227
2026-04-27 22:32:11,1845,1641,117,217,389,204
2026-04-27 22:32:13,1845,1649,109,224,396,196
2026-04-27 22:32:15,1845,1668,90,232,404,177
2026-04-27 22:32:17,1845,1683,75,239,411,162
2026-04-27 22:32:19,1845,1673,85,246,419,172
2026-04-27 22:32:22,1845,1680,78,253,426,165
2026-04-27 22:32:24,1845,1678,74,263,437,166
2026-04-27 22:32:26,1845,1634,112,265,429,210
2026-04-27 22:32:28,1845,1662,103,265,429,182
2026-04-27 22:32:31,1845,1626,147,219,373,219
2026-04-27 22:32:33,1845,1610,163,189,343,235
2026-04-27 22:32:35,1845,1623,150,196,351,222
2026-04-27 22:32:37,1845,1648,125,204,358,197
2026-04-27 22:32:39,1845,1657,116,212,366,188
2026-04-27 22:32:41,1845,1676,96,219,374,168
2026-04-27 22:32:43,1845,1679,67,231,396,165
2026-04-27 22:32:46,1845,1693,95,239,363,151
2026-04-27 22:32:48,1845,1724,91,247,362,120
2026-04-27 22:32:50,1845,1720,95,253,369,125
2026-04-27 22:32:52,1845,1701,95,261,377,144
2026-04-27 22:32:54,1845,1699,96,269,385,145
2026-04-27 22:32:56,1845,1714,102,269,382,131
2026-04-27 22:32:59,1845,1707,107,268,383,137
2026-04-27 22:33:01,1845,1654,142,222,337,191
2026-04-27 22:33:03,1845,1674,140,202,318,171
2026-04-27 22:33:05,1845,1685,129,209,325,159
2026-04-27 22:33:07,1845,1703,111,217,332,141
2026-04-27 22:33:09,1845,1716,98,224,340,128
2026-04-27 22:33:12,1845,1732,81,232,349,112
2026-04-27 22:33:14,1845,1743,61,238,365,101
2026-04-27 22:33:16,1845,1711,68,251,383,133
2026-04-27 22:33:19,1845,1713,44,258,412,131
2026-04-27 22:33:21,1845,1731,26,266,420,113
2026-04-27 22:33:23,1845,1692,98,264,418,153
2026-04-27 22:33:26,1845,1272,461,272,483,573
2026-04-27 22:33:28,1845,1259,472,262,476,586
2026-04-27 22:33:30,1845,1102,631,113,327,742
2026-04-27 22:33:32,1845,1023,711,42,256,822
2026-04-27 22:33:34,1845,1027,696,42,266,818
2026-04-27 22:33:37,1845,1028,695,42,266,816
2026-04-27 22:33:39,1845,1032,691,47,272,813
2026-04-27 22:33:41,1845,1033,690,45,270,812
2026-04-27 22:33:43,1845,1030,692,45,270,815
2026-04-27 22:33:45,1845,1037,686,53,278,808
2026-04-27 22:33:47,1845,1033,690,49,274,812
2026-04-27 22:33:49,1845,1040,683,57,282,805
2026-04-27 22:33:52,1845,1038,684,53,278,807
2026-04-27 22:33:54,1845,1040,682,60,285,805
2026-04-27 22:33:56,1845,1071,651,79,304,773
2026-04-27 22:33:58,1845,1072,650,79,304,772
2026-04-27 22:34:00,1845,1059,663,65,291,786
2026-04-27 22:34:02,1845,1059,663,65,291,785
2026-04-27 22:34:04,1845,1066,656,73,299,779
2026-04-27 22:34:07,1845,1099,621,69,296,745
2026-04-27 22:34:09,1845,1102,619,76,304,743
2026-04-27 22:34:11,1845,1102,619,72,299,743
2026-04-27 22:34:13,1845,1101,619,72,299,743
2026-04-27 22:34:15,1845,1103,617,80,307,741
2026-04-27 22:34:17,1845,1104,616,76,303,741
2026-04-27 22:34:20,1845,1109,609,83,313,736
2026-04-27 22:34:22,1845,1106,611,79,309,739
2026-04-27 22:34:24,1845,1114,603,87,317,730
2026-04-27 22:34:26,1845,1110,607,83,313,734
2026-04-27 22:34:28,1845,1110,607,83,313,735
2026-04-27 22:34:30,1845,1091,626,68,299,754
2026-04-27 22:34:32,1845,1092,624,64,295,752
2026-04-27 22:34:35,1845,1094,623,71,302,751
2026-04-27 22:34:37,1845,1097,620,67,298,748
2026-04-27 22:34:39,1845,1133,583,93,324,711
2026-04-27 22:34:41,1845,1132,585,87,318,713
2026-04-27 22:34:43,1845,1128,589,87,318,717
2026-04-27 22:34:45,1845,1111,605,77,309,733
2026-04-27 22:34:47,1845,1109,607,77,308,736
2026-04-27 22:34:50,1845,1107,610,75,306,738
2026-04-27 22:34:52,1845,1106,611,75,306,739
2026-04-27 22:34:54,1845,1113,603,85,317,732
2026-04-27 22:34:56,1845,1114,602,81,312,731
2026-04-27 22:34:58,1845,1112,605,81,312,733
2026-04-27 22:35:01,1845,1101,614,72,304,744
2026-04-27 22:35:03,1845,1101,614,68,300,744
2026-04-27 22:35:05,1845,1111,604,76,308,734
2026-04-27 22:35:07,1845,1106,609,72,304,739
2026-04-27 22:35:09,1845,1110,605,78,311,734
2026-04-27 22:35:11,1845,1104,612,74,306,741
2026-04-27 22:35:13,1845,1103,612,74,306,741
2026-04-27 22:35:15,1845,1114,580,83,337,731
2026-04-27 22:35:18,1845,1117,574,79,335,727
2026-04-27 22:35:20,1845,1152,538,106,363,692
2026-04-27 22:35:22,1845,1148,543,98,355,697
2026-04-27 22:35:24,1845,1166,525,116,372,679
2026-04-27 22:35:26,1845,1157,534,110,366,687
2026-04-27 22:35:28,1845,1156,531,110,370,688
2026-04-27 22:35:30,1845,1136,551,88,349,708
2026-04-27 22:35:32,1845,1132,555,84,345,713
2026-04-27 22:35:35,1845,1167,520,112,372,678
2026-04-27 22:35:37,1845,1159,528,103,364,686
2026-04-27 22:35:39,1845,1131,556,80,340,714
2026-04-27 22:35:41,1845,1127,560,79,340,718
2026-04-27 22:35:43,1845,1124,563,79,340,720
2026-04-27 22:35:45,1845,1118,569,77,337,727
2026-04-27 22:35:47,1845,1115,571,77,338,729
2026-04-27 22:35:50,1845,1121,565,87,348,723
2026-04-27 22:35:52,1845,1123,563,85,346,721
2026-04-27 22:35:54,1845,1129,557,92,353,715
2026-04-27 22:35:56,1845,1125,561,88,349,719
2026-04-27 22:35:58,1845,1124,563,88,349,721
2026-04-27 22:36:00,1845,1127,559,83,344,717
2026-04-27 22:36:02,1845,1123,564,77,338,722
2026-04-27 22:36:05,1845,1117,569,72,333,727
2026-04-27 22:36:07,1845,1070,616,70,331,774
2026-04-27 22:36:09,1845,1062,625,67,328,783
2026-04-27 22:36:11,1845,1059,628,67,328,786
2026-04-27 22:36:13,1845,1054,632,67,328,790
2026-04-27 22:36:15,1845,1053,633,67,328,791
2026-04-27 22:36:17,1845,1052,634,67,328,792
2026-04-27 22:36:20,1845,1056,630,70,331,789
2026-04-27 22:36:22,1845,1055,631,70,331,789
2026-04-27 22:36:24,1845,1098,588,102,364,747
2026-04-27 22:36:26,1845,1093,593,95,357,752
2026-04-27 22:36:28,1845,1093,592,95,357,751
2026-04-27 22:36:30,1845,1104,581,89,351,740
2026-04-27 22:36:32,1845,1093,592,83,345,752
2026-04-27 22:36:35,1845,1087,598,83,345,758
2026-04-27 22:36:37,1845,1080,606,79,341,765
2026-04-27 22:36:39,1845,1079,606,87,349,766
2026-04-27 22:36:41,1845,1075,610,83,345,770
2026-04-27 22:36:43,1845,1072,613,83,345,772
2026-04-27 22:36:45,1845,1070,613,85,350,775
2026-04-27 22:36:47,1845,1071,612,83,347,774
2026-04-27 22:36:50,1845,1064,619,80,344,780
2026-04-27 22:36:52,1845,1063,620,76,340,781
2026-04-27 22:36:54,1845,1067,616,79,343,778
2026-04-27 22:36:56,1845,1065,618,77,341,779
2026-04-27 22:36:58,1845,1062,621,77,341,782
2026-04-27 22:37:00,1845,1094,589,93,358,750
2026-04-27 22:37:03,1845,1083,600,85,350,762
2026-04-27 22:37:05,1845,1069,614,76,340,776
2026-04-27 22:37:07,1845,1068,616,76,340,777
2026-04-27 22:37:09,1845,1108,571,103,371,736
2026-04-27 22:37:11,1845,1093,587,95,363,752
2026-04-27 22:37:13,1845,1092,587,95,363,752
2026-04-27 22:37:15,1845,1080,599,86,353,764
2026-04-27 22:37:18,1845,1080,600,85,353,764
2026-04-27 22:37:20,1845,1077,602,83,351,767
2026-04-27 22:37:22,1845,1075,604,83,351,769
2026-04-27 22:37:24,1845,1074,606,83,351,771
2026-04-27 22:37:26,1845,1074,606,83,351,771
2026-04-27 22:37:28,1845,1073,606,83,351,771
2026-04-27 22:37:30,1845,1077,603,90,358,768
2026-04-27 22:37:33,1845,1078,602,86,354,767
2026-04-27 22:37:35,1845,1063,617,75,343,782
2026-04-27 22:37:37,1845,1060,619,71,339,784
2026-04-27 22:37:39,1845,1066,613,79,347,778
2026-04-27 22:37:41,1845,1074,591,75,358,771
2026-04-27 22:37:43,1845,1077,583,75,362,767
2026-04-27 22:37:45,1845,1087,573,83,371,758
2026-04-27 22:37:48,1845,1081,579,79,366,764
2026-04-27 22:37:50,1845,1082,578,79,367,763
2026-04-27 22:37:52,1845,1081,578,75,363,763
2026-04-27 22:37:54,1845,1129,530,103,390,715
2026-04-27 22:37:56,1845,1127,532,96,384,717
2026-04-27 22:37:58,1845,1117,543,96,384,728
2026-04-27 22:38:00,1845,1089,570,77,365,755
2026-04-27 22:38:03,1845,1086,573,73,361,759
2026-04-27 22:38:05,1845,1088,572,81,368,757
2026-04-27 22:38:07,1845,1084,576,76,364,761
2026-04-27 22:38:09,1845,1143,517,111,399,702
2026-04-27 22:38:11,1845,1133,526,103,391,711
2026-04-27 22:38:13,1845,1132,527,103,391,712
2026-04-27 22:38:15,1845,1115,545,93,381,730
2026-04-27 22:38:18,1845,1109,550,93,381,735
2026-04-27 22:38:20,1845,1114,544,92,382,731
2026-04-27 22:38:22,1845,1105,553,87,376,739
2026-04-27 22:38:24,1845,1100,558,87,376,745
2026-04-27 22:38:26,1845,1097,561,85,375,748
2026-04-27 22:38:28,1845,1096,561,85,375,748
2026-04-27 22:38:30,1845,1112,546,97,387,733
2026-04-27 22:38:32,1845,1111,547,93,383,734
2026-04-27 22:38:35,1845,1093,564,80,369,751
2026-04-27 22:38:37,1845,1090,567,76,365,754
2026-04-27 22:38:39,1845,1121,537,97,387,724
2026-04-27 22:38:41,1845,1122,536,91,381,723
2026-04-27 22:38:43,1845,1119,538,91,381,725
2026-04-27 22:38:45,1845,1157,500,110,400,687
2026-04-27 22:38:48,1845,1144,513,102,392,700
2026-04-27 22:38:50,1845,1112,546,88,378,733
2026-04-27 22:38:52,1845,1109,549,84,374,736
2026-04-27 22:38:54,1845,1136,521,111,401,708
2026-04-27 22:38:56,1845,1137,521,105,395,708
2026-04-27 22:38:58,1845,1132,525,105,395,712
2026-04-27 22:39:00,1845,1110,544,87,380,735
2026-04-27 22:39:03,1845,1111,523,83,396,734
2026-04-27 22:39:05,1845,1115,518,91,404,729
2026-04-27 22:39:07,1845,1114,520,87,400,731
2026-04-27 22:39:09,1845,1123,510,95,409,721
2026-04-27 22:39:11,1845,1117,517,91,405,727
2026-04-27 22:39:13,1845,1115,519,91,405,730
2026-04-27 22:39:15,1845,1109,525,98,412,736
2026-04-27 22:39:18,1845,1104,529,94,408,740
2026-04-27 22:39:20,1845,1107,527,102,416,738
2026-04-27 22:39:22,1845,1100,534,98,411,745
2026-04-27 22:39:24,1845,1136,498,119,433,709
2026-04-27 22:39:26,1845,1135,498,113,427,709
2026-04-27 22:39:28,1845,1135,498,113,427,710
2026-04-27 22:39:30,1845,1085,549,70,383,760
2026-04-27 22:39:33,1845,1082,551,69,383,762
2026-04-27 22:39:35,1845,1087,547,77,391,758
2026-04-27 22:39:37,1845,1083,550,73,386,761
2026-04-27 22:39:39,1845,1080,554,73,387,765
2026-04-27 22:39:41,1845,1079,555,73,386,766
2026-04-27 22:39:43,1845,1079,554,73,386,766
2026-04-27 22:39:45,1845,1111,522,94,408,733
2026-04-27 22:39:48,1845,1111,523,88,402,734
2026-04-27 22:39:50,1845,1102,531,83,397,743
2026-04-27 22:39:52,1845,1101,524,79,401,743
2026-04-27 22:39:54,1845,1120,494,86,419,724
2026-04-27 22:39:56,1845,1121,493,82,415,723
2026-04-27 22:39:58,1845,1120,494,82,416,725
2026-04-27 22:40:00,1845,1100,513,64,399,745
2026-04-27 22:40:03,1845,1097,516,60,395,748
2026-04-27 22:40:05,1845,1100,513,67,402,744
2026-04-27 22:40:07,1845,1097,516,63,398,747
2026-04-27 22:40:09,1845,1100,513,67,401,745
2026-04-27 22:40:11,1845,1100,513,64,399,745
2026-04-27 22:40:13,1845,1098,514,64,399,746
2026-04-27 22:40:15,1845,1108,504,72,408,736
2026-04-27 22:40:17,1845,1106,505,68,405,738
2026-04-27 22:40:20,1845,1106,505,68,405,738
2026-04-27 22:40:22,1845,1107,504,68,405,738
2026-04-27 22:40:24,1845,1152,458,102,439,692
2026-04-27 22:40:26,1845,1143,468,94,431,702
2026-04-27 22:40:28,1845,1142,469,94,431,703
2026-04-27 22:40:30,1845,1118,492,74,411,726
2026-04-27 22:40:32,1845,1118,492,70,407,727
2026-04-27 22:40:35,1845,1134,460,84,437,710
2026-04-27 22:40:37,1845,1136,458,83,436,709
2026-04-27 22:40:39,1845,1142,452,89,442,703
2026-04-27 22:40:41,1845,1136,452,82,441,709
2026-04-27 22:40:43,1845,1136,452,82,441,708
2026-04-27 22:40:46,1845,1130,458,79,438,715
2026-04-27 22:40:48,1845,1128,460,78,438,717
2026-04-27 22:40:50,1845,1130,458,78,438,714
2026-04-27 22:40:52,1845,1131,457,74,434,713
2026-04-27 22:40:54,1845,1136,452,79,439,708
2026-04-27 22:40:56,1845,1134,454,79,438,710
2026-04-27 22:40:58,1845,1134,453,78,438,710
2026-04-27 22:41:01,1845,1154,434,87,447,691
2026-04-27 22:41:03,1845,1130,458,69,429,715
2026-04-27 22:41:05,1845,1136,452,77,437,709
2026-04-27 22:41:07,1845,1129,459,68,428,716
2026-04-27 22:41:09,1845,1135,452,78,438,709
2026-04-27 22:41:11,1845,1131,456,74,434,713
2026-04-27 22:41:13,1845,1129,459,74,434,716
2026-04-27 22:41:16,1845,1128,459,73,433,716
2026-04-27 22:41:18,1845,1127,460,73,433,717
2026-04-27 22:41:20,1845,1124,463,71,431,720
2026-04-27 22:41:22,1845,1123,465,71,431,722
2026-04-27 22:41:24,1845,1136,451,82,442,708
2026-04-27 22:41:26,1845,1134,453,77,438,711
2026-04-27 22:41:28,1845,1134,453,77,438,710
2026-04-27 22:41:31,1845,1114,474,64,424,731
2026-04-27 22:41:33,1845,1115,473,64,424,730
2026-04-27 22:41:35,1845,1112,475,63,423,732
2026-04-27 22:41:37,1845,1113,474,63,423,731
2026-04-27 22:41:39,1845,1142,445,85,445,703
2026-04-27 22:41:41,1845,1138,449,79,439,706
2026-04-27 22:41:43,1845,1137,450,79,439,707
2026-04-27 22:41:45,1845,1142,445,79,439,702
2026-04-27 22:41:48,1845,1135,452,75,435,709
2026-04-27 22:41:50,1845,1131,457,78,438,714
2026-04-27 22:41:52,1845,1131,457,73,434,714
2026-04-27 22:41:54,1845,1131,456,81,441,713
2026-04-27 22:41:56,1845,1128,459,77,437,716
2026-04-27 22:41:58,1845,1129,458,77,437,715
2026-04-27 22:42:00,1845,1123,464,69,429,722
2026-04-27 22:42:02,1845,1117,470,65,425,728
2026-04-27 22:42:05,1845,1124,463,70,431,720
2026-04-27 22:42:07,1845,1122,465,68,429,722
2026-04-27 22:42:09,1845,1130,458,76,436,715
2026-04-27 22:42:11,1845,1127,460,72,432,717
2026-04-27 22:42:13,1845,1126,461,72,432,719
2026-04-27 22:42:15,1845,1135,452,80,440,709
2026-04-27 22:42:18,1845,1134,453,76,436,711
2026-04-27 22:42:20,1845,1172,415,109,470,672
2026-04-27 22:42:22,1845,1171,416,101,462,673
2026-04-27 22:42:24,1845,1166,421,100,460,679
2026-04-27 22:42:26,1845,1160,427,96,456,684
2026-04-27 22:42:28,1845,1158,429,96,456,686
2026-04-27 22:42:30,1845,1137,450,81,442,708
2026-04-27 22:42:33,1845,1132,455,77,438,712
2026-04-27 22:42:35,1845,1167,420,104,465,678
2026-04-27 22:42:37,1845,1166,421,96,457,679
2026-04-27 22:42:39,1845,1158,429,95,455,686
2026-04-27 22:42:41,1845,1156,431,91,451,689
2026-04-27 22:42:43,1845,1151,436,91,451,693
2026-04-27 22:42:45,1845,1156,431,99,459,689
2026-04-27 22:42:48,1845,1152,435,95,455,692
2026-04-27 22:42:50,1845,1151,436,96,457,694
2026-04-27 22:42:52,1845,1151,436,92,453,694
2026-04-27 22:42:54,1845,1157,430,100,461,688
2026-04-27 22:42:56,1845,1154,433,96,457,691
2026-04-27 22:42:58,1845,1153,434,96,457,692
2026-04-27 22:43:00,1845,1167,420,96,456,677
2026-04-27 22:43:03,1845,1162,425,90,450,683
2026-04-27 22:43:05,1845,1157,430,89,450,688
2026-04-27 22:43:07,1845,1153,434,85,446,691
2026-04-27 22:43:09,1845,1154,433,93,454,690
2026-04-27 22:43:11,1845,1151,436,89,450,694
2026-04-27 22:43:13,1845,1151,436,89,450,694
2026-04-27 22:43:15,1845,1154,432,96,457,690
2026-04-27 22:43:18,1845,1150,437,92,453,694
2026-04-27 22:43:20,1845,1160,427,100,461,685
2026-04-27 22:43:22,1845,1156,430,96,457,688
2026-04-27 22:43:24,1845,1156,430,96,457,688
2026-04-27 22:43:26,1845,1153,433,96,457,691
2026-04-27 22:43:28,1845,1153,434,96,457,692
2026-04-27 22:43:30,1845,1127,460,70,431,717
2026-04-27 22:43:33,1845,1131,456,70,431,714
2026-04-27 22:43:35,1845,1138,448,78,439,706
2026-04-27 22:43:37,1845,1134,453,74,435,711
2026-04-27 22:43:39,1845,1146,405,108,469,699
2026-04-27 22:43:41,1845,1132,419,100,461,712
2026-04-27 22:43:43,1845,1129,422,100,461,716
2026-04-27 22:43:46,1845,1128,423,98,459,717
2026-04-27 22:43:48,1845,1124,427,94,455,721
2026-04-27 22:43:50,1845,1116,434,90,451,728
2026-04-27 22:43:52,1845,1114,436,86,447,730
2026-04-27 22:43:54,1845,1111,439,86,447,733
2026-04-27 22:43:56,1845,1110,441,86,447,735
2026-04-27 22:43:59,1845,1110,441,86,447,735
2026-04-27 22:44:01,1845,1132,418,93,454,712
2026-04-27 22:44:03,1845,1116,434,85,446,728
2026-04-27 22:44:05,1845,1107,444,84,445,738
2026-04-27 22:44:07,1845,1101,450,79,440,744
2026-04-27 22:44:09,1845,1101,449,79,441,744
2026-04-27 22:44:11,1845,1101,450,79,440,744
2026-04-27 22:44:13,1845,1098,453,79,440,747
2026-04-27 22:44:16,1845,1096,455,77,438,749
2026-04-27 22:44:18,1845,1096,454,77,438,748
2026-04-27 22:44:20,1845,1107,444,87,448,738
2026-04-27 22:44:22,1845,1106,444,83,444,738
2026-04-27 22:44:24,1845,1115,435,91,452,729
2026-04-27 22:44:26,1845,1112,438,87,448,732
2026-04-27 22:44:28,1845,1112,439,87,448,733
2026-04-27 22:44:31,1845,1091,459,74,435,754
2026-04-27 22:44:33,1845,1088,462,70,431,756
2026-04-27 22:44:35,1845,1087,463,70,431,757
2026-04-27 22:44:37,1845,1086,464,70,431,758
2026-04-27 22:44:39,1845,1099,451,77,438,745
2026-04-27 22:44:41,1845,1097,453,73,434,747
2026-04-27 22:44:43,1845,1097,453,73,434,747
2026-04-27 22:44:45,1845,1128,422,100,462,716
2026-04-27 22:44:48,1845,1129,421,92,454,716
2026-04-27 22:44:50,1845,1133,416,84,446,711
2026-04-27 22:44:52,1845,1125,425,80,442,720
2026-04-27 22:44:54,1845,1124,426,85,447,721
2026-04-27 22:44:56,1845,1120,429,83,444,724
2026-04-27 22:44:58,1845,1116,434,83,444,728
2026-04-27 22:45:00,1845,1092,458,65,427,753
2026-04-27 22:45:02,1845,1092,458,65,427,753
2026-04-27 22:45:05,1845,1086,463,63,425,758
2026-04-27 22:45:07,1845,1086,463,63,425,758
2026-04-27 22:45:09,1845,1123,427,87,449,721
2026-04-27 22:45:11,1845,1122,428,80,442,723
2026-04-27 22:45:13,1845,1122,428,80,442,723
2026-04-27 22:45:15,1845,1116,434,79,440,728
2026-04-27 22:45:18,1845,1113,436,78,440,731
2026-04-27 22:45:20,1845,1107,442,76,438,737
2026-04-27 22:45:22,1845,1105,445,76,438,740
2026-04-27 22:45:24,1845,1115,434,86,448,729
2026-04-27 22:45:26,1845,1112,437,82,444,732
2026-04-27 22:45:28,1845,1112,437,82,444,732
2026-04-27 22:45:30,1845,1094,456,64,426,751
2026-04-27 22:45:32,1845,1090,459,60,422,754
2026-04-27 22:45:35,1845,1091,459,60,422,754
2026-04-27 22:45:37,1845,1090,460,60,422,755
2026-04-27 22:45:39,1845,1104,445,67,429,740
2026-04-27 22:45:41,1845,1106,443,63,425,739
2026-04-27 22:45:43,1845,1104,445,63,425,740
2026-04-27 22:45:45,1845,1109,440,71,433,735
2026-04-27 22:45:48,1845,1104,446,67,429,741
2026-04-27 22:45:50,1845,1129,420,91,453,716
2026-04-27 22:45:52,1845,1120,429,83,445,725
2026-04-27 22:45:54,1845,1147,403,89,451,698
2026-04-27 22:45:56,1845,1152,397,93,455,692
2026-04-27 22:45:58,1845,1151,399,97,459,694
2026-04-27 22:46:00,1845,1128,421,82,444,716
2026-04-27 22:46:03,1845,1126,424,82,444,719
2026-04-27 22:46:05,1845,1127,422,87,450,717
2026-04-27 22:46:07,1845,1119,430,80,443,725
2026-04-27 22:46:09,1845,1124,425,89,451,721
2026-04-27 22:46:11,1845,1119,430,85,447,726
2026-04-27 22:46:13,1845,1117,432,85,447,728
2026-04-27 22:46:15,1845,1126,423,91,453,719
2026-04-27 22:46:17,1845,1125,425,86,449,720
2026-04-27 22:46:20,1845,1120,429,87,449,725
2026-04-27 22:46:22,1845,1118,431,86,449,726
2026-04-27 22:46:24,1845,1125,424,94,456,720
2026-04-27 22:46:26,1845,1121,428,89,452,723
2026-04-27 22:46:28,1845,1120,429,89,452,724
2026-04-27 22:46:30,1845,1106,443,76,439,739
2026-04-27 22:46:32,1845,1106,443,72,435,738
2026-04-27 22:46:35,1845,1113,436,80,443,732
2026-04-27 22:46:37,1845,1113,436,76,439,731
2026-04-27 22:46:39,1845,1108,440,76,439,736
2026-04-27 22:46:41,1845,1107,442,76,439,738
2026-04-27 22:46:43,1845,1108,441,76,439,737
2026-04-27 22:46:45,1845,1146,403,103,466,698
2026-04-27 22:46:47,1845,1137,412,95,458,708
2026-04-27 22:46:49,1845,1142,407,87,450,703
2026-04-27 22:46:52,1845,1138,411,83,446,707
2026-04-27 22:46:54,1845,1136,412,87,450,708
2026-04-27 22:46:56,1845,1140,409,97,460,705
2026-04-27 22:46:58,1845,1125,423,95,458,719
2026-04-27 22:47:00,1845,1110,439,78,441,735
2026-04-27 22:47:02,1845,1099,449,71,434,745
2026-04-27 22:47:05,1845,1098,450,68,432,746
2026-04-27 22:47:07,1845,1098,450,68,431,746
2026-04-27 22:47:09,1845,1099,449,71,434,745
2026-04-27 22:47:11,1845,1098,450,69,432,746
2026-04-27 22:47:13,1845,1099,449,69,432,745
2026-04-27 22:47:15,1845,1097,451,69,432,748
2026-04-27 22:47:17,1845,1097,451,69,432,748
2026-04-27 22:47:19,1845,1120,428,96,459,724
2026-04-27 22:47:22,1845,1124,424,90,453,720
2026-04-27 22:47:24,1845,1142,406,90,453,703
2026-04-27 22:47:26,1845,1141,407,92,455,704
2026-04-27 22:47:28,1845,1132,416,92,455,713
2026-04-27 22:47:30,1845,1110,438,70,434,735
2026-04-27 22:47:33,1845,1105,444,70,433,740
2026-04-27 22:47:35,1845,1096,453,61,425,749
2026-04-27 22:47:37,1845,1093,456,59,423,752
2026-04-27 22:47:39,1845,1103,445,70,433,742
2026-04-27 22:47:41,1845,1103,445,65,429,741
2026-04-27 22:47:43,1845,1100,448,65,429,744
2026-04-27 22:47:45,1845,1108,440,73,437,737
2026-04-27 22:47:48,1845,1104,444,69,433,740
2026-04-27 22:47:50,1845,1108,440,68,431,737
2026-04-27 22:47:52,1845,1103,445,64,427,741
2026-04-27 22:47:54,1845,1111,437,76,439,733
2026-04-27 22:47:56,1845,1113,435,72,435,731
2026-04-27 22:47:58,1845,1112,436,72,435,732
2026-04-27 22:48:00,1845,1099,449,63,426,746
2026-04-27 22:48:03,1845,1099,449,59,422,746
2026-04-27 22:48:05,1845,1143,405,93,456,701
2026-04-27 22:48:07,1845,1138,410,84,448,707
2026-04-27 22:48:09,1845,1128,420,83,446,717
2026-04-27 22:48:11,1845,1120,428,79,442,724
2026-04-27 22:48:13,1845,1120,428,79,442,725
2026-04-27 22:48:15,1845,1118,430,81,445,726
2026-04-27 22:48:17,1845,1115,433,79,443,729
2026-04-27 22:48:20,1845,1115,433,82,445,729
2026-04-27 22:48:22,1845,1115,433,80,443,730
2026-04-27 22:48:24,1845,1120,428,86,450,725
2026-04-27 22:48:26,1845,1118,430,82,446,726
2026-04-27 22:48:28,1845,1120,428,82,446,725
2026-04-27 22:48:30,1845,1098,449,67,430,746
2026-04-27 22:48:33,1845,1098,450,67,430,746
2026-04-27 22:48:35,1845,1110,437,75,438,734
2026-04-27 22:48:37,1845,1108,440,71,434,736
2026-04-27 22:48:39,1845,1150,397,98,461,694
2026-04-27 22:48:41,1845,1141,407,90,453,703
2026-04-27 22:48:43,1845,1141,407,90,453,704
2026-04-27 22:48:45,1845,1128,420,84,447,717
2026-04-27 22:48:47,1845,1123,424,81,445,721
2026-04-27 22:48:50,1845,1176,372,109,472,669
2026-04-27 22:48:52,1845,1164,384,100,464,680
2026-04-27 22:48:54,1845,1139,408,81,444,705
2026-04-27 22:48:56,1845,1138,409,80,444,706
2026-04-27 22:48:58,1845,1133,415,80,444,712
2026-04-27 22:49:00,1845,1107,441,57,421,737
2026-04-27 22:49:03,1845,1105,443,57,421,740
2026-04-27 22:49:05,1845,1112,436,71,435,732
2026-04-27 22:49:07,1845,1111,436,67,431,733
2026-04-27 22:49:09,1845,1108,440,68,431,737
2026-04-27 22:49:11,1845,1107,441,68,431,738
2026-04-27 22:49:13,1845,1106,441,68,431,738
2026-04-27 22:49:15,1845,1116,432,76,439,729
2026-04-27 22:49:18,1845,1116,432,72,435,729
2026-04-27 22:49:20,1845,1118,429,80,443,726
2026-04-27 22:49:22,1845,1115,433,75,439,730
2026-04-27 22:49:24,1845,1121,427,83,447,724
2026-04-27 22:49:26,1845,1122,425,79,443,722
2026-04-27 22:49:28,1845,1119,429,79,443,726
2026-04-27 22:49:30,1845,1102,446,63,427,743
2026-04-27 22:49:33,1845,1098,449,59,423,746
2026-04-27 22:49:35,1845,1107,441,67,431,738
2026-04-27 22:49:37,1845,1105,443,63,427,740
2026-04-27 22:49:39,1845,1114,433,71,435,730
2026-04-27 22:49:41,1845,1112,436,67,431,733
2026-04-27 22:49:43,1845,1110,438,67,431,735
2026-04-27 22:49:45,1845,1116,432,77,441,729
2026-04-27 22:49:48,1845,1116,431,76,440,728
2026-04-27 22:49:50,1845,1120,428,82,446,725
2026-04-27 22:49:52,1845,1119,429,81,445,726
2026-04-27 22:49:54,1845,1128,419,85,449,717
2026-04-27 22:49:56,1845,1124,423,78,443,720
2026-04-27 22:49:58,1845,1125,422,76,440,720
2026-04-27 22:50:01,1845,1117,430,67,431,727
2026-04-27 22:50:03,1845,1112,435,66,430,732
2026-04-27 22:50:05,1845,1109,438,69,433,735
2026-04-27 22:50:07,1845,1109,439,68,432,736
2026-04-27 22:50:09,1845,1113,434,74,438,731
2026-04-27 22:50:11,1845,1105,442,68,433,740
2026-04-27 22:50:13,1845,1101,445,66,430,743
2026-04-27 22:50:16,1845,1116,431,75,440,729
2026-04-27 22:50:18,1845,1113,433,71,436,731
2026-04-27 22:50:20,1845,1121,426,79,444,724
2026-04-27 22:50:22,1845,1119,427,79,444,726
2026-04-27 22:50:24,1845,1122,425,81,446,723
2026-04-27 22:50:26,1845,1121,426,79,444,724
2026-04-27 22:50:28,1845,1115,431,80,445,729
2026-04-27 22:50:31,1845,1100,447,61,426,745
2026-04-27 22:50:33,1845,1100,446,61,426,744
2026-04-27 22:50:35,1845,1095,452,57,422,750
2026-04-27 22:50:37,1845,1095,451,57,422,749
2026-04-27 22:50:39,1845,1091,456,56,421,754
2026-04-27 22:50:41,1845,1091,456,56,421,754
2026-04-27 22:50:43,1845,1092,455,56,421,753
2026-04-27 22:50:45,1845,1094,453,56,421,751
2026-04-27 22:50:48,1845,1093,454,56,421,752
2026-04-27 22:50:50,1845,1101,445,64,429,743
2026-04-27 22:50:52,1845,1097,449,60,425,747
2026-04-27 22:50:54,1845,1105,441,67,432,739
2026-04-27 22:50:56,1845,1101,445,63,428,744
2026-04-27 22:50:58,1845,1102,444,63,428,743
2026-04-27 22:51:00,1845,1106,440,59,424,738
2026-04-27 22:51:03,1845,1105,441,56,422,739
2026-04-27 22:51:05,1845,1101,445,57,422,743
2026-04-27 22:51:07,1845,1102,444,56,422,742
2026-04-27 22:51:09,1845,1147,399,91,457,698
2026-04-27 22:51:11,1845,1138,408,82,448,707
2026-04-27 22:51:13,1845,1140,406,81,447,705
2026-04-27 22:51:16,1845,1154,392,93,458,691
2026-04-27 22:51:18,1845,1128,417,80,445,716
2026-04-27 22:51:20,1845,1139,407,88,454,705
2026-04-27 22:51:22,1845,1130,415,78,444,714
2026-04-27 22:51:24,1845,1126,420,80,446,719
2026-04-27 22:51:26,1845,1126,420,80,445,719
2026-04-27 22:51:29,1845,1124,421,80,445,720
2026-04-27 22:51:31,1845,1106,440,66,432,739
2026-04-27 22:51:33,1845,1106,440,66,432,739
2026-04-27 22:51:35,1845,1113,433,74,440,732
2026-04-27 22:51:37,1845,1113,433,70,436,731
2026-04-27 22:51:39,1845,1116,430,78,444,729
2026-04-27 22:51:42,1845,1115,430,74,440,729
2026-04-27 22:51:44,1845,1115,431,74,440,730
2026-04-27 22:51:46,1845,1121,425,78,443,723
2026-04-27 22:51:48,1845,1121,425,78,443,724
2026-04-27 22:51:50,1845,1117,428,78,444,727
2026-04-27 22:51:52,1845,1118,428,78,443,727
2026-04-27 22:51:54,1845,1121,425,81,447,724
2026-04-27 22:51:57,1845,1119,426,79,445,725
2026-04-27 22:51:59,1845,1100,445,60,426,744
2026-04-27 22:52:01,1845,1102,444,60,426,743
2026-04-27 22:52:03,1845,1102,443,60,426,742
2026-04-27 22:52:05,1845,1102,443,60,427,743
2026-04-27 22:52:07,1845,1100,445,60,426,744
2026-04-27 22:52:09,1845,1081,464,49,416,764
2026-04-27 22:52:12,1845,876,619,49,475,969
2026-04-27 22:52:14,1845,940,550,49,483,905
2026-04-27 22:52:16,1845,963,531,57,491,882
2026-04-27 22:52:18,1845,965,528,61,495,879
2026-04-27 22:52:20,1845,976,518,70,504,869
2026-04-27 22:52:22,1845,972,522,70,503,872
2026-04-27 22:52:25,1845,974,520,74,508,871
2026-04-27 22:52:27,1845,965,528,67,501,879
2026-04-27 22:52:29,1845,962,532,66,500,883
2026-04-27 22:52:31,1845,962,532,65,499,883
2026-04-27 22:52:33,1845,963,531,65,499,881
2026-04-27 22:52:35,1845,951,543,58,492,894
2026-04-27 22:52:37,1845,952,542,58,492,893
2026-04-27 22:52:39,1845,961,533,66,500,884
2026-04-27 22:52:42,1845,955,538,62,496,889
2026-04-27 22:52:44,1845,957,536,62,496,887
2026-04-27 22:52:46,1845,966,528,66,500,879
2026-04-27 22:52:48,1845,964,530,66,500,881
2026-04-27 22:52:50,1845,973,521,74,508,872
2026-04-27 22:52:52,1845,969,525,70,504,876
2026-04-27 22:52:54,1845,968,525,70,504,876
2026-04-27 22:52:56,1845,968,525,70,504,876
2026-04-27 22:52:59,1845,967,526,70,504,877
2026-04-27 22:53:01,1845,955,535,60,497,889
2026-04-27 22:53:03,1845,955,536,60,497,890
2026-04-27 22:53:05,1845,1008,486,93,531,837
2026-04-27 22:53:07,1845,1006,488,85,522,839
2026-04-27 22:53:09,1845,1002,493,83,520,843
2026-04-27 22:53:11,1845,998,496,79,516,847
2026-04-27 22:53:14,1845,997,497,79,516,848
2026-04-27 22:53:16,1845,990,504,80,517,855
2026-04-27 22:53:18,1845,988,507,76,513,857
2026-04-27 22:53:20,1845,991,506,80,517,853
2026-04-27 22:53:22,1845,988,509,77,515,857
2026-04-27 22:53:24,1845,985,513,78,515,860
2026-04-27 22:53:26,1845,986,512,77,515,859
2026-04-27 22:53:29,1845,985,512,77,515,859
2026-04-27 22:53:31,1845,967,530,59,497,878
2026-04-27 22:53:33,1845,962,536,59,497,883
2026-04-27 22:53:35,1845,972,526,69,507,873
2026-04-27 22:53:37,1845,971,526,65,503,873
2026-04-27 22:53:39,1845,980,517,72,510,864
2026-04-27 22:53:41,1845,978,519,68,506,866
2026-04-27 22:53:44,1845,977,520,68,506,867
2026-04-27 22:53:46,1845,986,511,77,514,859
2026-04-27 22:53:48,1845,981,517,76,514,864
2026-04-27 22:53:50,1845,992,506,86,524,853
2026-04-27 22:53:52,1845,993,505,85,523,852
2026-04-27 22:53:54,1845,993,504,87,525,851
2026-04-27 22:53:57,1845,989,508,81,518,856
2026-04-27 22:53:59,1845,986,511,81,518,858
2026-04-27 22:54:01,1845,969,528,60,498,876
2026-04-27 22:54:03,1845,962,535,60,498,882
2026-04-27 22:54:05,1845,973,524,66,504,871
2026-04-27 22:54:07,1845,969,528,62,500,876
2026-04-27 22:54:10,1845,974,523,70,508,871
2026-04-27 22:54:12,1845,969,528,66,504,876
2026-04-27 22:54:14,1845,973,523,66,504,871
2026-04-27 22:54:16,1845,983,514,70,508,862
2026-04-27 22:54:18,1845,981,515,70,508,863
2026-04-27 22:54:20,1845,1024,473,104,542,821
2026-04-27 22:54:22,1845,1021,475,95,534,823
2026-04-27 22:54:24,1845,1010,486,94,532,834
2026-04-27 22:54:27,1845,1009,488,90,528,836
2026-04-27 22:54:29,1845,1006,490,90,528,838
2026-04-27 22:54:31,1845,990,507,76,514,855
2026-04-27 22:54:33,1845,988,508,76,514,856
2026-04-27 22:54:35,1845,1000,496,83,522,844
2026-04-27 22:54:37,1845,1253,242,149,590,592
2026-04-27 22:54:40,1845,1268,231,158,598,577
2026-04-27 22:54:42,1845,1307,193,166,615,538
2026-04-27 22:54:44,1845,1343,122,162,645,501
2026-04-27 22:54:46,1845,1373,66,115,604,472
2026-04-27 22:54:48,1845,1374,73,107,596,471
2026-04-27 22:54:51,1845,1391,68,103,592,454
2026-04-27 22:54:53,1845,1368,63,103,591,476
2026-04-27 22:54:56,1845,1451,57,103,507,394
2026-04-27 22:54:59,1845,1632,58,83,334,213
2026-04-27 22:55:01,1845,1496,165,55,305,348
2026-04-27 22:55:03,1845,1517,107,72,359,328
2026-04-27 22:55:05,1845,1517,107,62,349,327
2026-04-27 22:55:07,1845,1515,108,57,344,329
2026-04-27 22:55:09,1845,1517,106,57,344,327
2026-04-27 22:55:12,1845,1512,112,53,341,333
2026-04-27 22:55:14,1845,1505,119,53,341,340
2026-04-27 22:55:16,1845,1497,124,44,335,348
2026-04-27 22:55:18,1845,1492,128,44,335,352
2026-04-27 22:55:20,1845,1487,133,44,336,358
2026-04-27 22:55:23,1845,1482,137,44,336,362
2026-04-27 22:55:25,1845,1481,138,44,336,363
2026-04-27 22:55:27,1845,1478,142,44,336,367
2026-04-27 22:55:29,1845,1479,141,44,336,365
2026-04-27 22:55:31,1845,1479,141,44,336,366
2026-04-27 22:55:33,1845,1528,87,74,370,316
2026-04-27 22:55:36,1845,1533,82,76,372,311
2026-04-27 22:55:38,1845,1544,71,83,379,300
2026-04-27 22:55:40,1845,1557,58,90,386,287
2026-04-27 22:55:42,1845,1572,43,92,388,272
2026-04-27 22:55:44,1845,1603,31,100,396,241
2026-04-27 22:55:46,1845,1588,66,108,376,257
2026-04-27 22:55:48,1845,1586,64,115,376,258
2026-04-27 22:55:51,1845,1609,41,121,383,236
2026-04-27 22:55:53,1845,1624,25,129,390,221
2026-04-27 22:55:55,1845,1617,65,131,360,227
2026-04-27 22:55:57,1845,1658,44,139,367,187
2026-04-27 22:55:59,1845,1655,41,146,362,190
2026-04-27 22:56:01,1845,1670,41,150,350,175
2026-04-27 22:56:04,1845,1684,28,157,357,161
2026-04-27 22:56:06,1845,1668,47,165,364,177
2026-04-27 22:56:08,1845,1674,38,172,372,171
2026-04-27 22:56:10,1845,1722,39,180,349,122
2026-04-27 22:56:13,1845,1700,74,188,349,145
2026-04-27 22:56:15,1845,1713,82,197,333,132
2026-04-27 22:56:17,1845,1700,83,205,340,145
2026-04-27 22:56:19,1845,1712,82,212,348,132
2026-04-27 22:56:21,1845,1705,93,219,356,140
2026-04-27 22:56:24,1845,1658,112,227,369,187
2026-04-27 22:56:26,1845,1673,104,234,376,172
2026-04-27 22:56:28,1845,1714,79,242,385,131
2026-04-27 22:56:30,1845,1734,67,247,389,111
2026-04-27 22:56:32,1845,1742,59,249,392,102
2026-04-27 22:56:34,1845,1706,63,252,394,139
2026-04-27 22:56:37,1845,1654,99,175,334,191
2026-04-27 22:56:39,1845,1664,88,182,341,180
2026-04-27 22:56:41,1845,1681,71,192,351,163
2026-04-27 22:56:43,1845,1701,51,200,360,144
1 2026-04-27 22:07:57 1845 1002 622 87 373 842
2 2026-04-27 22:07:59 1845 1009 615 95 381 835
3 2026-04-27 22:08:01 1845 1010 614 90 377 835
4 2026-04-27 22:08:03 1845 1018 606 98 385 827
5 2026-04-27 22:08:05 1845 1012 613 94 381 833
6 2026-04-27 22:08:07 1845 986 638 69 356 858
7 2026-04-27 22:08:09 1845 985 640 65 352 860
8 2026-04-27 22:08:12 1845 984 640 65 352 860
9 2026-04-27 22:08:14 1845 990 634 73 360 854
10 2026-04-27 22:08:16 1845 986 638 69 356 859
11 2026-04-27 22:08:18 1845 995 629 77 363 849
12 2026-04-27 22:08:20 1845 995 629 72 359 849
13 2026-04-27 22:08:22 1845 988 634 62 351 857
14 2026-04-27 22:08:24 1845 988 633 62 351 856
15 2026-04-27 22:08:27 1845 992 630 62 351 853
16 2026-04-27 22:08:29 1845 1008 614 74 363 837
17 2026-04-27 22:08:31 1845 1008 613 70 359 837
18 2026-04-27 22:08:33 1845 1013 609 78 367 832
19 2026-04-27 22:08:35 1845 1013 608 74 363 832
20 2026-04-27 22:08:37 1845 999 622 61 351 846
21 2026-04-27 22:08:40 1845 995 627 59 348 850
22 2026-04-27 22:08:42 1845 771 841 59 358 1073
23 2026-04-27 22:08:44 1845 795 757 66 425 1049
24 2026-04-27 22:08:46 1845 852 691 62 430 992
25 2026-04-27 22:30:21 1845 1008 649 51 305 836
26 2026-04-27 22:30:23 1845 1014 642 59 314 830
27 2026-04-27 22:30:25 1845 1017 640 55 310 828
28 2026-04-27 22:30:27 1845 1033 613 77 342 812
29 2026-04-27 22:30:29 1845 1047 599 83 348 798
30 2026-04-27 22:30:32 1845 1061 585 90 355 784
31 2026-04-27 22:30:34 1845 1081 565 98 363 764
32 2026-04-27 22:30:36 1845 1093 553 100 365 751
33 2026-04-27 22:30:38 1845 1106 540 107 372 738
34 2026-04-27 22:30:40 1845 1123 523 115 380 722
35 2026-04-27 22:30:42 1845 1141 505 122 387 703
36 2026-04-27 22:30:45 1845 1160 486 129 394 685
37 2026-04-27 22:30:47 1845 1179 467 136 401 666
38 2026-04-27 22:30:49 1845 1187 459 135 400 657
39 2026-04-27 22:30:51 1845 1203 443 143 408 642
40 2026-04-27 22:30:53 1845 1218 428 150 415 627
41 2026-04-27 22:30:55 1845 1239 407 158 423 606
42 2026-04-27 22:30:57 1845 1254 392 165 430 591
43 2026-04-27 22:31:00 1845 1284 374 172 437 561
44 2026-04-27 22:31:02 1845 1310 352 180 445 534
45 2026-04-27 22:31:04 1845 1326 336 188 453 519
46 2026-04-27 22:31:06 1845 1342 320 197 462 502
47 2026-04-27 22:31:08 1845 1361 301 203 469 483
48 2026-04-27 22:31:10 1845 1378 284 211 476 467
49 2026-04-27 22:31:12 1845 1396 266 218 484 448
50 2026-04-27 22:31:15 1845 1413 249 226 492 432
51 2026-04-27 22:31:17 1845 1433 229 234 499 412
52 2026-04-27 22:31:19 1845 1443 219 234 499 402
53 2026-04-27 22:31:21 1845 1461 201 241 506 384
54 2026-04-27 22:31:23 1845 1486 176 254 520 359
55 2026-04-27 22:31:25 1845 1501 160 258 523 343
56 2026-04-27 22:31:27 1845 1516 150 261 526 329
57 2026-04-27 22:31:30 1845 1486 183 216 482 359
58 2026-04-27 22:31:32 1845 1468 201 186 452 376
59 2026-04-27 22:31:34 1845 1485 187 194 460 359
60 2026-04-27 22:31:36 1845 1505 167 203 469 339
61 2026-04-27 22:31:38 1845 1521 151 210 476 324
62 2026-04-27 22:31:40 1845 1535 137 216 481 310
63 2026-04-27 22:31:42 1845 1544 116 225 487 301
64 2026-04-27 22:31:45 1845 1538 129 232 476 307
65 2026-04-27 22:31:47 1845 1550 117 240 484 295
66 2026-04-27 22:31:49 1845 1594 165 246 433 250
67 2026-04-27 22:31:51 1845 1548 173 252 441 297
68 2026-04-27 22:31:53 1845 1553 182 259 434 291
69 2026-04-27 22:31:56 1845 1608 163 262 438 236
70 2026-04-27 22:31:58 1845 1622 148 261 438 223
71 2026-04-27 22:32:00 1845 1546 200 215 380 298
72 2026-04-27 22:32:02 1845 1529 217 184 349 316
73 2026-04-27 22:32:04 1845 1579 180 193 364 265
74 2026-04-27 22:32:06 1845 1597 162 201 372 248
75 2026-04-27 22:32:09 1845 1617 141 209 380 227
76 2026-04-27 22:32:11 1845 1641 117 217 389 204
77 2026-04-27 22:32:13 1845 1649 109 224 396 196
78 2026-04-27 22:32:15 1845 1668 90 232 404 177
79 2026-04-27 22:32:17 1845 1683 75 239 411 162
80 2026-04-27 22:32:19 1845 1673 85 246 419 172
81 2026-04-27 22:32:22 1845 1680 78 253 426 165
82 2026-04-27 22:32:24 1845 1678 74 263 437 166
83 2026-04-27 22:32:26 1845 1634 112 265 429 210
84 2026-04-27 22:32:28 1845 1662 103 265 429 182
85 2026-04-27 22:32:31 1845 1626 147 219 373 219
86 2026-04-27 22:32:33 1845 1610 163 189 343 235
87 2026-04-27 22:32:35 1845 1623 150 196 351 222
88 2026-04-27 22:32:37 1845 1648 125 204 358 197
89 2026-04-27 22:32:39 1845 1657 116 212 366 188
90 2026-04-27 22:32:41 1845 1676 96 219 374 168
91 2026-04-27 22:32:43 1845 1679 67 231 396 165
92 2026-04-27 22:32:46 1845 1693 95 239 363 151
93 2026-04-27 22:32:48 1845 1724 91 247 362 120
94 2026-04-27 22:32:50 1845 1720 95 253 369 125
95 2026-04-27 22:32:52 1845 1701 95 261 377 144
96 2026-04-27 22:32:54 1845 1699 96 269 385 145
97 2026-04-27 22:32:56 1845 1714 102 269 382 131
98 2026-04-27 22:32:59 1845 1707 107 268 383 137
99 2026-04-27 22:33:01 1845 1654 142 222 337 191
100 2026-04-27 22:33:03 1845 1674 140 202 318 171
101 2026-04-27 22:33:05 1845 1685 129 209 325 159
102 2026-04-27 22:33:07 1845 1703 111 217 332 141
103 2026-04-27 22:33:09 1845 1716 98 224 340 128
104 2026-04-27 22:33:12 1845 1732 81 232 349 112
105 2026-04-27 22:33:14 1845 1743 61 238 365 101
106 2026-04-27 22:33:16 1845 1711 68 251 383 133
107 2026-04-27 22:33:19 1845 1713 44 258 412 131
108 2026-04-27 22:33:21 1845 1731 26 266 420 113
109 2026-04-27 22:33:23 1845 1692 98 264 418 153
110 2026-04-27 22:33:26 1845 1272 461 272 483 573
111 2026-04-27 22:33:28 1845 1259 472 262 476 586
112 2026-04-27 22:33:30 1845 1102 631 113 327 742
113 2026-04-27 22:33:32 1845 1023 711 42 256 822
114 2026-04-27 22:33:34 1845 1027 696 42 266 818
115 2026-04-27 22:33:37 1845 1028 695 42 266 816
116 2026-04-27 22:33:39 1845 1032 691 47 272 813
117 2026-04-27 22:33:41 1845 1033 690 45 270 812
118 2026-04-27 22:33:43 1845 1030 692 45 270 815
119 2026-04-27 22:33:45 1845 1037 686 53 278 808
120 2026-04-27 22:33:47 1845 1033 690 49 274 812
121 2026-04-27 22:33:49 1845 1040 683 57 282 805
122 2026-04-27 22:33:52 1845 1038 684 53 278 807
123 2026-04-27 22:33:54 1845 1040 682 60 285 805
124 2026-04-27 22:33:56 1845 1071 651 79 304 773
125 2026-04-27 22:33:58 1845 1072 650 79 304 772
126 2026-04-27 22:34:00 1845 1059 663 65 291 786
127 2026-04-27 22:34:02 1845 1059 663 65 291 785
128 2026-04-27 22:34:04 1845 1066 656 73 299 779
129 2026-04-27 22:34:07 1845 1099 621 69 296 745
130 2026-04-27 22:34:09 1845 1102 619 76 304 743
131 2026-04-27 22:34:11 1845 1102 619 72 299 743
132 2026-04-27 22:34:13 1845 1101 619 72 299 743
133 2026-04-27 22:34:15 1845 1103 617 80 307 741
134 2026-04-27 22:34:17 1845 1104 616 76 303 741
135 2026-04-27 22:34:20 1845 1109 609 83 313 736
136 2026-04-27 22:34:22 1845 1106 611 79 309 739
137 2026-04-27 22:34:24 1845 1114 603 87 317 730
138 2026-04-27 22:34:26 1845 1110 607 83 313 734
139 2026-04-27 22:34:28 1845 1110 607 83 313 735
140 2026-04-27 22:34:30 1845 1091 626 68 299 754
141 2026-04-27 22:34:32 1845 1092 624 64 295 752
142 2026-04-27 22:34:35 1845 1094 623 71 302 751
143 2026-04-27 22:34:37 1845 1097 620 67 298 748
144 2026-04-27 22:34:39 1845 1133 583 93 324 711
145 2026-04-27 22:34:41 1845 1132 585 87 318 713
146 2026-04-27 22:34:43 1845 1128 589 87 318 717
147 2026-04-27 22:34:45 1845 1111 605 77 309 733
148 2026-04-27 22:34:47 1845 1109 607 77 308 736
149 2026-04-27 22:34:50 1845 1107 610 75 306 738
150 2026-04-27 22:34:52 1845 1106 611 75 306 739
151 2026-04-27 22:34:54 1845 1113 603 85 317 732
152 2026-04-27 22:34:56 1845 1114 602 81 312 731
153 2026-04-27 22:34:58 1845 1112 605 81 312 733
154 2026-04-27 22:35:01 1845 1101 614 72 304 744
155 2026-04-27 22:35:03 1845 1101 614 68 300 744
156 2026-04-27 22:35:05 1845 1111 604 76 308 734
157 2026-04-27 22:35:07 1845 1106 609 72 304 739
158 2026-04-27 22:35:09 1845 1110 605 78 311 734
159 2026-04-27 22:35:11 1845 1104 612 74 306 741
160 2026-04-27 22:35:13 1845 1103 612 74 306 741
161 2026-04-27 22:35:15 1845 1114 580 83 337 731
162 2026-04-27 22:35:18 1845 1117 574 79 335 727
163 2026-04-27 22:35:20 1845 1152 538 106 363 692
164 2026-04-27 22:35:22 1845 1148 543 98 355 697
165 2026-04-27 22:35:24 1845 1166 525 116 372 679
166 2026-04-27 22:35:26 1845 1157 534 110 366 687
167 2026-04-27 22:35:28 1845 1156 531 110 370 688
168 2026-04-27 22:35:30 1845 1136 551 88 349 708
169 2026-04-27 22:35:32 1845 1132 555 84 345 713
170 2026-04-27 22:35:35 1845 1167 520 112 372 678
171 2026-04-27 22:35:37 1845 1159 528 103 364 686
172 2026-04-27 22:35:39 1845 1131 556 80 340 714
173 2026-04-27 22:35:41 1845 1127 560 79 340 718
174 2026-04-27 22:35:43 1845 1124 563 79 340 720
175 2026-04-27 22:35:45 1845 1118 569 77 337 727
176 2026-04-27 22:35:47 1845 1115 571 77 338 729
177 2026-04-27 22:35:50 1845 1121 565 87 348 723
178 2026-04-27 22:35:52 1845 1123 563 85 346 721
179 2026-04-27 22:35:54 1845 1129 557 92 353 715
180 2026-04-27 22:35:56 1845 1125 561 88 349 719
181 2026-04-27 22:35:58 1845 1124 563 88 349 721
182 2026-04-27 22:36:00 1845 1127 559 83 344 717
183 2026-04-27 22:36:02 1845 1123 564 77 338 722
184 2026-04-27 22:36:05 1845 1117 569 72 333 727
185 2026-04-27 22:36:07 1845 1070 616 70 331 774
186 2026-04-27 22:36:09 1845 1062 625 67 328 783
187 2026-04-27 22:36:11 1845 1059 628 67 328 786
188 2026-04-27 22:36:13 1845 1054 632 67 328 790
189 2026-04-27 22:36:15 1845 1053 633 67 328 791
190 2026-04-27 22:36:17 1845 1052 634 67 328 792
191 2026-04-27 22:36:20 1845 1056 630 70 331 789
192 2026-04-27 22:36:22 1845 1055 631 70 331 789
193 2026-04-27 22:36:24 1845 1098 588 102 364 747
194 2026-04-27 22:36:26 1845 1093 593 95 357 752
195 2026-04-27 22:36:28 1845 1093 592 95 357 751
196 2026-04-27 22:36:30 1845 1104 581 89 351 740
197 2026-04-27 22:36:32 1845 1093 592 83 345 752
198 2026-04-27 22:36:35 1845 1087 598 83 345 758
199 2026-04-27 22:36:37 1845 1080 606 79 341 765
200 2026-04-27 22:36:39 1845 1079 606 87 349 766
201 2026-04-27 22:36:41 1845 1075 610 83 345 770
202 2026-04-27 22:36:43 1845 1072 613 83 345 772
203 2026-04-27 22:36:45 1845 1070 613 85 350 775
204 2026-04-27 22:36:47 1845 1071 612 83 347 774
205 2026-04-27 22:36:50 1845 1064 619 80 344 780
206 2026-04-27 22:36:52 1845 1063 620 76 340 781
207 2026-04-27 22:36:54 1845 1067 616 79 343 778
208 2026-04-27 22:36:56 1845 1065 618 77 341 779
209 2026-04-27 22:36:58 1845 1062 621 77 341 782
210 2026-04-27 22:37:00 1845 1094 589 93 358 750
211 2026-04-27 22:37:03 1845 1083 600 85 350 762
212 2026-04-27 22:37:05 1845 1069 614 76 340 776
213 2026-04-27 22:37:07 1845 1068 616 76 340 777
214 2026-04-27 22:37:09 1845 1108 571 103 371 736
215 2026-04-27 22:37:11 1845 1093 587 95 363 752
216 2026-04-27 22:37:13 1845 1092 587 95 363 752
217 2026-04-27 22:37:15 1845 1080 599 86 353 764
218 2026-04-27 22:37:18 1845 1080 600 85 353 764
219 2026-04-27 22:37:20 1845 1077 602 83 351 767
220 2026-04-27 22:37:22 1845 1075 604 83 351 769
221 2026-04-27 22:37:24 1845 1074 606 83 351 771
222 2026-04-27 22:37:26 1845 1074 606 83 351 771
223 2026-04-27 22:37:28 1845 1073 606 83 351 771
224 2026-04-27 22:37:30 1845 1077 603 90 358 768
225 2026-04-27 22:37:33 1845 1078 602 86 354 767
226 2026-04-27 22:37:35 1845 1063 617 75 343 782
227 2026-04-27 22:37:37 1845 1060 619 71 339 784
228 2026-04-27 22:37:39 1845 1066 613 79 347 778
229 2026-04-27 22:37:41 1845 1074 591 75 358 771
230 2026-04-27 22:37:43 1845 1077 583 75 362 767
231 2026-04-27 22:37:45 1845 1087 573 83 371 758
232 2026-04-27 22:37:48 1845 1081 579 79 366 764
233 2026-04-27 22:37:50 1845 1082 578 79 367 763
234 2026-04-27 22:37:52 1845 1081 578 75 363 763
235 2026-04-27 22:37:54 1845 1129 530 103 390 715
236 2026-04-27 22:37:56 1845 1127 532 96 384 717
237 2026-04-27 22:37:58 1845 1117 543 96 384 728
238 2026-04-27 22:38:00 1845 1089 570 77 365 755
239 2026-04-27 22:38:03 1845 1086 573 73 361 759
240 2026-04-27 22:38:05 1845 1088 572 81 368 757
241 2026-04-27 22:38:07 1845 1084 576 76 364 761
242 2026-04-27 22:38:09 1845 1143 517 111 399 702
243 2026-04-27 22:38:11 1845 1133 526 103 391 711
244 2026-04-27 22:38:13 1845 1132 527 103 391 712
245 2026-04-27 22:38:15 1845 1115 545 93 381 730
246 2026-04-27 22:38:18 1845 1109 550 93 381 735
247 2026-04-27 22:38:20 1845 1114 544 92 382 731
248 2026-04-27 22:38:22 1845 1105 553 87 376 739
249 2026-04-27 22:38:24 1845 1100 558 87 376 745
250 2026-04-27 22:38:26 1845 1097 561 85 375 748
251 2026-04-27 22:38:28 1845 1096 561 85 375 748
252 2026-04-27 22:38:30 1845 1112 546 97 387 733
253 2026-04-27 22:38:32 1845 1111 547 93 383 734
254 2026-04-27 22:38:35 1845 1093 564 80 369 751
255 2026-04-27 22:38:37 1845 1090 567 76 365 754
256 2026-04-27 22:38:39 1845 1121 537 97 387 724
257 2026-04-27 22:38:41 1845 1122 536 91 381 723
258 2026-04-27 22:38:43 1845 1119 538 91 381 725
259 2026-04-27 22:38:45 1845 1157 500 110 400 687
260 2026-04-27 22:38:48 1845 1144 513 102 392 700
261 2026-04-27 22:38:50 1845 1112 546 88 378 733
262 2026-04-27 22:38:52 1845 1109 549 84 374 736
263 2026-04-27 22:38:54 1845 1136 521 111 401 708
264 2026-04-27 22:38:56 1845 1137 521 105 395 708
265 2026-04-27 22:38:58 1845 1132 525 105 395 712
266 2026-04-27 22:39:00 1845 1110 544 87 380 735
267 2026-04-27 22:39:03 1845 1111 523 83 396 734
268 2026-04-27 22:39:05 1845 1115 518 91 404 729
269 2026-04-27 22:39:07 1845 1114 520 87 400 731
270 2026-04-27 22:39:09 1845 1123 510 95 409 721
271 2026-04-27 22:39:11 1845 1117 517 91 405 727
272 2026-04-27 22:39:13 1845 1115 519 91 405 730
273 2026-04-27 22:39:15 1845 1109 525 98 412 736
274 2026-04-27 22:39:18 1845 1104 529 94 408 740
275 2026-04-27 22:39:20 1845 1107 527 102 416 738
276 2026-04-27 22:39:22 1845 1100 534 98 411 745
277 2026-04-27 22:39:24 1845 1136 498 119 433 709
278 2026-04-27 22:39:26 1845 1135 498 113 427 709
279 2026-04-27 22:39:28 1845 1135 498 113 427 710
280 2026-04-27 22:39:30 1845 1085 549 70 383 760
281 2026-04-27 22:39:33 1845 1082 551 69 383 762
282 2026-04-27 22:39:35 1845 1087 547 77 391 758
283 2026-04-27 22:39:37 1845 1083 550 73 386 761
284 2026-04-27 22:39:39 1845 1080 554 73 387 765
285 2026-04-27 22:39:41 1845 1079 555 73 386 766
286 2026-04-27 22:39:43 1845 1079 554 73 386 766
287 2026-04-27 22:39:45 1845 1111 522 94 408 733
288 2026-04-27 22:39:48 1845 1111 523 88 402 734
289 2026-04-27 22:39:50 1845 1102 531 83 397 743
290 2026-04-27 22:39:52 1845 1101 524 79 401 743
291 2026-04-27 22:39:54 1845 1120 494 86 419 724
292 2026-04-27 22:39:56 1845 1121 493 82 415 723
293 2026-04-27 22:39:58 1845 1120 494 82 416 725
294 2026-04-27 22:40:00 1845 1100 513 64 399 745
295 2026-04-27 22:40:03 1845 1097 516 60 395 748
296 2026-04-27 22:40:05 1845 1100 513 67 402 744
297 2026-04-27 22:40:07 1845 1097 516 63 398 747
298 2026-04-27 22:40:09 1845 1100 513 67 401 745
299 2026-04-27 22:40:11 1845 1100 513 64 399 745
300 2026-04-27 22:40:13 1845 1098 514 64 399 746
301 2026-04-27 22:40:15 1845 1108 504 72 408 736
302 2026-04-27 22:40:17 1845 1106 505 68 405 738
303 2026-04-27 22:40:20 1845 1106 505 68 405 738
304 2026-04-27 22:40:22 1845 1107 504 68 405 738
305 2026-04-27 22:40:24 1845 1152 458 102 439 692
306 2026-04-27 22:40:26 1845 1143 468 94 431 702
307 2026-04-27 22:40:28 1845 1142 469 94 431 703
308 2026-04-27 22:40:30 1845 1118 492 74 411 726
309 2026-04-27 22:40:32 1845 1118 492 70 407 727
310 2026-04-27 22:40:35 1845 1134 460 84 437 710
311 2026-04-27 22:40:37 1845 1136 458 83 436 709
312 2026-04-27 22:40:39 1845 1142 452 89 442 703
313 2026-04-27 22:40:41 1845 1136 452 82 441 709
314 2026-04-27 22:40:43 1845 1136 452 82 441 708
315 2026-04-27 22:40:46 1845 1130 458 79 438 715
316 2026-04-27 22:40:48 1845 1128 460 78 438 717
317 2026-04-27 22:40:50 1845 1130 458 78 438 714
318 2026-04-27 22:40:52 1845 1131 457 74 434 713
319 2026-04-27 22:40:54 1845 1136 452 79 439 708
320 2026-04-27 22:40:56 1845 1134 454 79 438 710
321 2026-04-27 22:40:58 1845 1134 453 78 438 710
322 2026-04-27 22:41:01 1845 1154 434 87 447 691
323 2026-04-27 22:41:03 1845 1130 458 69 429 715
324 2026-04-27 22:41:05 1845 1136 452 77 437 709
325 2026-04-27 22:41:07 1845 1129 459 68 428 716
326 2026-04-27 22:41:09 1845 1135 452 78 438 709
327 2026-04-27 22:41:11 1845 1131 456 74 434 713
328 2026-04-27 22:41:13 1845 1129 459 74 434 716
329 2026-04-27 22:41:16 1845 1128 459 73 433 716
330 2026-04-27 22:41:18 1845 1127 460 73 433 717
331 2026-04-27 22:41:20 1845 1124 463 71 431 720
332 2026-04-27 22:41:22 1845 1123 465 71 431 722
333 2026-04-27 22:41:24 1845 1136 451 82 442 708
334 2026-04-27 22:41:26 1845 1134 453 77 438 711
335 2026-04-27 22:41:28 1845 1134 453 77 438 710
336 2026-04-27 22:41:31 1845 1114 474 64 424 731
337 2026-04-27 22:41:33 1845 1115 473 64 424 730
338 2026-04-27 22:41:35 1845 1112 475 63 423 732
339 2026-04-27 22:41:37 1845 1113 474 63 423 731
340 2026-04-27 22:41:39 1845 1142 445 85 445 703
341 2026-04-27 22:41:41 1845 1138 449 79 439 706
342 2026-04-27 22:41:43 1845 1137 450 79 439 707
343 2026-04-27 22:41:45 1845 1142 445 79 439 702
344 2026-04-27 22:41:48 1845 1135 452 75 435 709
345 2026-04-27 22:41:50 1845 1131 457 78 438 714
346 2026-04-27 22:41:52 1845 1131 457 73 434 714
347 2026-04-27 22:41:54 1845 1131 456 81 441 713
348 2026-04-27 22:41:56 1845 1128 459 77 437 716
349 2026-04-27 22:41:58 1845 1129 458 77 437 715
350 2026-04-27 22:42:00 1845 1123 464 69 429 722
351 2026-04-27 22:42:02 1845 1117 470 65 425 728
352 2026-04-27 22:42:05 1845 1124 463 70 431 720
353 2026-04-27 22:42:07 1845 1122 465 68 429 722
354 2026-04-27 22:42:09 1845 1130 458 76 436 715
355 2026-04-27 22:42:11 1845 1127 460 72 432 717
356 2026-04-27 22:42:13 1845 1126 461 72 432 719
357 2026-04-27 22:42:15 1845 1135 452 80 440 709
358 2026-04-27 22:42:18 1845 1134 453 76 436 711
359 2026-04-27 22:42:20 1845 1172 415 109 470 672
360 2026-04-27 22:42:22 1845 1171 416 101 462 673
361 2026-04-27 22:42:24 1845 1166 421 100 460 679
362 2026-04-27 22:42:26 1845 1160 427 96 456 684
363 2026-04-27 22:42:28 1845 1158 429 96 456 686
364 2026-04-27 22:42:30 1845 1137 450 81 442 708
365 2026-04-27 22:42:33 1845 1132 455 77 438 712
366 2026-04-27 22:42:35 1845 1167 420 104 465 678
367 2026-04-27 22:42:37 1845 1166 421 96 457 679
368 2026-04-27 22:42:39 1845 1158 429 95 455 686
369 2026-04-27 22:42:41 1845 1156 431 91 451 689
370 2026-04-27 22:42:43 1845 1151 436 91 451 693
371 2026-04-27 22:42:45 1845 1156 431 99 459 689
372 2026-04-27 22:42:48 1845 1152 435 95 455 692
373 2026-04-27 22:42:50 1845 1151 436 96 457 694
374 2026-04-27 22:42:52 1845 1151 436 92 453 694
375 2026-04-27 22:42:54 1845 1157 430 100 461 688
376 2026-04-27 22:42:56 1845 1154 433 96 457 691
377 2026-04-27 22:42:58 1845 1153 434 96 457 692
378 2026-04-27 22:43:00 1845 1167 420 96 456 677
379 2026-04-27 22:43:03 1845 1162 425 90 450 683
380 2026-04-27 22:43:05 1845 1157 430 89 450 688
381 2026-04-27 22:43:07 1845 1153 434 85 446 691
382 2026-04-27 22:43:09 1845 1154 433 93 454 690
383 2026-04-27 22:43:11 1845 1151 436 89 450 694
384 2026-04-27 22:43:13 1845 1151 436 89 450 694
385 2026-04-27 22:43:15 1845 1154 432 96 457 690
386 2026-04-27 22:43:18 1845 1150 437 92 453 694
387 2026-04-27 22:43:20 1845 1160 427 100 461 685
388 2026-04-27 22:43:22 1845 1156 430 96 457 688
389 2026-04-27 22:43:24 1845 1156 430 96 457 688
390 2026-04-27 22:43:26 1845 1153 433 96 457 691
391 2026-04-27 22:43:28 1845 1153 434 96 457 692
392 2026-04-27 22:43:30 1845 1127 460 70 431 717
393 2026-04-27 22:43:33 1845 1131 456 70 431 714
394 2026-04-27 22:43:35 1845 1138 448 78 439 706
395 2026-04-27 22:43:37 1845 1134 453 74 435 711
396 2026-04-27 22:43:39 1845 1146 405 108 469 699
397 2026-04-27 22:43:41 1845 1132 419 100 461 712
398 2026-04-27 22:43:43 1845 1129 422 100 461 716
399 2026-04-27 22:43:46 1845 1128 423 98 459 717
400 2026-04-27 22:43:48 1845 1124 427 94 455 721
401 2026-04-27 22:43:50 1845 1116 434 90 451 728
402 2026-04-27 22:43:52 1845 1114 436 86 447 730
403 2026-04-27 22:43:54 1845 1111 439 86 447 733
404 2026-04-27 22:43:56 1845 1110 441 86 447 735
405 2026-04-27 22:43:59 1845 1110 441 86 447 735
406 2026-04-27 22:44:01 1845 1132 418 93 454 712
407 2026-04-27 22:44:03 1845 1116 434 85 446 728
408 2026-04-27 22:44:05 1845 1107 444 84 445 738
409 2026-04-27 22:44:07 1845 1101 450 79 440 744
410 2026-04-27 22:44:09 1845 1101 449 79 441 744
411 2026-04-27 22:44:11 1845 1101 450 79 440 744
412 2026-04-27 22:44:13 1845 1098 453 79 440 747
413 2026-04-27 22:44:16 1845 1096 455 77 438 749
414 2026-04-27 22:44:18 1845 1096 454 77 438 748
415 2026-04-27 22:44:20 1845 1107 444 87 448 738
416 2026-04-27 22:44:22 1845 1106 444 83 444 738
417 2026-04-27 22:44:24 1845 1115 435 91 452 729
418 2026-04-27 22:44:26 1845 1112 438 87 448 732
419 2026-04-27 22:44:28 1845 1112 439 87 448 733
420 2026-04-27 22:44:31 1845 1091 459 74 435 754
421 2026-04-27 22:44:33 1845 1088 462 70 431 756
422 2026-04-27 22:44:35 1845 1087 463 70 431 757
423 2026-04-27 22:44:37 1845 1086 464 70 431 758
424 2026-04-27 22:44:39 1845 1099 451 77 438 745
425 2026-04-27 22:44:41 1845 1097 453 73 434 747
426 2026-04-27 22:44:43 1845 1097 453 73 434 747
427 2026-04-27 22:44:45 1845 1128 422 100 462 716
428 2026-04-27 22:44:48 1845 1129 421 92 454 716
429 2026-04-27 22:44:50 1845 1133 416 84 446 711
430 2026-04-27 22:44:52 1845 1125 425 80 442 720
431 2026-04-27 22:44:54 1845 1124 426 85 447 721
432 2026-04-27 22:44:56 1845 1120 429 83 444 724
433 2026-04-27 22:44:58 1845 1116 434 83 444 728
434 2026-04-27 22:45:00 1845 1092 458 65 427 753
435 2026-04-27 22:45:02 1845 1092 458 65 427 753
436 2026-04-27 22:45:05 1845 1086 463 63 425 758
437 2026-04-27 22:45:07 1845 1086 463 63 425 758
438 2026-04-27 22:45:09 1845 1123 427 87 449 721
439 2026-04-27 22:45:11 1845 1122 428 80 442 723
440 2026-04-27 22:45:13 1845 1122 428 80 442 723
441 2026-04-27 22:45:15 1845 1116 434 79 440 728
442 2026-04-27 22:45:18 1845 1113 436 78 440 731
443 2026-04-27 22:45:20 1845 1107 442 76 438 737
444 2026-04-27 22:45:22 1845 1105 445 76 438 740
445 2026-04-27 22:45:24 1845 1115 434 86 448 729
446 2026-04-27 22:45:26 1845 1112 437 82 444 732
447 2026-04-27 22:45:28 1845 1112 437 82 444 732
448 2026-04-27 22:45:30 1845 1094 456 64 426 751
449 2026-04-27 22:45:32 1845 1090 459 60 422 754
450 2026-04-27 22:45:35 1845 1091 459 60 422 754
451 2026-04-27 22:45:37 1845 1090 460 60 422 755
452 2026-04-27 22:45:39 1845 1104 445 67 429 740
453 2026-04-27 22:45:41 1845 1106 443 63 425 739
454 2026-04-27 22:45:43 1845 1104 445 63 425 740
455 2026-04-27 22:45:45 1845 1109 440 71 433 735
456 2026-04-27 22:45:48 1845 1104 446 67 429 741
457 2026-04-27 22:45:50 1845 1129 420 91 453 716
458 2026-04-27 22:45:52 1845 1120 429 83 445 725
459 2026-04-27 22:45:54 1845 1147 403 89 451 698
460 2026-04-27 22:45:56 1845 1152 397 93 455 692
461 2026-04-27 22:45:58 1845 1151 399 97 459 694
462 2026-04-27 22:46:00 1845 1128 421 82 444 716
463 2026-04-27 22:46:03 1845 1126 424 82 444 719
464 2026-04-27 22:46:05 1845 1127 422 87 450 717
465 2026-04-27 22:46:07 1845 1119 430 80 443 725
466 2026-04-27 22:46:09 1845 1124 425 89 451 721
467 2026-04-27 22:46:11 1845 1119 430 85 447 726
468 2026-04-27 22:46:13 1845 1117 432 85 447 728
469 2026-04-27 22:46:15 1845 1126 423 91 453 719
470 2026-04-27 22:46:17 1845 1125 425 86 449 720
471 2026-04-27 22:46:20 1845 1120 429 87 449 725
472 2026-04-27 22:46:22 1845 1118 431 86 449 726
473 2026-04-27 22:46:24 1845 1125 424 94 456 720
474 2026-04-27 22:46:26 1845 1121 428 89 452 723
475 2026-04-27 22:46:28 1845 1120 429 89 452 724
476 2026-04-27 22:46:30 1845 1106 443 76 439 739
477 2026-04-27 22:46:32 1845 1106 443 72 435 738
478 2026-04-27 22:46:35 1845 1113 436 80 443 732
479 2026-04-27 22:46:37 1845 1113 436 76 439 731
480 2026-04-27 22:46:39 1845 1108 440 76 439 736
481 2026-04-27 22:46:41 1845 1107 442 76 439 738
482 2026-04-27 22:46:43 1845 1108 441 76 439 737
483 2026-04-27 22:46:45 1845 1146 403 103 466 698
484 2026-04-27 22:46:47 1845 1137 412 95 458 708
485 2026-04-27 22:46:49 1845 1142 407 87 450 703
486 2026-04-27 22:46:52 1845 1138 411 83 446 707
487 2026-04-27 22:46:54 1845 1136 412 87 450 708
488 2026-04-27 22:46:56 1845 1140 409 97 460 705
489 2026-04-27 22:46:58 1845 1125 423 95 458 719
490 2026-04-27 22:47:00 1845 1110 439 78 441 735
491 2026-04-27 22:47:02 1845 1099 449 71 434 745
492 2026-04-27 22:47:05 1845 1098 450 68 432 746
493 2026-04-27 22:47:07 1845 1098 450 68 431 746
494 2026-04-27 22:47:09 1845 1099 449 71 434 745
495 2026-04-27 22:47:11 1845 1098 450 69 432 746
496 2026-04-27 22:47:13 1845 1099 449 69 432 745
497 2026-04-27 22:47:15 1845 1097 451 69 432 748
498 2026-04-27 22:47:17 1845 1097 451 69 432 748
499 2026-04-27 22:47:19 1845 1120 428 96 459 724
500 2026-04-27 22:47:22 1845 1124 424 90 453 720
501 2026-04-27 22:47:24 1845 1142 406 90 453 703
502 2026-04-27 22:47:26 1845 1141 407 92 455 704
503 2026-04-27 22:47:28 1845 1132 416 92 455 713
504 2026-04-27 22:47:30 1845 1110 438 70 434 735
505 2026-04-27 22:47:33 1845 1105 444 70 433 740
506 2026-04-27 22:47:35 1845 1096 453 61 425 749
507 2026-04-27 22:47:37 1845 1093 456 59 423 752
508 2026-04-27 22:47:39 1845 1103 445 70 433 742
509 2026-04-27 22:47:41 1845 1103 445 65 429 741
510 2026-04-27 22:47:43 1845 1100 448 65 429 744
511 2026-04-27 22:47:45 1845 1108 440 73 437 737
512 2026-04-27 22:47:48 1845 1104 444 69 433 740
513 2026-04-27 22:47:50 1845 1108 440 68 431 737
514 2026-04-27 22:47:52 1845 1103 445 64 427 741
515 2026-04-27 22:47:54 1845 1111 437 76 439 733
516 2026-04-27 22:47:56 1845 1113 435 72 435 731
517 2026-04-27 22:47:58 1845 1112 436 72 435 732
518 2026-04-27 22:48:00 1845 1099 449 63 426 746
519 2026-04-27 22:48:03 1845 1099 449 59 422 746
520 2026-04-27 22:48:05 1845 1143 405 93 456 701
521 2026-04-27 22:48:07 1845 1138 410 84 448 707
522 2026-04-27 22:48:09 1845 1128 420 83 446 717
523 2026-04-27 22:48:11 1845 1120 428 79 442 724
524 2026-04-27 22:48:13 1845 1120 428 79 442 725
525 2026-04-27 22:48:15 1845 1118 430 81 445 726
526 2026-04-27 22:48:17 1845 1115 433 79 443 729
527 2026-04-27 22:48:20 1845 1115 433 82 445 729
528 2026-04-27 22:48:22 1845 1115 433 80 443 730
529 2026-04-27 22:48:24 1845 1120 428 86 450 725
530 2026-04-27 22:48:26 1845 1118 430 82 446 726
531 2026-04-27 22:48:28 1845 1120 428 82 446 725
532 2026-04-27 22:48:30 1845 1098 449 67 430 746
533 2026-04-27 22:48:33 1845 1098 450 67 430 746
534 2026-04-27 22:48:35 1845 1110 437 75 438 734
535 2026-04-27 22:48:37 1845 1108 440 71 434 736
536 2026-04-27 22:48:39 1845 1150 397 98 461 694
537 2026-04-27 22:48:41 1845 1141 407 90 453 703
538 2026-04-27 22:48:43 1845 1141 407 90 453 704
539 2026-04-27 22:48:45 1845 1128 420 84 447 717
540 2026-04-27 22:48:47 1845 1123 424 81 445 721
541 2026-04-27 22:48:50 1845 1176 372 109 472 669
542 2026-04-27 22:48:52 1845 1164 384 100 464 680
543 2026-04-27 22:48:54 1845 1139 408 81 444 705
544 2026-04-27 22:48:56 1845 1138 409 80 444 706
545 2026-04-27 22:48:58 1845 1133 415 80 444 712
546 2026-04-27 22:49:00 1845 1107 441 57 421 737
547 2026-04-27 22:49:03 1845 1105 443 57 421 740
548 2026-04-27 22:49:05 1845 1112 436 71 435 732
549 2026-04-27 22:49:07 1845 1111 436 67 431 733
550 2026-04-27 22:49:09 1845 1108 440 68 431 737
551 2026-04-27 22:49:11 1845 1107 441 68 431 738
552 2026-04-27 22:49:13 1845 1106 441 68 431 738
553 2026-04-27 22:49:15 1845 1116 432 76 439 729
554 2026-04-27 22:49:18 1845 1116 432 72 435 729
555 2026-04-27 22:49:20 1845 1118 429 80 443 726
556 2026-04-27 22:49:22 1845 1115 433 75 439 730
557 2026-04-27 22:49:24 1845 1121 427 83 447 724
558 2026-04-27 22:49:26 1845 1122 425 79 443 722
559 2026-04-27 22:49:28 1845 1119 429 79 443 726
560 2026-04-27 22:49:30 1845 1102 446 63 427 743
561 2026-04-27 22:49:33 1845 1098 449 59 423 746
562 2026-04-27 22:49:35 1845 1107 441 67 431 738
563 2026-04-27 22:49:37 1845 1105 443 63 427 740
564 2026-04-27 22:49:39 1845 1114 433 71 435 730
565 2026-04-27 22:49:41 1845 1112 436 67 431 733
566 2026-04-27 22:49:43 1845 1110 438 67 431 735
567 2026-04-27 22:49:45 1845 1116 432 77 441 729
568 2026-04-27 22:49:48 1845 1116 431 76 440 728
569 2026-04-27 22:49:50 1845 1120 428 82 446 725
570 2026-04-27 22:49:52 1845 1119 429 81 445 726
571 2026-04-27 22:49:54 1845 1128 419 85 449 717
572 2026-04-27 22:49:56 1845 1124 423 78 443 720
573 2026-04-27 22:49:58 1845 1125 422 76 440 720
574 2026-04-27 22:50:01 1845 1117 430 67 431 727
575 2026-04-27 22:50:03 1845 1112 435 66 430 732
576 2026-04-27 22:50:05 1845 1109 438 69 433 735
577 2026-04-27 22:50:07 1845 1109 439 68 432 736
578 2026-04-27 22:50:09 1845 1113 434 74 438 731
579 2026-04-27 22:50:11 1845 1105 442 68 433 740
580 2026-04-27 22:50:13 1845 1101 445 66 430 743
581 2026-04-27 22:50:16 1845 1116 431 75 440 729
582 2026-04-27 22:50:18 1845 1113 433 71 436 731
583 2026-04-27 22:50:20 1845 1121 426 79 444 724
584 2026-04-27 22:50:22 1845 1119 427 79 444 726
585 2026-04-27 22:50:24 1845 1122 425 81 446 723
586 2026-04-27 22:50:26 1845 1121 426 79 444 724
587 2026-04-27 22:50:28 1845 1115 431 80 445 729
588 2026-04-27 22:50:31 1845 1100 447 61 426 745
589 2026-04-27 22:50:33 1845 1100 446 61 426 744
590 2026-04-27 22:50:35 1845 1095 452 57 422 750
591 2026-04-27 22:50:37 1845 1095 451 57 422 749
592 2026-04-27 22:50:39 1845 1091 456 56 421 754
593 2026-04-27 22:50:41 1845 1091 456 56 421 754
594 2026-04-27 22:50:43 1845 1092 455 56 421 753
595 2026-04-27 22:50:45 1845 1094 453 56 421 751
596 2026-04-27 22:50:48 1845 1093 454 56 421 752
597 2026-04-27 22:50:50 1845 1101 445 64 429 743
598 2026-04-27 22:50:52 1845 1097 449 60 425 747
599 2026-04-27 22:50:54 1845 1105 441 67 432 739
600 2026-04-27 22:50:56 1845 1101 445 63 428 744
601 2026-04-27 22:50:58 1845 1102 444 63 428 743
602 2026-04-27 22:51:00 1845 1106 440 59 424 738
603 2026-04-27 22:51:03 1845 1105 441 56 422 739
604 2026-04-27 22:51:05 1845 1101 445 57 422 743
605 2026-04-27 22:51:07 1845 1102 444 56 422 742
606 2026-04-27 22:51:09 1845 1147 399 91 457 698
607 2026-04-27 22:51:11 1845 1138 408 82 448 707
608 2026-04-27 22:51:13 1845 1140 406 81 447 705
609 2026-04-27 22:51:16 1845 1154 392 93 458 691
610 2026-04-27 22:51:18 1845 1128 417 80 445 716
611 2026-04-27 22:51:20 1845 1139 407 88 454 705
612 2026-04-27 22:51:22 1845 1130 415 78 444 714
613 2026-04-27 22:51:24 1845 1126 420 80 446 719
614 2026-04-27 22:51:26 1845 1126 420 80 445 719
615 2026-04-27 22:51:29 1845 1124 421 80 445 720
616 2026-04-27 22:51:31 1845 1106 440 66 432 739
617 2026-04-27 22:51:33 1845 1106 440 66 432 739
618 2026-04-27 22:51:35 1845 1113 433 74 440 732
619 2026-04-27 22:51:37 1845 1113 433 70 436 731
620 2026-04-27 22:51:39 1845 1116 430 78 444 729
621 2026-04-27 22:51:42 1845 1115 430 74 440 729
622 2026-04-27 22:51:44 1845 1115 431 74 440 730
623 2026-04-27 22:51:46 1845 1121 425 78 443 723
624 2026-04-27 22:51:48 1845 1121 425 78 443 724
625 2026-04-27 22:51:50 1845 1117 428 78 444 727
626 2026-04-27 22:51:52 1845 1118 428 78 443 727
627 2026-04-27 22:51:54 1845 1121 425 81 447 724
628 2026-04-27 22:51:57 1845 1119 426 79 445 725
629 2026-04-27 22:51:59 1845 1100 445 60 426 744
630 2026-04-27 22:52:01 1845 1102 444 60 426 743
631 2026-04-27 22:52:03 1845 1102 443 60 426 742
632 2026-04-27 22:52:05 1845 1102 443 60 427 743
633 2026-04-27 22:52:07 1845 1100 445 60 426 744
634 2026-04-27 22:52:09 1845 1081 464 49 416 764
635 2026-04-27 22:52:12 1845 876 619 49 475 969
636 2026-04-27 22:52:14 1845 940 550 49 483 905
637 2026-04-27 22:52:16 1845 963 531 57 491 882
638 2026-04-27 22:52:18 1845 965 528 61 495 879
639 2026-04-27 22:52:20 1845 976 518 70 504 869
640 2026-04-27 22:52:22 1845 972 522 70 503 872
641 2026-04-27 22:52:25 1845 974 520 74 508 871
642 2026-04-27 22:52:27 1845 965 528 67 501 879
643 2026-04-27 22:52:29 1845 962 532 66 500 883
644 2026-04-27 22:52:31 1845 962 532 65 499 883
645 2026-04-27 22:52:33 1845 963 531 65 499 881
646 2026-04-27 22:52:35 1845 951 543 58 492 894
647 2026-04-27 22:52:37 1845 952 542 58 492 893
648 2026-04-27 22:52:39 1845 961 533 66 500 884
649 2026-04-27 22:52:42 1845 955 538 62 496 889
650 2026-04-27 22:52:44 1845 957 536 62 496 887
651 2026-04-27 22:52:46 1845 966 528 66 500 879
652 2026-04-27 22:52:48 1845 964 530 66 500 881
653 2026-04-27 22:52:50 1845 973 521 74 508 872
654 2026-04-27 22:52:52 1845 969 525 70 504 876
655 2026-04-27 22:52:54 1845 968 525 70 504 876
656 2026-04-27 22:52:56 1845 968 525 70 504 876
657 2026-04-27 22:52:59 1845 967 526 70 504 877
658 2026-04-27 22:53:01 1845 955 535 60 497 889
659 2026-04-27 22:53:03 1845 955 536 60 497 890
660 2026-04-27 22:53:05 1845 1008 486 93 531 837
661 2026-04-27 22:53:07 1845 1006 488 85 522 839
662 2026-04-27 22:53:09 1845 1002 493 83 520 843
663 2026-04-27 22:53:11 1845 998 496 79 516 847
664 2026-04-27 22:53:14 1845 997 497 79 516 848
665 2026-04-27 22:53:16 1845 990 504 80 517 855
666 2026-04-27 22:53:18 1845 988 507 76 513 857
667 2026-04-27 22:53:20 1845 991 506 80 517 853
668 2026-04-27 22:53:22 1845 988 509 77 515 857
669 2026-04-27 22:53:24 1845 985 513 78 515 860
670 2026-04-27 22:53:26 1845 986 512 77 515 859
671 2026-04-27 22:53:29 1845 985 512 77 515 859
672 2026-04-27 22:53:31 1845 967 530 59 497 878
673 2026-04-27 22:53:33 1845 962 536 59 497 883
674 2026-04-27 22:53:35 1845 972 526 69 507 873
675 2026-04-27 22:53:37 1845 971 526 65 503 873
676 2026-04-27 22:53:39 1845 980 517 72 510 864
677 2026-04-27 22:53:41 1845 978 519 68 506 866
678 2026-04-27 22:53:44 1845 977 520 68 506 867
679 2026-04-27 22:53:46 1845 986 511 77 514 859
680 2026-04-27 22:53:48 1845 981 517 76 514 864
681 2026-04-27 22:53:50 1845 992 506 86 524 853
682 2026-04-27 22:53:52 1845 993 505 85 523 852
683 2026-04-27 22:53:54 1845 993 504 87 525 851
684 2026-04-27 22:53:57 1845 989 508 81 518 856
685 2026-04-27 22:53:59 1845 986 511 81 518 858
686 2026-04-27 22:54:01 1845 969 528 60 498 876
687 2026-04-27 22:54:03 1845 962 535 60 498 882
688 2026-04-27 22:54:05 1845 973 524 66 504 871
689 2026-04-27 22:54:07 1845 969 528 62 500 876
690 2026-04-27 22:54:10 1845 974 523 70 508 871
691 2026-04-27 22:54:12 1845 969 528 66 504 876
692 2026-04-27 22:54:14 1845 973 523 66 504 871
693 2026-04-27 22:54:16 1845 983 514 70 508 862
694 2026-04-27 22:54:18 1845 981 515 70 508 863
695 2026-04-27 22:54:20 1845 1024 473 104 542 821
696 2026-04-27 22:54:22 1845 1021 475 95 534 823
697 2026-04-27 22:54:24 1845 1010 486 94 532 834
698 2026-04-27 22:54:27 1845 1009 488 90 528 836
699 2026-04-27 22:54:29 1845 1006 490 90 528 838
700 2026-04-27 22:54:31 1845 990 507 76 514 855
701 2026-04-27 22:54:33 1845 988 508 76 514 856
702 2026-04-27 22:54:35 1845 1000 496 83 522 844
703 2026-04-27 22:54:37 1845 1253 242 149 590 592
704 2026-04-27 22:54:40 1845 1268 231 158 598 577
705 2026-04-27 22:54:42 1845 1307 193 166 615 538
706 2026-04-27 22:54:44 1845 1343 122 162 645 501
707 2026-04-27 22:54:46 1845 1373 66 115 604 472
708 2026-04-27 22:54:48 1845 1374 73 107 596 471
709 2026-04-27 22:54:51 1845 1391 68 103 592 454
710 2026-04-27 22:54:53 1845 1368 63 103 591 476
711 2026-04-27 22:54:56 1845 1451 57 103 507 394
712 2026-04-27 22:54:59 1845 1632 58 83 334 213
713 2026-04-27 22:55:01 1845 1496 165 55 305 348
714 2026-04-27 22:55:03 1845 1517 107 72 359 328
715 2026-04-27 22:55:05 1845 1517 107 62 349 327
716 2026-04-27 22:55:07 1845 1515 108 57 344 329
717 2026-04-27 22:55:09 1845 1517 106 57 344 327
718 2026-04-27 22:55:12 1845 1512 112 53 341 333
719 2026-04-27 22:55:14 1845 1505 119 53 341 340
720 2026-04-27 22:55:16 1845 1497 124 44 335 348
721 2026-04-27 22:55:18 1845 1492 128 44 335 352
722 2026-04-27 22:55:20 1845 1487 133 44 336 358
723 2026-04-27 22:55:23 1845 1482 137 44 336 362
724 2026-04-27 22:55:25 1845 1481 138 44 336 363
725 2026-04-27 22:55:27 1845 1478 142 44 336 367
726 2026-04-27 22:55:29 1845 1479 141 44 336 365
727 2026-04-27 22:55:31 1845 1479 141 44 336 366
728 2026-04-27 22:55:33 1845 1528 87 74 370 316
729 2026-04-27 22:55:36 1845 1533 82 76 372 311
730 2026-04-27 22:55:38 1845 1544 71 83 379 300
731 2026-04-27 22:55:40 1845 1557 58 90 386 287
732 2026-04-27 22:55:42 1845 1572 43 92 388 272
733 2026-04-27 22:55:44 1845 1603 31 100 396 241
734 2026-04-27 22:55:46 1845 1588 66 108 376 257
735 2026-04-27 22:55:48 1845 1586 64 115 376 258
736 2026-04-27 22:55:51 1845 1609 41 121 383 236
737 2026-04-27 22:55:53 1845 1624 25 129 390 221
738 2026-04-27 22:55:55 1845 1617 65 131 360 227
739 2026-04-27 22:55:57 1845 1658 44 139 367 187
740 2026-04-27 22:55:59 1845 1655 41 146 362 190
741 2026-04-27 22:56:01 1845 1670 41 150 350 175
742 2026-04-27 22:56:04 1845 1684 28 157 357 161
743 2026-04-27 22:56:06 1845 1668 47 165 364 177
744 2026-04-27 22:56:08 1845 1674 38 172 372 171
745 2026-04-27 22:56:10 1845 1722 39 180 349 122
746 2026-04-27 22:56:13 1845 1700 74 188 349 145
747 2026-04-27 22:56:15 1845 1713 82 197 333 132
748 2026-04-27 22:56:17 1845 1700 83 205 340 145
749 2026-04-27 22:56:19 1845 1712 82 212 348 132
750 2026-04-27 22:56:21 1845 1705 93 219 356 140
751 2026-04-27 22:56:24 1845 1658 112 227 369 187
752 2026-04-27 22:56:26 1845 1673 104 234 376 172
753 2026-04-27 22:56:28 1845 1714 79 242 385 131
754 2026-04-27 22:56:30 1845 1734 67 247 389 111
755 2026-04-27 22:56:32 1845 1742 59 249 392 102
756 2026-04-27 22:56:34 1845 1706 63 252 394 139
757 2026-04-27 22:56:37 1845 1654 99 175 334 191
758 2026-04-27 22:56:39 1845 1664 88 182 341 180
759 2026-04-27 22:56:41 1845 1681 71 192 351 163
760 2026-04-27 22:56:43 1845 1701 51 200 360 144
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
timestamp,process_name,pid,%mem,rss_MB,command
1 timestamp process_name pid %mem rss_MB command
@@ -0,0 +1 @@
timestamp,process_name,pid,%mem,rss_MB,command
1 timestamp process_name pid %mem rss_MB command
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# Output CSV file
CSV_FILE="memory_log.csv"
# Process names to track
PROCESSES_TO_TRACK=("photobooth" "chromium" "firefox") # Add more as needed
# Create system memory CSV if it doesn't exist
if [ ! -f "$MEM_LOG" ]; then
echo "timestamp,total_MB,used_MB,free_MB,shared_MB,buff/cache_MB,available_MB" > "$MEM_LOG"
fi
# Create individual CSVs for each process name if missing
for pname in "${PROCESSES_TO_TRACK[@]}"; do
fname="process_${pname}.csv"
if [ ! -f "$fname" ]; then
echo "timestamp,process_name,pid,%mem,rss_MB,command" > "$fname"
fi
done
# Initialize CSV file with headers
if [ ! -f "$CSV_FILE" ]; then
echo "timestamp,total_MB,used_MB,free_MB,shared_MB,cache_MB,available_MB" > "$CSV_FILE"
fi
# Function to get memory usage and append to CSV
# Adjust "Speicher" to Mem or Swap, see free -m
log_mem_usage() {
free -m | awk -v tstamp="$(date '+%Y-%m-%d %H:%M:%S')" '
/^Mem:/ {
printf "%s,%s,%s,%s,%s,%s,%s\n", tstamp, $2, $3, $4, $5, $6, $7
}
' >> "$CSV_FILE"
}
log_process_mem() {
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
for pname in "${PROCESSES_TO_TRACK[@]}"; do
outfile="process_${pname}.csv"
ps -C "$pname" -o pid,rss,cmd --no-headers | while read -r pid rss cmd; do
# Convert RSS from KB to integer MB
rss_mb=$((rss / 1024))
echo "$timestamp,$pname,$pid,$rss_mb,\"$cmd\"" >> "$outfile"
done
done
}
# Trap Ctrl+C and exit gracefully
trap "echo -e '\nExiting on Ctrl+C'; exit 0" SIGINT
while true; do
free -h
log_mem_usage
log_process_mem
# curl -X 'GET' \
# 'http://localhost:8083/api/actions/image/0' \
# -H 'accept: application/json'
# echo
sleep 2
done
+126
View File
@@ -0,0 +1,126 @@
#!/bin/bash
# =============================================================================
# script_print.sh - Impression photo pour photomaton
# =============================================================================
# Usage: script_print.sh "<filename>" "<media_type>" "<action_config_name>" "<copies>"
# Appele par photobooth-app via l'action Share "Impression"
#
# Imprimantes: Canon Selphy CP1300 x2 via WiFi (blanche + noire)
# Load balancing: utilise l'imprimante idle, sinon file d'attente sur la 1ere
#
# Sortie stdout parsable par printer_service.py:
# PRINTED:<printer_name>:<copies> en cas de succes
# PRINT_ERROR:<printer_name>:<raison> en cas d'erreur
# =============================================================================
#
# ── CALIBRAGE IMPRESSION ─────────────────────────────────────────────────────
#
# Selphy CP1300 — papier Postcard borderless 100x148mm
# Image traitée par photobooth-app : 2000x1333
# Overscan : 2114x1418 avec fond blanc (la Selphy rogne les bords en borderless)
# Décalage : -roll -12+8 (calibré pour l'offset naturel de l'imprimante)
# Option CUPS : PageSize=Postcard.Borderless (défaut imprimante = om_postcard-borderless_100x148mm)
# PAS de -o raw : le driver CUPS gère le rendu correctement
# =============================================================================
# (plus de TEST_MODE — paramètres calibrés définitifs ci-dessous)
set -uo pipefail
# --- Parametres ---
filename="${1:-}"
media_type="${2:-}"
action_config_name="${3:-}"
copies="${4:-1}"
# --- Noms des imprimantes CUPS (WiFi) ---
PRINTER_1="Selphy_Blanche_WiFi"
PRINTER_2="Selphy_Noire_WiFi"
# --- Logging ---
LOG_TAG="photomaton-print"
log_info() { logger -t "$LOG_TAG" "[INFO] $1"; echo "[INFO] $1"; }
log_error() { logger -t "$LOG_TAG" "[ERROR] $1"; echo "[ERROR] $1"; }
# --- Validation ---
if [ -z "$filename" ]; then
log_error "Aucun fichier specifie"
exit 1
fi
if [ ! -f "$filename" ]; then
log_error "Fichier introuvable: $filename"
exit 1
fi
if [ "$copies" -lt 1 ] || [ "$copies" -gt 3 ]; then
log_error "Nombre de copies invalide: $copies (doit etre entre 1 et 3)"
exit 1
fi
# --- Preparation de l'image pour impression ---
filename_no_ext="${filename%.jpg}"
filename_print="${filename_no_ext}_print.jpg"
if ! command -v convert &>/dev/null; then
log_error "ImageMagick (convert) non installe — impression de l'original"
FILE_TO_PRINT="$filename"
PRINT_OPTIONS="-o PageSize=Postcard.Borderless"
else
log_info "Preparation image pour impression borderless..."
if convert "$filename" \
-resize 2000x1333 \
-background white \
-gravity center \
-extent 2114x1418 \
-roll -12+8 \
-quality 95 \
"$filename_print" 2>/dev/null; then
FILE_TO_PRINT="$filename_print"
PRINT_OPTIONS="-o PageSize=Postcard.Borderless"
log_info "Image preparee: 2114x1418 (overscan+roll) → $filename_print"
else
log_error "Echec convert — impression de l'original"
FILE_TO_PRINT="$filename"
PRINT_OPTIONS="-o PageSize=Postcard.Borderless"
fi
fi
# --- Selection de l'imprimante (load balancing) ---
printer_1_status=$(lpstat -p "$PRINTER_1" 2>/dev/null || echo "not found")
printer_2_status=$(lpstat -p "$PRINTER_2" 2>/dev/null || echo "not found")
SELECTED_PRINTER=""
if [[ "$printer_1_status" == *"idle"* ]]; then
SELECTED_PRINTER="$PRINTER_1"
log_info "Imprimante selectionnee: $PRINTER_1 (idle)"
elif [[ "$printer_2_status" == *"idle"* ]]; then
SELECTED_PRINTER="$PRINTER_2"
log_info "Imprimante selectionnee: $PRINTER_2 (idle)"
else
jobs_1=$(lpstat -o "$PRINTER_1" 2>/dev/null | wc -l)
jobs_2=$(lpstat -o "$PRINTER_2" 2>/dev/null | wc -l)
if [ "$jobs_1" -le "$jobs_2" ] 2>/dev/null; then
SELECTED_PRINTER="$PRINTER_1"
else
SELECTED_PRINTER="$PRINTER_2"
fi
log_info "Aucune imprimante idle, file sur: $SELECTED_PRINTER (P1=$jobs_1 jobs, P2=$jobs_2 jobs)"
fi
# --- Impression ---
log_info "Impression x${copies} sur $SELECTED_PRINTER [mode ${TEST_MODE}]: $FILE_TO_PRINT"
job_output=$(lp -n "$copies" -d "$SELECTED_PRINTER" $PRINT_OPTIONS "$FILE_TO_PRINT" 2>&1)
lp_exit=$?
if [ $lp_exit -ne 0 ]; then
log_error "lp a echoue (exit $lp_exit): $job_output"
echo "PRINT_ERROR:${SELECTED_PRINTER}:lp_failed"
[ -f "$filename_print" ] && rm -f "$filename_print" 2>/dev/null
exit 1
fi
# Extraction du job ID (ex: "request id is Selphy_Blanche_WiFi-42 (1 file(s
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_2" data-name="Calque 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 365.26 350.96">
<defs>
<style>
.cls-1, .cls-2 {
fill: #fff;
}
.cls-3 {
fill: #62b037;
}
.cls-3, .cls-2, .cls-4, .cls-5 {
stroke-miterlimit: 10;
}
.cls-3, .cls-4, .cls-5 {
stroke: #fff;
}
.cls-2 {
stroke: #62b037;
}
.cls-4 {
stroke-width: 2px;
}
.cls-4, .cls-5 {
fill: none;
stroke-linecap: round;
}
.cls-5 {
stroke-width: 4px;
}
</style>
</defs>
<g id="Calque_1-2" data-name="Calque 1">
<path class="cls-4" d="M209.26,324.83c-10.87,2.45-22.17,3.74-33.78,3.74-45.65,0-86.64-19.98-114.69-51.68"/>
<path class="cls-4" d="M100.64,41.9c22.13-12.42,47.66-19.51,74.84-19.51,45.53,0,86.41,19.87,114.45,51.42"/>
<polygon class="cls-2" points="100.33 73.42 85.62 51.72 150.14 73.8 100.33 73.42"/>
<path class="cls-3" d="M166.78,313.26s-.17-26.19,0-39.9l-49.47,15.84c15.8,8.03,49.47,24.06,49.47,24.06Z"/>
<path class="cls-2" d="M167.68,245.83l-50.75-33.83-1.28,76.93c16.63-5.15,35.31-11.15,51.94-16.3,0-8.66,.09-18.14,.09-26.8Z"/>
<polygon class="cls-2" points="69.46 121.69 50.67 161.43 95.54 177.13 115.45 144.35 69.46 121.69"/>
<polygon class="cls-2" points="108.69 189.32 95.93 176.55 116.57 143.56 108.69 189.32"/>
<path class="cls-3" d="M63.5,224.79c-4.24-19.83-7.93-42.53-12.17-62.35,15.08,4.39,29.03,10.85,44.11,15.24-10.85,15.44-21.1,31.68-31.95,47.12Z"/>
<polygon class="cls-3" points="129.73 212.86 96.31 178.14 64.18 225.43 129.73 212.86"/>
<path class="cls-3" d="M94.82,276.17c-6.91-9.61-14.3-18.61-21.2-28.22,11.75-4.04,23.34-7.16,35.09-11.2-4.84,13.65-9.04,25.77-13.89,39.42Z"/>
<path class="cls-3" d="M139.79,256.38l-30.57-19.63-13.44,39.42,44-19.65c.06-.03,.07-.11,.01-.14Z"/>
<polygon class="cls-2" points="139.25 254.99 146.72 228.32 118.15 207.29 109 235.74 139.25 254.99"/>
<polygon class="cls-2" points="80.55 210.38 73.62 246.87 109 235.74 118.15 207.29 80.55 210.38"/>
<path class="cls-3" d="M166.78,192.76v53.7l-65.82-46.58c14.03-7.99,28.97-14.76,43-22.75,7.91,5.42,14.91,10.21,22.82,15.63Z"/>
<path class="cls-2" d="M109.46,155.13c-2.79,15.22-6.04,28.85-8.82,44.07,14.13-7.34,28.27-14.69,42.4-22.03-11.35-7.88-22.23-14.17-33.58-22.04Z"/>
<path class="cls-2" d="M35.93,115.72c15.11-19.86,35.38-43.48,50.49-63.33,4.74,7.16,9.48,14.32,14.22,21.48-19.85,12.7-44.86,29.16-64.71,41.85Z"/>
<polygon class="cls-3" points="96.37 134.93 39.25 114.36 100.47 74.77 96.37 134.93"/>
<polygon class="cls-3" points="150.53 74.64 101.4 74.37 96.79 134.01 150.53 74.64"/>
<polygon class="cls-3" points="166.81 192.33 166.81 126.44 92.65 142.93 166.81 192.33"/>
<polygon class="cls-2" points="167.64 125.57 167.95 52.24 96.1 99.62 92.08 142.03 167.64 125.57"/>
<g>
<path class="cls-1" d="M212.71,175.55v-35.5h-12.85v35.5h-10.45V97.36h10.45v33.21h12.85v-33.21h10.45v78.18h-10.45Z"/>
<path class="cls-1" d="M245.05,175.55c-8.42,0-12.63-4.43-12.63-13.28v-51.5c0-8.93,4.17-13.39,12.52-13.39h10.02c8.35,0,12.52,4.46,12.52,13.39v51.5c0,8.86-4.21,13.28-12.63,13.28h-9.8Zm2.5-9.47h5.34c2.76,0,4.14-1.52,4.14-4.57v-50.09c0-3.05-1.56-4.57-4.68-4.57h-4.9c-3.05,0-4.57,1.52-4.57,4.57v50.09c0,3.05,1.56,4.57,4.68,4.57Z"/>
<path class="cls-1" d="M276.74,97.36h19.06c8.42,0,12.63,4.46,12.63,13.39v23.19c0,8.86-4.18,13.28-12.52,13.28h-8.71v28.31h-10.45V97.36Zm10.45,40.4h6.64c2.76,0,4.14-1.52,4.14-4.57v-21.78c0-3.05-1.56-4.57-4.68-4.57h-6.1v30.92Z"/>
<path class="cls-1" d="M313.43,126l5.01-14.05h-5.12v-14.7h7.3c2.9,0,4.36,1.45,4.36,4.36v10.34l-5.23,14.05h-6.32Z"/>
<path class="cls-1" d="M354.81,135.15v-37.78h10.45v78.18h-10.45v-15.57l-11.22-35.61v51.18h-10.45V97.36h10.45l11.22,37.78Z"/>
<path class="cls-1" d="M213.69,241.63c5.73,1.6,8.6,4.57,8.6,8.93v19.6c0,7.77-3.96,11.65-11.87,11.65h-21.02v-78.18h17.86c9.29,0,13.94,4.72,13.94,14.16v15.03c0,4.5-2.5,7.26-7.51,8.28v.54Zm-13.83-5.01h4.9c3.99,0,5.99-1.89,5.99-5.66v-12.2c0-3.77-1.56-5.66-4.68-5.66h-6.21v23.52Zm0,35.72h7.84c2.76,0,4.14-1.12,4.14-3.38v-17.31c0-3.7-2.07-5.55-6.21-5.55h-5.77v26.24Z"/>
<path class="cls-1" d="M255.83,281.81h-24.83v-78.18h10.45v68.71h14.37v9.47Z"/>
<path class="cls-1" d="M273.9,281.81c-8.42,0-12.63-4.43-12.63-13.28v-51.5c0-8.93,4.17-13.39,12.52-13.39h10.02c8.35,0,12.52,4.46,12.52,13.39v51.5c0,8.86-4.21,13.28-12.63,13.28h-9.8Zm2.5-9.47h5.34c2.76,0,4.14-1.52,4.14-4.57v-50.09c0-3.05-1.56-4.57-4.68-4.57h-4.9c-3.05,0-4.57,1.52-4.57,4.57v50.09c0,3.05,1.56,4.57,4.68,4.57Z"/>
<path class="cls-1" d="M336.84,263.52v5.01c0,8.86-4.18,13.28-12.52,13.28h-6.75c-8.35,0-12.52-4.43-12.52-13.28v-51.61c0-8.86,4.17-13.28,12.52-13.28h6.75c8.35,0,12.52,4.43,12.52,13.28v8.82h-10.45v-8.06c0-3.05-1.56-4.57-4.68-4.57h-2.07c-2.76,0-4.14,1.52-4.14,4.57v50.09c0,3.05,1.38,4.57,4.14,4.57h2.61c2.76,0,4.14-1.52,4.14-4.57v-4.25h10.45Z"/>
</g>
<path class="cls-5" d="M2,175.48C2,79.67,79.67,2,175.48,2c58.6,0,110.42,29.06,141.83,73.56"/>
<path class="cls-5" d="M289.94,305.84c-30.55,26.84-70.6,43.11-114.46,43.11C79.67,348.96,2,271.29,2,175.48"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_2" data-name="Calque 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 365.26 350.96">
<defs>
<style>
.cls-1, .cls-2 {
fill: #fff;
}
.cls-3 {
fill: #184437;
}
.cls-3, .cls-4, .cls-5 {
stroke: #fff;
}
.cls-3, .cls-4, .cls-5, .cls-2 {
stroke-miterlimit: 10;
}
.cls-4 {
stroke-width: 2px;
}
.cls-4, .cls-5 {
fill: none;
stroke-linecap: round;
}
.cls-5 {
stroke-width: 4px;
}
.cls-2 {
stroke: #184437;
}
</style>
</defs>
<g id="Calque_1-2" data-name="Calque 1">
<path class="cls-4" d="M209.26,324.83c-10.87,2.45-22.17,3.74-33.78,3.74-45.65,0-86.64-19.98-114.69-51.68"/>
<path class="cls-4" d="M100.64,41.9c22.13-12.42,47.66-19.51,74.84-19.51,45.53,0,86.41,19.87,114.45,51.42"/>
<polygon class="cls-2" points="100.33 73.42 85.62 51.72 150.14 73.8 100.33 73.42"/>
<path class="cls-3" d="M166.78,313.26s-.17-26.19,0-39.9l-49.47,15.84c15.8,8.03,49.47,24.06,49.47,24.06Z"/>
<path class="cls-2" d="M167.68,245.83l-50.75-33.83-1.28,76.93c16.63-5.15,35.31-11.15,51.94-16.3,0-8.66,.09-18.14,.09-26.8Z"/>
<polygon class="cls-2" points="69.46 121.69 50.67 161.43 95.54 177.13 115.45 144.35 69.46 121.69"/>
<polygon class="cls-2" points="108.69 189.32 95.93 176.55 116.57 143.56 108.69 189.32"/>
<path class="cls-3" d="M63.5,224.79c-4.24-19.83-7.93-42.53-12.17-62.35,15.08,4.39,29.03,10.85,44.11,15.24-10.85,15.44-21.1,31.68-31.95,47.12Z"/>
<polygon class="cls-3" points="129.73 212.86 96.31 178.14 64.18 225.43 129.73 212.86"/>
<path class="cls-3" d="M94.82,276.17c-6.91-9.61-14.3-18.61-21.2-28.22,11.75-4.04,23.34-7.16,35.09-11.2-4.84,13.65-9.04,25.77-13.89,39.42Z"/>
<path class="cls-3" d="M139.79,256.38l-30.57-19.63-13.44,39.42,44-19.65c.06-.03,.07-.11,.01-.14Z"/>
<polygon class="cls-2" points="139.25 254.99 146.72 228.32 118.15 207.29 109 235.74 139.25 254.99"/>
<polygon class="cls-2" points="80.55 210.38 73.62 246.87 109 235.74 118.15 207.29 80.55 210.38"/>
<path class="cls-3" d="M166.78,192.76v53.7l-65.82-46.58c14.03-7.99,28.97-14.76,43-22.75,7.91,5.42,14.91,10.21,22.82,15.63Z"/>
<path class="cls-2" d="M109.46,155.13c-2.79,15.22-6.04,28.85-8.82,44.07,14.13-7.34,28.27-14.69,42.4-22.03-11.35-7.88-22.23-14.17-33.58-22.04Z"/>
<path class="cls-2" d="M35.93,115.72c15.11-19.86,35.38-43.48,50.49-63.33,4.74,7.16,9.48,14.32,14.22,21.48-19.85,12.7-44.86,29.16-64.71,41.85Z"/>
<polygon class="cls-3" points="96.37 134.93 39.25 114.36 100.47 74.77 96.37 134.93"/>
<polygon class="cls-3" points="150.53 74.64 101.4 74.37 96.79 134.01 150.53 74.64"/>
<polygon class="cls-3" points="166.81 192.33 166.81 126.44 92.65 142.93 166.81 192.33"/>
<polygon class="cls-2" points="167.64 125.57 167.95 52.24 96.1 99.62 92.08 142.03 167.64 125.57"/>
<g>
<path class="cls-1" d="M212.71,175.55v-35.5h-12.85v35.5h-10.45V97.36h10.45v33.21h12.85v-33.21h10.45v78.18h-10.45Z"/>
<path class="cls-1" d="M245.05,175.55c-8.42,0-12.63-4.43-12.63-13.28v-51.5c0-8.93,4.17-13.39,12.52-13.39h10.02c8.35,0,12.52,4.46,12.52,13.39v51.5c0,8.86-4.21,13.28-12.63,13.28h-9.8Zm2.5-9.47h5.34c2.76,0,4.14-1.52,4.14-4.57v-50.09c0-3.05-1.56-4.57-4.68-4.57h-4.9c-3.05,0-4.57,1.52-4.57,4.57v50.09c0,3.05,1.56,4.57,4.68,4.57Z"/>
<path class="cls-1" d="M276.74,97.36h19.06c8.42,0,12.63,4.46,12.63,13.39v23.19c0,8.86-4.18,13.28-12.52,13.28h-8.71v28.31h-10.45V97.36Zm10.45,40.4h6.64c2.76,0,4.14-1.52,4.14-4.57v-21.78c0-3.05-1.56-4.57-4.68-4.57h-6.1v30.92Z"/>
<path class="cls-1" d="M313.43,126l5.01-14.05h-5.12v-14.7h7.3c2.9,0,4.36,1.45,4.36,4.36v10.34l-5.23,14.05h-6.32Z"/>
<path class="cls-1" d="M354.81,135.15v-37.78h10.45v78.18h-10.45v-15.57l-11.22-35.61v51.18h-10.45V97.36h10.45l11.22,37.78Z"/>
<path class="cls-1" d="M213.69,241.63c5.73,1.6,8.6,4.57,8.6,8.93v19.6c0,7.77-3.96,11.65-11.87,11.65h-21.02v-78.18h17.86c9.29,0,13.94,4.72,13.94,14.16v15.03c0,4.5-2.5,7.26-7.51,8.28v.54Zm-13.83-5.01h4.9c3.99,0,5.99-1.89,5.99-5.66v-12.2c0-3.77-1.56-5.66-4.68-5.66h-6.21v23.52Zm0,35.72h7.84c2.76,0,4.14-1.12,4.14-3.38v-17.31c0-3.7-2.07-5.55-6.21-5.55h-5.77v26.24Z"/>
<path class="cls-1" d="M255.83,281.81h-24.83v-78.18h10.45v68.71h14.37v9.47Z"/>
<path class="cls-1" d="M273.9,281.81c-8.42,0-12.63-4.43-12.63-13.28v-51.5c0-8.93,4.17-13.39,12.52-13.39h10.02c8.35,0,12.52,4.46,12.52,13.39v51.5c0,8.86-4.21,13.28-12.63,13.28h-9.8Zm2.5-9.47h5.34c2.76,0,4.14-1.52,4.14-4.57v-50.09c0-3.05-1.56-4.57-4.68-4.57h-4.9c-3.05,0-4.57,1.52-4.57,4.57v50.09c0,3.05,1.56,4.57,4.68,4.57Z"/>
<path class="cls-1" d="M336.84,263.52v5.01c0,8.86-4.18,13.28-12.52,13.28h-6.75c-8.35,0-12.52-4.43-12.52-13.28v-51.61c0-8.86,4.17-13.28,12.52-13.28h6.75c8.35,0,12.52,4.43,12.52,13.28v8.82h-10.45v-8.06c0-3.05-1.56-4.57-4.68-4.57h-2.07c-2.76,0-4.14,1.52-4.14,4.57v50.09c0,3.05,1.38,4.57,4.14,4.57h2.61c2.76,0,4.14-1.52,4.14-4.57v-4.25h10.45Z"/>
</g>
<path class="cls-5" d="M2,175.48C2,79.67,79.67,2,175.48,2c58.6,0,110.42,29.06,141.83,73.56"/>
<path class="cls-5" d="M289.94,305.84c-30.55,26.84-70.6,43.11-114.46,43.11C79.67,348.96,2,271.29,2,175.48"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_2" data-name="Calque 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 365.26 350.96">
<defs>
<style>
.cls-1 {
fill: #63b036;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
stroke: #184437;
stroke-miterlimit: 10;
}
.cls-2 {
stroke-width: 2px;
}
.cls-2, .cls-3 {
fill: none;
stroke-linecap: round;
}
.cls-5 {
fill: #184437;
}
.cls-3 {
stroke-width: 4px;
}
.cls-4 {
fill: #fff;
}
</style>
</defs>
<g id="Calque_1-2" data-name="Calque 1">
<path class="cls-2" d="M209.26,324.83c-10.87,2.45-22.17,3.74-33.78,3.74-45.65,0-86.64-19.98-114.69-51.68"/>
<path class="cls-2" d="M100.64,41.9c22.13-12.42,47.66-19.51,74.84-19.51,45.53,0,86.41,19.87,114.45,51.42"/>
<g>
<polygon class="cls-1" points="100.99 73.8 87.58 52.39 150.14 73.8 100.99 73.8"/>
<path class="cls-4" d="M166.78,313.26s-.17-26.19,0-39.9l-49.47,15.84c15.8,8.03,49.47,24.06,49.47,24.06Z"/>
<path class="cls-1" d="M166.78,246.87l-48.63-34.01-1.27,75.45c16.63-5.15,33.27-10.31,49.9-15.46v-25.98Z"/>
<polygon class="cls-1" points="70.26 122.08 51.33 161.67 95.54 177.13 115.45 144.35 70.26 122.08"/>
<polygon class="cls-1" points="108.64 190.46 95.88 177.69 116.52 144.69 108.64 190.46"/>
<path class="cls-4" d="M63.5,224.79c-4.24-19.83-7.93-42.53-12.17-62.35,15.08,4.39,29.03,10.85,44.11,15.24-10.85,15.44-21.1,31.68-31.95,47.12Z"/>
<polygon class="cls-4" points="129.73 212.86 95.51 178.25 64.18 225.43 129.73 212.86"/>
<path class="cls-4" d="M94.82,276.17c-6.91-9.61-14.3-18.61-21.2-28.22,11.75-4.04,23.63-7.98,35.38-12.02-4.84,13.65-9.33,26.59-14.18,40.24Z"/>
<path class="cls-4" d="M139.79,256.38l-30.57-19.63-13.44,39.42,44-19.65c.06-.03,.07-.11,.01-.14Z"/>
<polygon class="cls-1" points="139.92 256.46 146.72 228.32 118.15 207.29 109 235.74 139.92 256.46"/>
<polygon class="cls-1" points="80.55 210.38 73.62 246.87 109 235.74 118.15 207.29 80.55 210.38"/>
<path class="cls-4" d="M166.78,192.76v54.12l-65.82-46.99c14.03-7.99,28.97-14.76,43-22.75,7.91,5.42,14.91,10.21,22.82,15.63Z"/>
<path class="cls-1" d="M109,153.53c-2.79,15.22-5.57,30.44-8.36,45.67,14.13-7.34,28.27-14.69,42.4-22.03-11.35-7.88-22.7-15.76-34.05-23.64Z"/>
<path class="cls-1" d="M41.08,111.96c15.11-19.86,30.23-39.72,45.34-59.57,4.74,7.16,9.48,14.32,14.22,21.48-19.85,12.7-39.71,25.4-59.56,38.09Z"/>
<polygon class="cls-4" points="96.37 134.93 39.25 114.36 100.47 74.77 96.37 134.93"/>
<polygon class="cls-4" points="150.53 74.64 101.38 74.64 96.79 134.01 150.53 74.64"/>
<polygon class="cls-4" points="166.81 192.33 166.81 126.44 92.65 142.93 166.81 192.33"/>
<polygon class="cls-1" points="166.81 125.75 166.81 52.24 96.1 99.62 92.08 142.03 166.81 125.75"/>
</g>
<g>
<path class="cls-5" d="M212.71,175.55v-35.5h-12.85v35.5h-10.45V97.36h10.45v33.21h12.85v-33.21h10.45v78.18h-10.45Z"/>
<path class="cls-5" d="M245.05,175.55c-8.42,0-12.63-4.43-12.63-13.28v-51.5c0-8.93,4.17-13.39,12.52-13.39h10.02c8.35,0,12.52,4.46,12.52,13.39v51.5c0,8.86-4.21,13.28-12.63,13.28h-9.8Zm2.5-9.47h5.34c2.76,0,4.14-1.52,4.14-4.57v-50.09c0-3.05-1.56-4.57-4.68-4.57h-4.9c-3.05,0-4.57,1.52-4.57,4.57v50.09c0,3.05,1.56,4.57,4.68,4.57Z"/>
<path class="cls-5" d="M276.74,97.36h19.06c8.42,0,12.63,4.46,12.63,13.39v23.19c0,8.86-4.18,13.28-12.52,13.28h-8.71v28.31h-10.45V97.36Zm10.45,40.4h6.64c2.76,0,4.14-1.52,4.14-4.57v-21.78c0-3.05-1.56-4.57-4.68-4.57h-6.1v30.92Z"/>
<path class="cls-5" d="M313.43,126l5.01-14.05h-5.12v-14.7h7.3c2.9,0,4.36,1.45,4.36,4.36v10.34l-5.23,14.05h-6.32Z"/>
<path class="cls-5" d="M354.81,135.15v-37.78h10.45v78.18h-10.45v-15.57l-11.22-35.61v51.18h-10.45V97.36h10.45l11.22,37.78Z"/>
<path class="cls-5" d="M213.69,241.63c5.73,1.6,8.6,4.57,8.6,8.93v19.6c0,7.77-3.96,11.65-11.87,11.65h-21.02v-78.18h17.86c9.29,0,13.94,4.72,13.94,14.16v15.03c0,4.5-2.5,7.26-7.51,8.28v.54Zm-13.83-5.01h4.9c3.99,0,5.99-1.89,5.99-5.66v-12.2c0-3.77-1.56-5.66-4.68-5.66h-6.21v23.52Zm0,35.72h7.84c2.76,0,4.14-1.12,4.14-3.38v-17.31c0-3.7-2.07-5.55-6.21-5.55h-5.77v26.24Z"/>
<path class="cls-5" d="M255.83,281.81h-24.83v-78.18h10.45v68.71h14.37v9.47Z"/>
<path class="cls-5" d="M273.9,281.81c-8.42,0-12.63-4.43-12.63-13.28v-51.5c0-8.93,4.17-13.39,12.52-13.39h10.02c8.35,0,12.52,4.46,12.52,13.39v51.5c0,8.86-4.21,13.28-12.63,13.28h-9.8Zm2.5-9.47h5.34c2.76,0,4.14-1.52,4.14-4.57v-50.09c0-3.05-1.56-4.57-4.68-4.57h-4.9c-3.05,0-4.57,1.52-4.57,4.57v50.09c0,3.05,1.56,4.57,4.68,4.57Z"/>
<path class="cls-5" d="M336.84,263.52v5.01c0,8.86-4.18,13.28-12.52,13.28h-6.75c-8.35,0-12.52-4.43-12.52-13.28v-51.61c0-8.86,4.17-13.28,12.52-13.28h6.75c8.35,0,12.52,4.43,12.52,13.28v8.82h-10.45v-8.06c0-3.05-1.56-4.57-4.68-4.57h-2.07c-2.76,0-4.14,1.52-4.14,4.57v50.09c0,3.05,1.38,4.57,4.14,4.57h2.61c2.76,0,4.14-1.52,4.14-4.57v-4.25h10.45Z"/>
</g>
<path class="cls-3" d="M289.94,305.84c-30.55,26.84-70.6,43.11-114.46,43.11C79.67,348.96,2,271.29,2,175.48S79.67,2,175.48,2c58.6,0,110.42,29.06,141.83,73.56"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_2" data-name="Calque 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 365.26 350.96">
<defs>
<style>
.cls-1, .cls-2 {
fill: #fff;
}
.cls-3 {
fill: #184437;
}
.cls-3, .cls-4, .cls-5, .cls-2 {
stroke-miterlimit: 10;
}
.cls-3, .cls-2 {
stroke: #63b036;
}
.cls-4 {
stroke-width: 2px;
}
.cls-4, .cls-5 {
fill: none;
stroke: #fff;
stroke-linecap: round;
}
.cls-5 {
stroke-width: 4px;
}
</style>
</defs>
<g id="Calque_1-2" data-name="Calque 1">
<path class="cls-4" d="M209.26,324.83c-10.87,2.45-22.17,3.74-33.78,3.74-45.65,0-86.64-19.98-114.69-51.68"/>
<path class="cls-4" d="M100.64,41.9c22.13-12.42,47.66-19.51,74.84-19.51,45.53,0,86.41,19.87,114.45,51.42"/>
<g>
<path class="cls-1" d="M212.71,175.55v-35.5h-12.85v35.5h-10.45V97.36h10.45v33.21h12.85v-33.21h10.45v78.18h-10.45Z"/>
<path class="cls-1" d="M245.05,175.55c-8.42,0-12.63-4.43-12.63-13.28v-51.5c0-8.93,4.17-13.39,12.52-13.39h10.02c8.35,0,12.52,4.46,12.52,13.39v51.5c0,8.86-4.21,13.28-12.63,13.28h-9.8Zm2.5-9.47h5.34c2.76,0,4.14-1.52,4.14-4.57v-50.09c0-3.05-1.56-4.57-4.68-4.57h-4.9c-3.05,0-4.57,1.52-4.57,4.57v50.09c0,3.05,1.56,4.57,4.68,4.57Z"/>
<path class="cls-1" d="M276.74,97.36h19.06c8.42,0,12.63,4.46,12.63,13.39v23.19c0,8.86-4.18,13.28-12.52,13.28h-8.71v28.31h-10.45V97.36Zm10.45,40.4h6.64c2.76,0,4.14-1.52,4.14-4.57v-21.78c0-3.05-1.56-4.57-4.68-4.57h-6.1v30.92Z"/>
<path class="cls-1" d="M313.43,126l5.01-14.05h-5.12v-14.7h7.3c2.9,0,4.36,1.45,4.36,4.36v10.34l-5.23,14.05h-6.32Z"/>
<path class="cls-1" d="M354.81,135.15v-37.78h10.45v78.18h-10.45v-15.57l-11.22-35.61v51.18h-10.45V97.36h10.45l11.22,37.78Z"/>
<path class="cls-1" d="M213.69,241.63c5.73,1.6,8.6,4.57,8.6,8.93v19.6c0,7.77-3.96,11.65-11.87,11.65h-21.02v-78.18h17.86c9.29,0,13.94,4.72,13.94,14.16v15.03c0,4.5-2.5,7.26-7.51,8.28v.54Zm-13.83-5.01h4.9c3.99,0,5.99-1.89,5.99-5.66v-12.2c0-3.77-1.56-5.66-4.68-5.66h-6.21v23.52Zm0,35.72h7.84c2.76,0,4.14-1.12,4.14-3.38v-17.31c0-3.7-2.07-5.55-6.21-5.55h-5.77v26.24Z"/>
<path class="cls-1" d="M255.83,281.81h-24.83v-78.18h10.45v68.71h14.37v9.47Z"/>
<path class="cls-1" d="M273.9,281.81c-8.42,0-12.63-4.43-12.63-13.28v-51.5c0-8.93,4.17-13.39,12.52-13.39h10.02c8.35,0,12.52,4.46,12.52,13.39v51.5c0,8.86-4.21,13.28-12.63,13.28h-9.8Zm2.5-9.47h5.34c2.76,0,4.14-1.52,4.14-4.57v-50.09c0-3.05-1.56-4.57-4.68-4.57h-4.9c-3.05,0-4.57,1.52-4.57,4.57v50.09c0,3.05,1.56,4.57,4.68,4.57Z"/>
<path class="cls-1" d="M336.84,263.52v5.01c0,8.86-4.18,13.28-12.52,13.28h-6.75c-8.35,0-12.52-4.43-12.52-13.28v-51.61c0-8.86,4.17-13.28,12.52-13.28h6.75c8.35,0,12.52,4.43,12.52,13.28v8.82h-10.45v-8.06c0-3.05-1.56-4.57-4.68-4.57h-2.07c-2.76,0-4.14,1.52-4.14,4.57v50.09c0,3.05,1.38,4.57,4.14,4.57h2.61c2.76,0,4.14-1.52,4.14-4.57v-4.25h10.45Z"/>
</g>
<path class="cls-5" d="M2,175.48C2,79.67,79.67,2,175.48,2c58.6,0,110.42,29.06,141.83,73.56"/>
<path class="cls-5" d="M289.94,305.84c-30.55,26.84-70.6,43.11-114.46,43.11C79.67,348.96,2,271.29,2,175.48"/>
<g>
<polygon class="cls-3" points="102.57 74.28 89.16 52.87 151.72 74.28 102.57 74.28"/>
<path class="cls-2" d="M168.36,313.73s-.17-26.19,0-39.9l-49.47,15.84c15.8,8.03,49.47,24.06,49.47,24.06Z"/>
<path class="cls-3" d="M168.36,247.35l-48.63-34.01-1.27,75.45c16.63-5.15,33.27-10.31,49.9-15.46v-25.98Z"/>
<polygon class="cls-3" points="71.83 122.56 52.91 162.14 97.12 177.61 117.03 144.83 71.83 122.56"/>
<polygon class="cls-3" points="110.22 190.94 97.46 178.16 118.1 145.17 110.22 190.94"/>
<path class="cls-2" d="M65.08,225.27c-4.24-19.83-7.93-42.53-12.17-62.35,15.08,4.39,29.03,10.85,44.11,15.24-10.85,15.44-21.1,31.68-31.95,47.12Z"/>
<polygon class="cls-2" points="131.31 213.34 97.09 178.73 65.76 225.91 131.31 213.34"/>
<path class="cls-2" d="M96.4,276.65c-6.91-9.61-14.3-18.61-21.2-28.22,11.75-4.04,23.63-7.98,35.38-12.02-4.84,13.65-9.33,26.59-14.18,40.24Z"/>
<path class="cls-2" d="M141.37,256.86l-30.57-19.63-13.44,39.42,44-19.65c.06-.03,.07-.11,.01-.14Z"/>
<polygon class="cls-3" points="141.5 256.94 148.3 228.8 119.73 207.77 110.58 236.22 141.5 256.94"/>
<polygon class="cls-3" points="82.13 210.86 75.2 247.35 110.58 236.22 119.73 207.77 82.13 210.86"/>
<path class="cls-2" d="M168.36,193.24v54.12l-65.82-46.99c14.03-7.99,28.97-14.76,43-22.75,7.91,5.42,14.91,10.21,22.82,15.63Z"/>
<path class="cls-3" d="M110.58,154.01c-2.79,15.22-5.57,30.44-8.36,45.67,14.13-7.34,28.27-14.69,42.4-22.03-11.35-7.88-22.7-15.76-34.05-23.64Z"/>
<path class="cls-3" d="M42.66,112.44c15.11-19.86,30.23-39.72,45.34-59.57,4.74,7.16,9.48,14.32,14.22,21.48-19.85,12.7-39.71,25.4-59.56,38.09Z"/>
<polygon class="cls-2" points="97.95 135.41 40.83 114.83 102.05 75.25 97.95 135.41"/>
<polygon class="cls-2" points="152.11 75.12 102.96 75.12 98.37 134.49 152.11 75.12"/>
<polygon class="cls-2" points="168.39 192.81 168.39 126.92 94.23 143.41 168.39 192.81"/>
<polygon class="cls-3" points="168.39 126.23 168.39 52.72 97.68 100.1 93.66 142.5 168.39 126.23"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_2" data-name="Calque 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 355.34 350.96">
<defs>
<style>
.cls-1, .cls-2 {
fill: #fff;
}
.cls-3 {
fill: #63b036;
}
.cls-3, .cls-4, .cls-5, .cls-2 {
stroke-miterlimit: 10;
}
.cls-3, .cls-2 {
stroke: #184437;
}
.cls-4 {
stroke-width: 2px;
}
.cls-4, .cls-5 {
fill: none;
stroke: #fff;
stroke-linecap: round;
}
.cls-5 {
stroke-width: 4px;
}
</style>
</defs>
<g id="Calque_1-2" data-name="Calque 1">
<path class="cls-4" d="M203.6,324.83c-10.57,2.45-21.57,3.74-32.86,3.74-44.41,0-84.27-19.98-111.56-51.68"/>
<path class="cls-4" d="M97.94,41.9c21.52-12.42,46.36-19.51,72.8-19.51,44.28,0,84.05,19.87,111.33,51.42"/>
<g>
<path class="cls-1" d="M206.96,175.55v-35.5h-12.5v35.5h-10.17V97.36h10.17v33.21h12.5v-33.21h10.17v78.18h-10.17Z"/>
<path class="cls-1" d="M238.42,175.55c-8.19,0-12.29-4.43-12.29-13.28v-51.5c0-8.93,4.06-13.39,12.18-13.39h9.74c8.12,0,12.18,4.46,12.18,13.39v51.5c0,8.86-4.1,13.28-12.29,13.28h-9.53Zm2.44-9.47h5.19c2.68,0,4.02-1.52,4.02-4.57v-50.09c0-3.05-1.52-4.57-4.55-4.57h-4.77c-2.97,0-4.45,1.52-4.45,4.57v50.09c0,3.05,1.52,4.57,4.55,4.57Z"/>
<path class="cls-1" d="M269.24,97.36h18.54c8.19,0,12.29,4.46,12.29,13.39v23.19c0,8.86-4.06,13.28-12.18,13.28h-8.47v28.31h-10.17V97.36Zm10.17,40.4h6.46c2.68,0,4.03-1.52,4.03-4.57v-21.78c0-3.05-1.52-4.57-4.55-4.57h-5.93v30.92Z"/>
<path class="cls-1" d="M304.93,126l4.87-14.05h-4.98v-14.7h7.1c2.82,0,4.24,1.45,4.24,4.36v10.34l-5.08,14.05h-6.14Z"/>
<path class="cls-1" d="M345.18,135.15v-37.78h10.17v78.18h-10.17v-15.57l-10.91-35.61v51.18h-10.17V97.36h10.17l10.91,37.78Z"/>
<path class="cls-1" d="M207.91,241.63c5.58,1.6,8.37,4.57,8.37,8.93v19.6c0,7.77-3.85,11.65-11.54,11.65h-20.44v-78.18h17.37c9.04,0,13.56,4.72,13.56,14.16v15.03c0,4.5-2.44,7.26-7.31,8.28v.54Zm-13.45-5.01h4.77c3.88,0,5.83-1.89,5.83-5.66v-12.2c0-3.77-1.52-5.66-4.55-5.66h-6.04v23.52Zm0,35.72h7.63c2.68,0,4.02-1.12,4.02-3.38v-17.31c0-3.7-2.01-5.55-6.04-5.55h-5.61v26.24Z"/>
<path class="cls-1" d="M248.9,281.81h-24.15v-78.18h10.17v68.71h13.98v9.47Z"/>
<path class="cls-1" d="M266.48,281.81c-8.19,0-12.29-4.43-12.29-13.28v-51.5c0-8.93,4.06-13.39,12.18-13.39h9.74c8.12,0,12.18,4.46,12.18,13.39v51.5c0,8.86-4.1,13.28-12.29,13.28h-9.53Zm2.44-9.47h5.19c2.68,0,4.02-1.52,4.02-4.57v-50.09c0-3.05-1.52-4.57-4.55-4.57h-4.77c-2.97,0-4.45,1.52-4.45,4.57v50.09c0,3.05,1.52,4.57,4.55,4.57Z"/>
<path class="cls-1" d="M327.7,263.52v5.01c0,8.86-4.06,13.28-12.18,13.28h-6.57c-8.12,0-12.18-4.43-12.18-13.28v-51.61c0-8.86,4.06-13.28,12.18-13.28h6.57c8.12,0,12.18,4.43,12.18,13.28v8.82h-10.17v-8.06c0-3.05-1.52-4.57-4.55-4.57h-2.01c-2.68,0-4.02,1.52-4.02,4.57v50.09c0,3.05,1.34,4.57,4.02,4.57h2.54c2.68,0,4.03-1.52,4.03-4.57v-4.25h10.17Z"/>
</g>
<path class="cls-5" d="M2,175.48C2,79.67,77.55,2,170.74,2c57,0,107.41,29.06,137.96,73.56"/>
<path class="cls-5" d="M282.08,305.84c-29.71,26.84-68.67,43.11-111.33,43.11C77.55,348.96,2,271.29,2,175.48"/>
<g>
<polygon class="cls-3" points="99.83 74.28 86.78 52.87 147.63 74.28 99.83 74.28"/>
<path class="cls-2" d="M163.82,313.73s-.17-26.19,0-39.9l-48.12,15.84c15.37,8.03,48.12,24.06,48.12,24.06Z"/>
<path class="cls-3" d="M163.82,247.35l-47.3-34.01-1.24,75.45c16.18-5.15,32.36-10.31,48.54-15.46v-25.98Z"/>
<polygon class="cls-3" points="69.93 122.56 51.52 162.14 94.52 177.61 113.89 144.83 69.93 122.56"/>
<polygon class="cls-3" points="107.27 190.94 94.86 178.16 114.93 145.17 107.27 190.94"/>
<path class="cls-2" d="M63.36,225.27c-4.12-19.83-7.72-42.53-11.84-62.35,14.67,4.39,28.24,10.85,42.91,15.24-10.55,15.44-20.52,31.68-31.07,47.12Z"/>
<polygon class="cls-2" points="127.78 213.34 94.5 178.73 64.02 225.91 127.78 213.34"/>
<path class="cls-2" d="M93.83,276.65c-6.72-9.61-13.91-18.61-20.63-28.22,11.43-4.04,22.98-7.98,34.41-12.02-4.71,13.65-9.08,26.59-13.79,40.24Z"/>
<path class="cls-2" d="M137.57,256.86l-29.74-19.63-13.07,39.42,42.8-19.65c.06-.03,.06-.11,.01-.14Z"/>
<polygon class="cls-3" points="137.69 256.94 144.31 228.8 116.51 207.77 107.61 236.22 137.69 256.94"/>
<polygon class="cls-3" points="79.94 210.86 73.2 247.35 107.61 236.22 116.51 207.77 79.94 210.86"/>
<path class="cls-2" d="M163.82,193.24v54.12l-64.02-46.99c13.65-7.99,28.18-14.76,41.82-22.75,7.7,5.42,14.51,10.21,22.2,15.63Z"/>
<path class="cls-3" d="M107.61,154.01c-2.71,15.22-5.42,30.44-8.13,45.67,13.75-7.34,27.5-14.69,41.25-22.03-11.04-7.88-22.08-15.76-33.12-23.64Z"/>
<path class="cls-3" d="M41.55,112.44c14.7-19.86,29.4-39.72,44.1-59.57,4.61,7.16,9.22,14.32,13.83,21.48-19.31,12.7-38.62,25.4-57.93,38.09Z"/>
<polygon class="cls-2" points="95.33 135.41 39.77 114.83 99.32 75.25 95.33 135.41"/>
<polygon class="cls-2" points="148.01 75.12 100.2 75.12 95.74 134.49 148.01 75.12"/>
<polygon class="cls-2" points="163.85 192.81 163.85 126.92 91.71 143.41 163.85 192.81"/>
<polygon class="cls-3" points="163.85 126.23 163.85 52.72 95.07 100.1 91.15 142.5 163.85 126.23"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+4
View File
@@ -0,0 +1,4 @@
/* Cacher le bouton Supprimer sur l'ecran de review apres capture */
.action-button-delete {
display: none !important;
}