diff --git a/app/templates/wall.html b/app/templates/wall.html
index 57ce551..d7a2d3d 100644
--- a/app/templates/wall.html
+++ b/app/templates/wall.html
@@ -58,6 +58,14 @@
{% endfor %}
+{% if not single and slides|length > 1 %}
+
+{% endif %}
{% else %}
🖥️ Wallboard
diff --git a/static/css/style.css b/static/css/style.css
index f040478..f708a46 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -1080,3 +1080,29 @@ footer {
}
.triage-project:hover { border-color: var(--accent, #58a6ff); }
.triage-project-count { color: var(--text-3); }
+
+/* ── Wallboard : navigation manuelle (0.11.0) ── */
+.wall-nav {
+ position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%);
+ display: flex; align-items: center; gap: 14px; z-index: 50;
+}
+.wall-nav-btn {
+ width: 46px; height: 46px; border-radius: 50%;
+ background: rgba(19, 26, 41, 0.72); color: #dbe4f0;
+ border: 1px solid rgba(143, 163, 191, 0.45);
+ font-size: 1.15rem; cursor: pointer; backdrop-filter: blur(4px);
+}
+.wall-nav-btn:hover { border-color: #58a6ff; color: #fff; }
+.wall-dots { display: flex; gap: 8px; }
+.wall-dot {
+ width: 13px; height: 13px; border-radius: 50%; padding: 0;
+ background: rgba(143, 163, 191, 0.35); border: 1px solid rgba(143, 163, 191, 0.5);
+ cursor: pointer;
+}
+.wall-dot.on { background: #58a6ff; border-color: #58a6ff; }
+.wall-paused {
+ position: fixed; top: 18px; left: 50%; transform: translateX(-50%);
+ background: rgba(19, 26, 41, 0.85); color: #f0b429;
+ border: 1px solid rgba(240, 180, 41, 0.55); border-radius: 99px;
+ padding: 6px 16px; font-size: 0.95rem; z-index: 51;
+}
diff --git a/static/js/wall.js b/static/js/wall.js
index f0b9be7..d428bd3 100644
--- a/static/js/wall.js
+++ b/static/js/wall.js
@@ -1,21 +1,90 @@
/**
- * Kanbante — Wallboard: slide rotation, clock, live refresh.
+ * Kanbante — Wallboard: rotation, manual navigation, clock, live.
*
- * The wall is a display, not a workspace: no interaction to protect,
- * so a realtime event simply reloads the page (plus a slow safety
- * reload in case the stream drops silently).
+ * One slide per active project, rotating every 30 s. The viewer can
+ * take over at any time: ←/→ keys (or the on-screen ❮ ❯ buttons and
+ * dots) switch projects immediately and pause the rotation for
+ * 90 s; Space toggles a hard pause. The current position survives
+ * the live/safety reloads via sessionStorage, so the wall no longer
+ * appears stuck on the first project after every refresh.
*/
(function () {
var slides = Array.from(document.querySelectorAll('.wall-slide'));
var ROTATE_MS = 30000;
- var current = 0;
+ var MANUAL_PAUSE_MS = 90000;
+ var STORAGE_KEY = 'kanbante-wall-index';
+ var current = 0;
+ var paused = false;
+ var pausedUntil = 0;
+ var pausedBadge = document.getElementById('wall-paused');
+ var dotsBox = document.getElementById('wall-dots');
+
+ function updateBadge() {
+ if (pausedBadge) {
+ pausedBadge.hidden = !(paused || Date.now() < pausedUntil);
+ }
+ }
+
+ function updateDots() {
+ if (!dotsBox) return;
+ Array.from(dotsBox.children).forEach(function (dot, i) {
+ dot.classList.toggle('on', i === current);
+ });
+ }
+
+ function show(index, manual) {
+ if (!slides.length) return;
+ slides[current].classList.remove('active');
+ current = ((index % slides.length) + slides.length) % slides.length;
+ slides[current].classList.add('active');
+ try { sessionStorage.setItem(STORAGE_KEY, String(current)); } catch (e) { /* private mode */ }
+ updateDots();
+ if (manual) {
+ pausedUntil = Date.now() + MANUAL_PAUSE_MS;
+ updateBadge();
+ }
+ }
+
+ // Restore the position kept across reloads.
if (slides.length > 1) {
+ var saved = 0;
+ try { saved = parseInt(sessionStorage.getItem(STORAGE_KEY) || '0', 10) || 0; } catch (e) { saved = 0; }
+ if (saved) show(saved, false);
+
+ // Dots (one per project, clickable).
+ if (dotsBox) {
+ slides.forEach(function (_slide, i) {
+ var dot = document.createElement('button');
+ dot.type = 'button';
+ dot.className = 'wall-dot';
+ dot.title = 'Projet ' + (i + 1);
+ dot.setAttribute('aria-label', 'Afficher le projet ' + (i + 1));
+ dot.addEventListener('click', function () { show(i, true); });
+ dotsBox.appendChild(dot);
+ });
+ updateDots();
+ }
+ var prev = document.getElementById('wall-prev');
+ var next = document.getElementById('wall-next');
+ if (prev) prev.addEventListener('click', function () { show(current - 1, true); });
+ if (next) next.addEventListener('click', function () { show(current + 1, true); });
+
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'ArrowRight') { e.preventDefault(); show(current + 1, true); }
+ else if (e.key === 'ArrowLeft') { e.preventDefault(); show(current - 1, true); }
+ else if (e.key === ' ') {
+ e.preventDefault();
+ paused = !paused;
+ if (!paused) pausedUntil = 0;
+ updateBadge();
+ }
+ });
+
setInterval(function () {
- slides[current].classList.remove('active');
- current = (current + 1) % slides.length;
- slides[current].classList.add('active');
+ if (!paused && Date.now() >= pausedUntil) show(current + 1, false);
}, ROTATE_MS);
+ setInterval(updateBadge, 5000);
}
function tickClock() {
@@ -30,10 +99,11 @@
tickClock();
setInterval(tickClock, 15000);
- // Live: any board activity refreshes the wall.
+ // Live: board activity reloads the wall. The hub emits NAMED
+ // "gitea" events — onmessage would never fire (fixed in 0.11.0).
try {
var source = new EventSource('/api/stream');
- source.onmessage = function () { location.reload(); };
+ source.addEventListener('gitea', function () { location.reload(); });
} catch (err) {
console.warn('Wallboard live stream unavailable:', err);
}