Diffusion SSE sur toutes les mutations (move, création, capture,
étiquettes) via _publish_board_changed; GET /api/board/{o}/{r}
(fragments _card.html rendus serveur, même pipeline que la page);
board-live.js: diff en place par colonne avec animation FLIP,
application différée pendant un drag, filtres/compteurs réappliqués,
repli par sondage 45 s sans webhooks. Carte extraite en partiel
_card.html partagé page/API.
149 lines
5.9 KiB
JavaScript
149 lines
5.9 KiB
JavaScript
/**
|
|
* Kanbante — dynamic board (issue #15).
|
|
*
|
|
* The board reflects reality without a manual refresh: any change
|
|
* (another session, Gitea via webhook/labels, capture) is pushed
|
|
* over SSE; the client then fetches server-rendered card fragments
|
|
* from /api/board/<repo> and swaps only the columns that changed,
|
|
* animating surviving cards to their new place (FLIP). A discreet
|
|
* 45 s poll covers installs without Gitea webhooks. Local drags are
|
|
* never interrupted: a refresh arriving mid-drag waits for its end.
|
|
* Filters, scroll, the detail drawer and Sortable all survive,
|
|
* because only column contents are replaced — never the page.
|
|
*/
|
|
(function () {
|
|
var match = window.location.pathname.match(/^\/board\/(.+)$/);
|
|
if (!match) return;
|
|
var repoPath = match[1].replace(/\/$/, '');
|
|
var milestone = new URLSearchParams(window.location.search).get('milestone') || '';
|
|
var apiUrl = '/api/board/' + repoPath + (milestone ? '?milestone=' + encodeURIComponent(milestone) : '');
|
|
var repoFullName = repoPath.split('/').map(decodeURIComponent).join('/');
|
|
|
|
var lastEventAt = Date.now();
|
|
var inFlight = false;
|
|
var queued = false;
|
|
var debounce = null;
|
|
var failedOnce = false;
|
|
|
|
function signature(html) {
|
|
var h = 0;
|
|
for (var i = 0; i < html.length; i++) {
|
|
h = ((h << 5) - h + html.charCodeAt(i)) | 0;
|
|
}
|
|
return html.length + ':' + h;
|
|
}
|
|
|
|
function captureRects() {
|
|
var rects = {};
|
|
document.querySelectorAll('.kanban-card').forEach(function (card) {
|
|
rects[card.getAttribute('data-issue')] = card.getBoundingClientRect();
|
|
});
|
|
return rects;
|
|
}
|
|
|
|
function playFlip(before) {
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
document.querySelectorAll('.kanban-card').forEach(function (card) {
|
|
var key = card.getAttribute('data-issue');
|
|
var old = before[key];
|
|
if (!old) {
|
|
card.animate(
|
|
[{ opacity: 0, transform: 'scale(0.96)' }, { opacity: 1, transform: 'none' }],
|
|
{ duration: 220, easing: 'ease-out' }
|
|
);
|
|
return;
|
|
}
|
|
var now = card.getBoundingClientRect();
|
|
var dx = old.left - now.left;
|
|
var dy = old.top - now.top;
|
|
if (dx || dy) {
|
|
card.animate(
|
|
[{ transform: 'translate(' + dx + 'px,' + dy + 'px)' }, { transform: 'none' }],
|
|
{ duration: 280, easing: 'ease-in-out' }
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
function showFallbackPill() {
|
|
if (document.getElementById('live-pill')) return;
|
|
var pill = document.createElement('button');
|
|
pill.id = 'live-pill';
|
|
pill.type = 'button';
|
|
pill.textContent = '🔄 Actualisation automatique en échec — cliquer pour recharger';
|
|
pill.addEventListener('click', function () { window.location.reload(); });
|
|
document.body.appendChild(pill);
|
|
}
|
|
|
|
function applyData(data) {
|
|
var before = captureRects();
|
|
var changed = false;
|
|
Object.keys(data.columns).forEach(function (col) {
|
|
var container = document.querySelector('.column-cards[data-column="' + col + '"]');
|
|
if (!container) return;
|
|
var payload = data.columns[col];
|
|
var sig = signature(payload.html);
|
|
if (container.getAttribute('data-sig') === sig) return;
|
|
container.innerHTML = payload.html;
|
|
container.setAttribute('data-sig', sig);
|
|
changed = true;
|
|
});
|
|
if (!changed) return;
|
|
playFlip(before);
|
|
if (window.kanbanteUpdateCounts) window.kanbanteUpdateCounts();
|
|
if (window.kanbanteRefreshHints) window.kanbanteRefreshHints();
|
|
if (window.kanbanteApplyFilters) window.kanbanteApplyFilters();
|
|
}
|
|
|
|
function refresh() {
|
|
if (window.__kanbanteDragging) {
|
|
setTimeout(refresh, 700); // retry once the local drag ends
|
|
return;
|
|
}
|
|
if (inFlight) { queued = true; return; }
|
|
inFlight = true;
|
|
fetch(apiUrl, { headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
.then(function (data) { failedOnce = false; applyData(data); })
|
|
.catch(function () { if (!failedOnce) { failedOnce = true; showFallbackPill(); } })
|
|
.finally(function () {
|
|
inFlight = false;
|
|
if (queued) { queued = false; refresh(); }
|
|
});
|
|
}
|
|
|
|
function scheduleRefresh() {
|
|
clearTimeout(debounce);
|
|
debounce = setTimeout(refresh, 400);
|
|
}
|
|
|
|
// 1. Push: SSE events about this repo (internal moves broadcast
|
|
// {"type": "board-changed"}; webhooks broadcast their payload).
|
|
if (window.EventSource) {
|
|
var source = new EventSource('/api/stream');
|
|
source.addEventListener('gitea', function (e) {
|
|
try {
|
|
var payload = JSON.parse(e.data);
|
|
if (payload.repo && payload.repo !== repoFullName) return;
|
|
} catch (err) { /* unparseable -> refresh anyway */ }
|
|
lastEventAt = Date.now();
|
|
scheduleRefresh();
|
|
});
|
|
source.addEventListener('error', function () { /* EventSource retries itself */ });
|
|
}
|
|
|
|
// 2. Fallback poll (no webhooks configured, or SSE missed
|
|
// something): only when visible and quiet for a full cycle.
|
|
setInterval(function () {
|
|
if (document.visibilityState === 'visible' && Date.now() - lastEventAt >= 45000) {
|
|
lastEventAt = Date.now();
|
|
refresh();
|
|
}
|
|
}, 5000);
|
|
|
|
// 3. Coming back to the tab: catch up immediately.
|
|
document.addEventListener('visibilitychange', function () {
|
|
if (document.visibilityState === 'visible') refresh();
|
|
});
|
|
})();
|