Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b9ec1ff99 | ||
|
|
1d3794ba55 | ||
|
|
715687da66 |
@@ -1,5 +1,27 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.11.0 — 2026-10-11
|
||||||
|
|
||||||
|
### Nouveautés
|
||||||
|
- **Wallboard navigable** : flèches `←` / `→` du clavier, boutons
|
||||||
|
❮ ❯ et pastilles cliquables pour changer de projet à la demande ;
|
||||||
|
une navigation manuelle met la rotation en pause 90 s, `Espace`
|
||||||
|
bascule une pause franche (badge visible). La **position est
|
||||||
|
mémorisée** (sessionStorage) entre les rechargements : le mur ne
|
||||||
|
repart plus à la première diapo à chaque refresh — avec 12
|
||||||
|
projets actifs × 30 s, le cycle complet (6 min) dépassait le
|
||||||
|
rechargement de sécurité (5 min), rendant les dernières diapos
|
||||||
|
inatteignables et donnant l'impression d'un mur figé.
|
||||||
|
|
||||||
|
### Correction
|
||||||
|
- L'écoute temps réel du wallboard utilisait `onmessage`, qui ne
|
||||||
|
se déclenche jamais pour les événements SSE **nommés** (`gitea`)
|
||||||
|
émis par le hub : les rechargements en direct ne partaient pas,
|
||||||
|
seul le filet de 5 minutes fonctionnait. Corrigé en
|
||||||
|
`addEventListener('gitea', …)`.
|
||||||
|
|
||||||
|
3 tests ajoutés (136 au total).
|
||||||
|
|
||||||
## 0.10.0 — 2026-10-11
|
## 0.10.0 — 2026-10-11
|
||||||
|
|
||||||
### Nouveautés
|
### Nouveautés
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
APP_VERSION = "0.10.0"
|
APP_VERSION = "0.11.0"
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
|
|||||||
+1
-1
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
|||||||
{
|
{
|
||||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||||
"capabilities": {},
|
"capabilities": {},
|
||||||
"clientInfo": {"name": "kanbante", "version": "0.10.0"},
|
"clientInfo": {"name": "kanbante", "version": "0.11.0"},
|
||||||
},
|
},
|
||||||
req_id=1,
|
req_id=1,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -58,6 +58,14 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if not single and slides|length > 1 %}
|
||||||
|
<nav class="wall-nav" aria-label="Navigation des projets">
|
||||||
|
<button type="button" class="wall-nav-btn" id="wall-prev" title="Projet précédent (←)">❮</button>
|
||||||
|
<div class="wall-dots" id="wall-dots"></div>
|
||||||
|
<button type="button" class="wall-nav-btn" id="wall-next" title="Projet suivant (→)">❯</button>
|
||||||
|
<div class="wall-paused" id="wall-paused" hidden>⏸️ Rotation en pause — Espace pour reprendre</div>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="wall-empty">
|
<div class="wall-empty">
|
||||||
<h1>🖥️ Wallboard</h1>
|
<h1>🖥️ Wallboard</h1>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "kanbante"
|
name = "kanbante"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
description = "Kanban project manager integrated with Gitea"
|
description = "Kanban project manager integrated with Gitea"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1080,3 +1080,29 @@ footer {
|
|||||||
}
|
}
|
||||||
.triage-project:hover { border-color: var(--accent, #58a6ff); }
|
.triage-project:hover { border-color: var(--accent, #58a6ff); }
|
||||||
.triage-project-count { color: var(--text-3); }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
+80
-10
@@ -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,
|
* One slide per active project, rotating every 30 s. The viewer can
|
||||||
* so a realtime event simply reloads the page (plus a slow safety
|
* take over at any time: ←/→ keys (or the on-screen ❮ ❯ buttons and
|
||||||
* reload in case the stream drops silently).
|
* 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 () {
|
(function () {
|
||||||
var slides = Array.from(document.querySelectorAll('.wall-slide'));
|
var slides = Array.from(document.querySelectorAll('.wall-slide'));
|
||||||
var ROTATE_MS = 30000;
|
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) {
|
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 () {
|
setInterval(function () {
|
||||||
slides[current].classList.remove('active');
|
if (!paused && Date.now() >= pausedUntil) show(current + 1, false);
|
||||||
current = (current + 1) % slides.length;
|
|
||||||
slides[current].classList.add('active');
|
|
||||||
}, ROTATE_MS);
|
}, ROTATE_MS);
|
||||||
|
setInterval(updateBadge, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function tickClock() {
|
function tickClock() {
|
||||||
@@ -30,10 +99,11 @@
|
|||||||
tickClock();
|
tickClock();
|
||||||
setInterval(tickClock, 15000);
|
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 {
|
try {
|
||||||
var source = new EventSource('/api/stream');
|
var source = new EventSource('/api/stream');
|
||||||
source.onmessage = function () { location.reload(); };
|
source.addEventListener('gitea', function () { location.reload(); });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Wallboard live stream unavailable:', err);
|
console.warn('Wallboard live stream unavailable:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Palier 8 (0.11.0): navigation manuelle du Wallboard."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def _make_two_active_projects(fake_gitea):
|
||||||
|
"""The fake's second repo is 800 days old (inactive): revive it."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for repo in fake_gitea.repos:
|
||||||
|
if repo["full_name"] == "Projets/vieux":
|
||||||
|
repo["updated_at"] = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_wall_nav_markup_with_multiple_slides(client, fake_gitea):
|
||||||
|
_make_two_active_projects(fake_gitea)
|
||||||
|
r = client.get("/wall")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.text.count("wall-slide") >= 2
|
||||||
|
assert 'id="wall-prev"' in r.text
|
||||||
|
assert 'id="wall-next"' in r.text
|
||||||
|
assert 'id="wall-dots"' in r.text
|
||||||
|
assert 'id="wall-paused"' in r.text
|
||||||
|
assert "1 / 2" in r.text and "2 / 2" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_wall_no_nav_with_single_slide(client, fake_gitea):
|
||||||
|
r = client.get("/wall")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert 'id="wall-prev"' not in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_wall_js_contract():
|
||||||
|
js = open("static/js/wall.js", encoding="utf-8").read()
|
||||||
|
# Arrow navigation + hard pause + position persistence.
|
||||||
|
assert "ArrowRight" in js and "ArrowLeft" in js
|
||||||
|
assert "sessionStorage" in js
|
||||||
|
assert "MANUAL_PAUSE_MS" in js
|
||||||
|
# The live listener must use the NAMED gitea event (onmessage
|
||||||
|
# never fires for named SSE events — the 0.6.0 bug).
|
||||||
|
assert "addEventListener('gitea'" in js
|
||||||
|
assert "source.onmessage" not in js
|
||||||
Reference in New Issue
Block a user