first commit
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled

This commit is contained in:
2026-07-16 00:55:29 +02:00
commit 209f81aa3d
100 changed files with 17682 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
{% extends "base.html" %}
{% block title %}Actions — JH Photomaton{% endblock %}
{% block nav_links %}
<a href="/admin">Dashboard</a>
<a href="/admin/gallery">Galerie admin</a>
<a href="/admin/print">Impression</a>
<a href="/admin/actions" class="active">Actions</a>
<a href="/gallery">Galerie publique</a>
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
{% endblock %}
{% block content %}
<div class="container">
<h1 class="page-title">Gestion des actions & mapping bouton</h1>
<!-- Mapping clics → actions -->
<div class="section">
<div class="card-title">Mapping bouton → actions photobooth-app</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>
<button class="btn btn-primary mt-2" onclick="saveMapping()">💾 Sauvegarder le mapping</button>
</div>
</div>
<!-- Actions photobooth-app -->
<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>
{% else %}
<div class="empty-state"><div class="icon">⚙️</div>Aucune action configurée dans photobooth-app</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
{% 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';
}
async function saveAction(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,
};
try {
await api('PUT', `/api/actions/photobooth/${idx}`, updates);
showToast('✅ Action sauvegardée — redémarrage photobooth-app requis', 'success', 6000);
toggleEdit(idx);
} catch(e) {
showToast('❌ Erreur sauvegarde', 'error');
}
}
async function triggerAction(idx) {
if (!confirm(`Déclencher l'action ${idx} (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'); }
}
// ── Mapping bouton ─────────────────────────────────────────────────────────────
async function saveMapping() {
const mapping = {};
for (let n = 1; n <= 4; n++) {
mapping[n] = {
label: document.getElementById('label-' + n).value,
photobooth_index: parseInt(document.getElementById('index-' + n).value),
};
}
try {
await api('PUT', '/api/actions/mapping', mapping);
showToast('✅ Mapping sauvegardé', 'success');
} catch(e) { showToast('❌ Erreur sauvegarde mapping', '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;
try {
await api('POST', `/api/actions/trigger/${idx}`);
showToast(`▶ Test ${n} clic → action ${idx}`, 'info');
} catch(e) { showToast('Erreur', '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 %}
</script>
{% endblock %}
+281
View File
@@ -0,0 +1,281 @@
{% extends "base.html" %}
{% block title %}Dashboard — JH Photomaton{% endblock %}
{% block nav_links %}
<a href="/admin" class="active">Dashboard</a>
<a href="/admin/gallery">Galerie admin</a>
<a href="/admin/print">Impression</a>
<a href="/admin/actions">Actions</a>
<a href="/gallery">Galerie publique</a>
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
{% endblock %}
{% block content %}
<div class="container">
<div class="flex items-center justify-between mb-2">
<h1 class="page-title" style="margin:0">Dashboard</h1>
<div class="flex gap-1 items-center">
<span class="text-sm text-muted">Mode impression :</span>
<select id="print-mode-select" class="form-control" style="width:auto">
<option value="direct" {% if print_mode=='direct' %}selected{% endif %}>Direct</option>
<option value="validation" {% if print_mode=='validation' %}selected{% endif %}>Validation</option>
<option value="gallery" {% if print_mode=='gallery' %}selected{% endif %}>Galerie</option>
</select>
</div>
</div>
<!-- Ressources système -->
<div class="section">
<div class="card-title">Ressources système</div>
<div class="grid-4">
<div class="card">
<div class="card-title">CPU</div>
<div class="card-value" id="cpu-val">{{ stats.cpu_percent|default('—') }}%</div>
<div class="progress mt-1"><div class="progress-bar" id="cpu-bar" style="width:{{ stats.cpu_percent|default(0) }}%"></div></div>
</div>
<div class="card">
<div class="card-title">RAM</div>
<div class="card-value" id="ram-val">{{ stats.ram_used_mb|default('—') }} Mo</div>
<div class="card-sub" id="ram-sub">sur {{ stats.ram_total_mb|default('?') }} Mo</div>
<div class="progress mt-1"><div class="progress-bar" id="ram-bar" style="width:{{ stats.ram_percent|default(0) }}%"></div></div>
</div>
<div class="card">
<div class="card-title">Température</div>
<div class="card-value" id="temp-val">{{ stats.cpu_temp|default('—') }}°C</div>
<div class="card-sub">CPU</div>
</div>
<div class="card">
<div class="card-title">Disque</div>
<div class="card-value" id="disk-val">{{ stats.disk_used_gb|default('—') }} Go</div>
<div class="card-sub" id="disk-sub">sur {{ stats.disk_total_gb|default('?') }} Go</div>
<div class="progress mt-1"><div class="progress-bar" id="disk-bar" style="width:{{ stats.disk_percent|default(0) }}%"></div></div>
</div>
</div>
</div>
<div class="grid-2 section">
<!-- Contrôle LED -->
<div class="card">
<div class="card-title">Anneau LED (35 LEDs · GPIO18)</div>
<div class="flex items-center gap-2 mt-1 mb-2">
<div class="led-ring {{ led_effect }}" id="led-ring">
<span id="led-label">{{ led_effect }}</span>
</div>
<div style="flex:1">
<div class="text-sm text-muted mb-1">Effet actuel</div>
<div class="flex gap-1" style="flex-wrap:wrap">
{% for effect in ['idle','countdown','capture','captured','finished','printing','error','disabled','off'] %}
<button class="btn btn-ghost btn-sm" onclick="setLedEffect('{{ effect }}')">{{ effect }}</button>
{% endfor %}
</div>
<div class="flex gap-1 mt-1 items-center">
<span class="text-sm text-muted">Couleur :</span>
<input type="color" id="led-color" value="#1e5080" style="border:none;background:none;cursor:pointer;width:32px;height:28px">
<button class="btn btn-ghost btn-sm" onclick="setLedColor()">Appliquer</button>
</div>
</div>
</div>
</div>
<!-- Statut bouton & relay -->
<div class="card">
<div class="card-title">Bouton physique (GPIO23) · Relay (GPIO12)</div>
<div class="flex gap-2 mt-1 mb-2">
<div>
<div class="text-sm text-muted mb-1">Relay bouton 12V</div>
<div class="flex gap-1">
<button class="btn btn-success btn-sm" onclick="setRelay('on')">✅ Activer</button>
<button class="btn btn-danger btn-sm" onclick="setRelay('off')">🔴 Désactiver</button>
</div>
<div class="text-xs text-muted mt-1">Statut : <span id="relay-state">{{ 'ON' if relay_state else 'OFF' }}</span></div>
</div>
<div>
<div class="text-sm text-muted mb-1">Simulation (test)</div>
<div class="flex gap-1" style="flex-wrap:wrap">
<button class="btn btn-ghost btn-sm" onclick="simulateClick(1)">1 clic</button>
<button class="btn btn-ghost btn-sm" onclick="simulateClick(2)">2 clics</button>
<button class="btn btn-ghost btn-sm" onclick="simulateClick(3)">3 clics</button>
<button class="btn btn-ghost btn-sm" onclick="simulateClick(0)">Long press</button>
</div>
</div>
</div>
<div class="text-xs text-muted" id="last-button-event">Dernier événement : —</div>
</div>
</div>
<!-- File d'impression en attente -->
<div class="section">
<div class="flex items-center justify-between mb-1">
<div class="card-title" style="margin:0">File d'impression (<span id="queue-count"></span>)</div>
<a href="/admin/print" class="btn btn-ghost btn-sm">Voir tout →</a>
</div>
<div class="card" id="print-queue-container">
<div class="text-muted text-sm">Chargement…</div>
</div>
</div>
<!-- Services -->
<div class="section">
<div class="card-title">Services</div>
<div class="card">
<table class="table">
<thead><tr><th>Service</th><th>Statut</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>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ── Live stats ────────────────────────────────────────────────────────────────
function updateStats(s) {
if (!s) return;
if (s.cpu_percent !== undefined) {
document.getElementById('cpu-val').textContent = s.cpu_percent + '%';
const cpuBar = document.getElementById('cpu-bar');
cpuBar.style.width = s.cpu_percent + '%';
cpuBar.className = 'progress-bar' + (s.cpu_percent > 85 ? ' danger' : s.cpu_percent > 65 ? ' warn' : '');
}
if (s.ram_used_mb !== undefined) {
document.getElementById('ram-val').textContent = s.ram_used_mb + ' Mo';
document.getElementById('ram-sub').textContent = 'sur ' + s.ram_total_mb + ' Mo';
document.getElementById('ram-bar').style.width = s.ram_percent + '%';
}
if (s.cpu_temp !== undefined && s.cpu_temp !== null) {
document.getElementById('temp-val').textContent = s.cpu_temp + '°C';
}
if (s.disk_used_gb !== undefined) {
document.getElementById('disk-val').textContent = s.disk_used_gb + ' Go';
document.getElementById('disk-sub').textContent = 'sur ' + s.disk_total_gb + ' Go';
document.getElementById('disk-bar').style.width = s.disk_percent + '%';
}
}
// ── LED ───────────────────────────────────────────────────────────────────────
function updateLed(effect) {
const ring = document.getElementById('led-ring');
ring.className = 'led-ring ' + effect;
document.getElementById('led-label').textContent = effect;
}
async function setLedEffect(effect) {
try {
await api('POST', `/api/leds/effect?effect=${effect}`);
updateLed(effect);
} catch(e) { showToast('Erreur LED', 'error'); }
}
async function setLedColor() {
const hex = document.getElementById('led-color').value;
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
try {
await api('POST', `/api/leds/color?r=${r}&g=${g}&b=${b}`);
showToast('Couleur appliquée', 'success');
} catch(e) { showToast('Erreur', 'error'); }
}
// ── Relay ─────────────────────────────────────────────────────────────────────
async function setRelay(state) {
try {
await api('POST', `/api/system/relay?state=${state}`);
document.getElementById('relay-state').textContent = state.toUpperCase();
showToast(`Relay ${state.toUpperCase()}`, 'success');
} catch(e) { showToast('Erreur relay', 'error'); }
}
// ── Simulation bouton ─────────────────────────────────────────────────────────
async function simulateClick(n) {
try {
await api('POST', `/api/system/button/simulate?clicks=${n}`);
showToast(n === 0 ? 'Long press simulé' : `${n} clic(s) simulé(s)`, 'info');
} catch(e) { showToast('Erreur simulation', 'error'); }
}
// ── Mode impression ────────────────────────────────────────────────────────────
document.getElementById('print-mode-select').addEventListener('change', async (e) => {
try {
await api('POST', `/api/print/mode?mode=${e.target.value}`);
showToast(`Mode: ${e.target.value}`, 'success');
} catch(err) { showToast('Erreur changement mode', 'error'); }
});
// ── File d'impression ─────────────────────────────────────────────────────────
async function loadQueue() {
try {
const data = await api('GET', '/api/print/queue?status=pending');
const queue = data.queue || [];
document.getElementById('queue-count').textContent = queue.length;
const container = document.getElementById('print-queue-container');
if (queue.length === 0) {
container.innerHTML = '<div class="text-muted text-sm">Aucune impression en attente</div>';
return;
}
container.innerHTML = `
<table class="table">
<thead><tr><th>Aperçu</th><th>Fichier</th><th>Copies</th><th>Demandé le</th><th>Actions</th></tr></thead>
<tbody>
${queue.slice(0,5).map(q => `
<tr>
<td><img src="${q.thumb_url}" class="print-thumb" onerror="this.style.display='none'"></td>
<td class="text-sm">${q.filename.split('/').pop()}</td>
<td>${q.copies}</td>
<td class="text-xs text-muted">${new Date(q.requested_at*1000).toLocaleTimeString('fr-FR')}</td>
<td>
<button class="btn btn-success btn-sm" onclick="executePrint('${q.id}',${q.copies})">🖨 Imprimer</button>
<button class="btn btn-danger btn-sm" onclick="cancelPrint('${q.id}')">✕</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch(e) {}
}
async function executePrint(id, copies) {
try {
const r = await api('POST', `/api/print/execute/${id}?copies=${copies}`);
showToast(r.success ? '✅ Impression lancée' : '❌ ' + r.error, r.success ? 'success' : 'error');
loadQueue();
} catch(e) { showToast('Erreur impression', 'error'); }
}
async function cancelPrint(id) {
try {
await api('POST', `/api/print/cancel/${id}`);
showToast('Annulé', 'info');
loadQueue();
} catch(e) { showToast('Erreur annulation', 'error'); }
}
// ── WebSocket handler ─────────────────────────────────────────────────────────
function onWsMessage(msg) {
switch(msg.type) {
case 'system_stats': updateStats(msg.data); break;
case 'led_effect': updateLed(msg.effect); break;
case 'button_event':
document.getElementById('last-button-event').textContent =
`Dernier événement : ${msg.clicks === 0 ? 'Long press' : msg.clicks + ' clic(s)'}${msg.action || ''}`;
break;
case 'print_request': loadQueue(); break;
case 'print_result': loadQueue(); break;
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
loadQueue();
setInterval(loadQueue, 15000);
</script>
{% endblock %}
+151
View File
@@ -0,0 +1,151 @@
{% 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 admin</a>
<a href="/admin/print">Impression</a>
<a href="/admin/actions">Actions</a>
<a href="/gallery">Galerie publique</a>
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
{% endblock %}
{% block content %}
<div class="container">
<div class="flex items-center justify-between mb-2">
<h1 class="page-title" style="margin:0">Galerie — Administration</h1>
<div class="flex gap-1 items-center">
<span class="text-sm text-muted" id="photo-count">Chargement…</span>
<div class="flex gap-1">
<input type="number" id="copies-input" min="1" max="3" value="1" class="form-control" style="width:70px" title="Copies">
<button class="btn btn-ghost btn-sm" onclick="refreshPhotos()">↻ Actualiser</button>
</div>
</div>
</div>
<!-- Filtres / 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>
<span class="text-sm text-muted">Page <span id="page-cur">1</span> / <span id="page-total">1</span></span>
<button class="btn btn-ghost btn-sm" id="next-btn" onclick="changePage(1)">Suiv. →</button>
</div>
<div class="photo-grid" id="photo-grid">
<div class="empty-state"><div class="icon"></div>Chargement…</div>
</div>
</div>
<!-- Lightbox admin -->
<div class="lightbox" id="lightbox" onclick="closeLightbox(event)">
<button class="lightbox-close" onclick="closeLightbox()">&times;</button>
<img id="lightbox-img" src="" alt="">
<div class="lightbox-actions">
<button class="btn btn-success" id="lb-print-btn">🖨 Imprimer</button>
<a class="btn btn-ghost" id="lb-download-btn" download>⬇ Télécharger</a>
<button class="btn btn-danger" id="lb-delete-btn">🗑 Supprimer</button>
</div>
<div class="text-sm text-muted" id="lb-filename"></div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentPage = 1;
let totalPages = 1;
let currentPhotoId = null;
async function loadPhotos(page = 1) {
try {
const data = await api('GET', `/admin/api/gallery/photos?page=${page}&limit=24`);
const { photos, total, pages } = data;
currentPage = page;
totalPages = pages;
document.getElementById('photo-count').textContent = `${total} photo(s)`;
document.getElementById('page-cur').textContent = page;
document.getElementById('page-total').textContent = pages;
document.getElementById('prev-btn').disabled = page <= 1;
document.getElementById('next-btn').disabled = page >= pages;
const grid = document.getElementById('photo-grid');
if (!photos.length) {
grid.innerHTML = '<div class="empty-state"><div class="icon">📷</div>Aucune photo</div>';
return;
}
grid.innerHTML = photos.map(p => {
const pid = p.id || p.filename || p.uid || '';
return `
<div class="photo-card" onclick="openLightbox('${pid}', '${p.full_url}', '${p.thumb_url}')">
<img src="${p.thumb_url}" loading="lazy" alt="">
<div class="photo-card-actions">
<button class="btn btn-success btn-sm" onclick="event.stopPropagation();printPhoto('${pid}')">🖨</button>
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deletePhoto('${pid}')">🗑</button>
<a class="btn btn-ghost btn-sm" href="${p.full_url}" download onclick="event.stopPropagation()">⬇</a>
</div>
</div>
`;
}).join('');
} catch(e) {
document.getElementById('photo-grid').innerHTML = '<div class="empty-state"><div class="icon">❌</div>Erreur de chargement</div>';
}
}
function changePage(delta) {
loadPhotos(currentPage + delta);
}
function refreshPhotos() { loadPhotos(currentPage); }
// ── Lightbox ──────────────────────────────────────────────────────────────────
function openLightbox(pid, fullUrl, thumbUrl) {
currentPhotoId = pid;
document.getElementById('lightbox-img').src = fullUrl || thumbUrl;
document.getElementById('lb-download-btn').href = fullUrl;
document.getElementById('lb-filename').textContent = pid;
document.getElementById('lb-print-btn').onclick = () => printPhoto(pid);
document.getElementById('lb-delete-btn').onclick = () => deletePhoto(pid);
document.getElementById('lightbox').classList.add('open');
}
function closeLightbox(e) {
if (e && e.target !== document.getElementById('lightbox') && e.type !== 'click') return;
document.getElementById('lightbox').classList.remove('open');
currentPhotoId = null;
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
// ── Actions ───────────────────────────────────────────────────────────────────
async function printPhoto(pid) {
const copies = parseInt(document.getElementById('copies-input').value) || 1;
if (!confirm(`Imprimer ${copies} copie(s) ?`)) return;
try {
const r = await api('POST', `/admin/api/gallery/print/${pid}?copies=${copies}`);
if (r.success) showToast('✅ Impression lancée sur ' + r.printer, 'success');
else if (r.ok) showToast('📋 Demande ajoutée à la file', 'info');
else showToast('❌ ' + (r.error || 'Erreur'), 'error');
} catch(e) { showToast('Erreur impression', 'error'); }
}
async function deletePhoto(pid) {
if (!confirm('Supprimer cette photo définitivement ?')) return;
try {
const r = await api('DELETE', `/admin/api/gallery/${pid}`);
showToast(r.ok ? '🗑 Photo supprimée' : '❌ Erreur suppression', r.ok ? 'success' : 'error');
if (r.ok) {
document.getElementById('lightbox').classList.remove('open');
loadPhotos(currentPage);
}
} catch(e) { showToast('Erreur suppression', 'error'); }
}
function onWsMessage(msg) {
if (msg.type === 'photo_deleted') loadPhotos(currentPage);
if (msg.type === 'print_result') showToast(msg.result.success ? '✅ Impression OK' : '❌ ' + msg.result.error, msg.result.success ? 'success' : 'error');
}
loadPhotos(1);
</script>
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion — JH Photomaton</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="login-wrapper">
<div class="login-card">
<div class="login-title">
<div style="font-size:3rem;margin-bottom:.5rem">📷</div>
<h1>JH Photomaton</h1>
<p>Dashboard d'administration</p>
</div>
{% if error %}
<div class="error-msg">{{ error }}</div>
{% endif %}
<form method="post" action="/admin/login">
<div class="form-group">
<label class="form-label">Mot de passe</label>
<input
type="password"
name="password"
class="form-control"
placeholder="••••••••••"
autofocus
required
>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;justify-content:center;padding:.65rem">
Connexion
</button>
</form>
<p class="text-xs text-muted mt-2" style="text-align:center">
<a href="/gallery">← Galerie publique</a>
</p>
</div>
</div>
</body>
</html>
+190
View File
@@ -0,0 +1,190 @@
{% extends "base.html" %}
{% block title %}Impression — JH Photomaton{% endblock %}
{% block nav_links %}
<a href="/admin">Dashboard</a>
<a href="/admin/gallery">Galerie admin</a>
<a href="/admin/print" class="active">Impression</a>
<a href="/admin/actions">Actions</a>
<a href="/gallery">Galerie publique</a>
<a href="/admin/logout" style="margin-left:auto;color:var(--text-dim)">Déconnexion</a>
{% endblock %}
{% block content %}
<div class="container">
<h1 class="page-title">Gestion de l'impression</h1>
<!-- Imprimantes -->
<div class="section">
<div class="card-title">Imprimantes CUPS</div>
<div class="card">
<table class="table" id="printers-table">
<thead><tr><th>Imprimante</th><th>Statut</th><th>Jobs en attente</th><th>Actions</th></tr></thead>
<tbody>
{% for p in printers %}
<tr>
<td class="font-bold">{{ p.label }}</td>
<td>
<span class="badge
{% if p.state == 'idle' %}badge-success
{% elif p.state == 'printing' %}badge-info
{% elif p.state == 'disabled' %}badge-warning
{% else %}badge-error{% endif %}
">
{% if p.state == 'idle' %}Disponible
{% elif p.state == 'printing' %}Impression
{% elif p.state == 'disabled' %}Désactivée
{% elif p.state == 'offline' %}Hors ligne
{% else %}{{ p.state }}{% endif %}
</span>
</td>
<td>{{ p.jobs }}</td>
<td>
<button class="btn btn-danger btn-sm" onclick="cancelCupsJobs('{{ p.name }}')">✕ Vider file</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Mode impression -->
<div class="section">
<div class="card-title">Mode d'impression</div>
<div class="card">
<div class="flex gap-1 mb-1">
<label class="flex items-center gap-1" style="cursor:pointer">
<input type="radio" name="print-mode" value="direct" {% if config.print.mode=='direct' %}checked{% endif %}> Direct (impression automatique)
</label>
<label class="flex items-center gap-1" style="cursor:pointer">
<input type="radio" name="print-mode" value="validation" {% if config.print.mode=='validation' %}checked{% endif %}> Validation (admin confirme)
</label>
<label class="flex items-center gap-1" style="cursor:pointer">
<input type="radio" name="print-mode" value="gallery" {% if config.print.mode=='gallery' %}checked{% endif %}> Galerie (file uniquement)
</label>
</div>
<p class="text-xs text-muted">
<b>Direct</b> : impression lancée immédiatement sans confirmation. <b>Validation</b> : l'admin valide chaque impression. <b>Galerie</b> : les demandes s'accumulent, impression via la galerie admin.
</p>
</div>
</div>
<!-- File d'attente -->
<div class="section">
<div class="flex items-center justify-between mb-1">
<div class="card-title" style="margin:0">File d'attente (<span id="pending-count">{{ queue|length }}</span>)</div>
<button class="btn btn-ghost btn-sm" onclick="refreshQueue()">↻ Actualiser</button>
</div>
<div class="card">
<div id="queue-container">
{% if not queue %}
<div class="empty-state"><div class="icon"></div>Aucune impression en attente</div>
{% else %}
<table class="table" id="queue-table">
<thead><tr><th>Aperçu</th><th>Fichier</th><th>Copies</th><th>Statut</th><th>Demandé</th><th>Actions</th></tr></thead>
<tbody id="queue-tbody">
{% for q in queue %}
<tr id="row-{{ q.id }}">
<td><img src="{{ q.thumb_url }}" class="print-thumb" onerror="this.style.opacity=0"></td>
<td class="text-sm">{{ q.filename.split('/')[-1] }}</td>
<td>
<input type="number" value="{{ q.copies }}" min="1" max="3" id="copies-{{ q.id }}" style="width:50px;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:4px;padding:2px 6px">
</td>
<td>
<span class="badge
{% if q.status == 'pending' %}badge-warning
{% elif q.status == 'done' %}badge-success
{% elif q.status == 'printing' %}badge-info
{% else %}badge-error{% endif %}
">{{ q.status }}</span>
</td>
<td class="text-xs text-muted">{{ q.requested_at|int }}</td>
<td>
{% if q.status == 'pending' %}
<button class="btn btn-success btn-sm" onclick="executePrint('{{ q.id }}')">🖨 Imprimer</button>
<button class="btn btn-danger btn-sm" onclick="cancelPrint('{{ q.id }}')"></button>
{% elif q.status == 'done' %}
<span class="text-sm text-muted">{{ q.printer or '—' }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// ── Mode impression ────────────────────────────────────────────────────────────
document.querySelectorAll('input[name="print-mode"]').forEach(r => {
r.addEventListener('change', async (e) => {
try {
await api('POST', `/api/print/mode?mode=${e.target.value}`);
showToast(`Mode: ${e.target.value}`, 'success');
} catch(e) { showToast('Erreur', 'error'); }
});
});
// ── File d'attente ─────────────────────────────────────────────────────────────
async function refreshQueue() {
try {
const data = await api('GET', '/api/print/queue');
const queue = data.queue || [];
const pending = queue.filter(q => q.status === 'pending');
document.getElementById('pending-count').textContent = pending.length;
const tbody = document.getElementById('queue-tbody');
if (!tbody) return;
// Mise à jour des statuts existants
queue.forEach(q => {
const row = document.getElementById('row-' + q.id);
if (row) {
const badge = row.querySelector('.badge');
if (badge) badge.textContent = q.status;
}
});
} catch(e) {}
}
async function executePrint(id) {
const copiesEl = document.getElementById('copies-' + id);
const copies = copiesEl ? parseInt(copiesEl.value) : 1;
try {
const r = await api('POST', `/api/print/execute/${id}?copies=${copies}`);
showToast(r.success ? '✅ Impression lancée sur ' + r.printer : '❌ ' + r.error, r.success ? 'success' : 'error');
setTimeout(refreshQueue, 1000);
} catch(e) { showToast('Erreur impression', 'error'); }
}
async function cancelPrint(id) {
try {
await api('POST', `/api/print/cancel/${id}`);
showToast('Annulé', 'info');
const row = document.getElementById('row-' + id);
if (row) row.remove();
} catch(e) { showToast('Erreur', 'error'); }
}
async function cancelCupsJobs(printer) {
if (!confirm(`Vider la file de ${printer} ?`)) return;
try {
const r = await api('POST', `/api/print/cups/cancel/${printer}`);
showToast(r.ok ? 'File vidée' : 'Erreur', r.ok ? 'success' : 'error');
refreshQueue();
} catch(e) { showToast('Erreur', 'error'); }
}
function onWsMessage(msg) {
if (['print_request','print_result','print_cancelled'].includes(msg.type)) refreshQueue();
}
setInterval(refreshQueue, 10000);
</script>
{% endblock %}
+94
View File
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}JH Photomaton{% endblock %}</title>
<link rel="stylesheet" href="/static/css/main.css">
{% block head %}{% endblock %}
</head>
<body>
{% block navbar %}
<nav class="navbar">
<div class="navbar-brand">📷 <span>JH</span> Photomaton</div>
<div class="navbar-links">
{% block nav_links %}{% endblock %}
</div>
<div class="navbar-status" id="ws-status">
<span class="dot dot-gray" id="ws-dot"></span>
<span id="ws-label">Connexion…</span>
</div>
</nav>
{% endblock %}
<main>
{% block content %}{% endblock %}
</main>
<!-- Toast container -->
<div class="toast-container" id="toasts"></div>
<script>
// ── WebSocket global ─────────────────────────────────────────────────────────
let ws = null;
let wsReconnectTimer = null;
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onopen = () => {
document.getElementById('ws-dot').className = 'dot dot-green';
document.getElementById('ws-label').textContent = 'Connecté';
clearTimeout(wsReconnectTimer);
};
ws.onclose = () => {
document.getElementById('ws-dot').className = 'dot dot-red';
document.getElementById('ws-label').textContent = 'Déconnecté';
wsReconnectTimer = setTimeout(connectWS, 3000);
};
ws.onerror = () => ws.close();
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
handleWsMessage(msg);
} catch(err) {}
};
}
function handleWsMessage(msg) {
// Override dans chaque page si besoin
if (typeof onWsMessage === 'function') onWsMessage(msg);
}
// ── Toast notifications ──────────────────────────────────────────────────────
function showToast(text, type = 'info', duration = 4000) {
const el = document.createElement('div');
el.className = `toast ${type}`;
el.textContent = text;
document.getElementById('toasts').appendChild(el);
setTimeout(() => el.remove(), duration);
}
// ── API helper ───────────────────────────────────────────────────────────────
async function api(method, url, body = null) {
const opts = { method, headers: {} };
if (body) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const r = await fetch(url, opts);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
connectWS();
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block title %}Galerie photos — Photomaton LSDW{% endblock %}
{% block nav_links %}
<a href="/gallery" class="active">📷 Galerie photos</a>
{% endblock %}
{% block content %}
<div class="container">
<div class="flex items-center justify-between mb-2">
<h1 class="page-title" style="margin:0">📷 Vos photos</h1>
<div class="flex gap-1 items-center">
<span class="text-sm text-muted" id="photo-count">Chargement…</span>
<button class="btn btn-ghost btn-sm" onclick="loadPhotos(1)"></button>
</div>
</div>
<p class="text-sm text-muted mb-2">
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>
<!-- 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>
<span class="text-sm text-muted">Page <span id="page-cur">1</span> / <span id="page-total">1</span></span>
<button class="btn btn-ghost btn-sm" id="next-btn" onclick="changePage(1)">Suiv. →</button>
</div>
<div class="photo-grid" id="photo-grid">
<div class="empty-state"><div class="icon"></div>Chargement…</div>
</div>
</div>
<!-- Lightbox -->
<div class="lightbox" id="lightbox" onclick="closeLightbox(event)">
<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>
</div>
<div class="text-xs text-muted">Connectez-vous au WiFi Photomaton pour télécharger</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentPage = 1;
let totalPages = 1;
async function loadPhotos(page = 1) {
try {
const data = await api('GET', `/api/gallery/photos?page=${page}&limit=24`);
const { photos, total, pages } = data;
currentPage = page;
totalPages = pages;
document.getElementById('photo-count').textContent = `${total} photo(s)`;
document.getElementById('page-cur').textContent = page;
document.getElementById('page-total').textContent = pages;
document.getElementById('prev-btn').disabled = page <= 1;
document.getElementById('next-btn').disabled = page >= pages;
const grid = document.getElementById('photo-grid');
if (!photos || !photos.length) {
grid.innerHTML = `
<div class="empty-state" style="grid-column:1/-1">
<div class="icon">📷</div>
Aucune photo pour l'instant — prenez la pose !
</div>`;
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('');
} catch(e) {
document.getElementById('photo-grid').innerHTML = `
<div class="empty-state" style="grid-column:1/-1">
<div class="icon">⚠️</div>
Impossible de charger les photos. Êtes-vous connecté au WiFi Photomaton ?
</div>`;
}
}
function changePage(delta) { loadPhotos(currentPage + delta); }
function openLightbox(fullUrl, downloadUrl) {
document.getElementById('lightbox-img').src = fullUrl;
document.getElementById('lb-download').href = downloadUrl;
document.getElementById('lightbox').classList.add('open');
}
function closeLightbox(e) {
if (!e || e.target.id === 'lightbox' || e.target.classList.contains('lightbox-close')) {
document.getElementById('lightbox').classList.remove('open');
}
}
document.querySelector('.lightbox-close').addEventListener('click', closeLightbox);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox({}); });
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);
</script>
{% endblock %}