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); }