feat(board): étiquettes gérables, notes repliable/redimensionnable/auto-save

- modale Étiquettes: liste, création (nom + couleur + palette),
  suppression via DELETE (CSRF par en-tête); panneau détail:
  bascules d'étiquettes par issue avec mise à jour immédiate des
  chips de la carte; _issue_payload expose les ids d'étiquettes
- API étiquettes dans main (routes repos + toggle issue)
- notes: bouton replier/déplier et largeur par glisser, persistés
  par projet (localStorage), sauvegarde automatique débouncée
  avec statut, formulaire repris par board.js (HTMX retiré du board)
- nouvelles pages search/my/briefing; navigation enrichie partout
  (Mes issues, Briefing, Recherche); CSS associé
This commit is contained in:
Spark
2026-10-11 11:00:53 -04:00
parent a788db7b1f
commit 6887d631ea
10 changed files with 621 additions and 22 deletions
+210
View File
@@ -138,6 +138,161 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// ── Repo context (repoFullName is declared at the top) ──
const [repoOwner, repoName] = (repoFullName || '').split('/', 2);
// ── Notes panel: collapsible, resizable, autosave ──────
const notesPanel = document.getElementById('notes-panel');
if (notesPanel) {
const widthKey = 'kb.notes.w.' + repoFullName;
const collapsedKey = 'kb.notes.c.' + repoFullName;
const savedWidth = parseInt(localStorage.getItem(widthKey) || '0', 10);
if (savedWidth >= 200 && savedWidth <= 560) notesPanel.style.width = savedWidth + 'px';
const toggleBtn = document.getElementById('notes-toggle');
function applyCollapsed(collapsed) {
notesPanel.classList.toggle('collapsed', collapsed);
toggleBtn.textContent = collapsed ? '⤡' : '⤢';
toggleBtn.title = collapsed ? 'Déplier le panneau' : 'Replier le panneau';
}
applyCollapsed(localStorage.getItem(collapsedKey) === '1');
toggleBtn?.addEventListener('click', () => {
const collapsed = !notesPanel.classList.contains('collapsed');
applyCollapsed(collapsed);
localStorage.setItem(collapsedKey, collapsed ? '1' : '0');
});
const handle = document.getElementById('notes-resize');
handle?.addEventListener('pointerdown', (e) => {
e.preventDefault();
const startX = e.clientX;
const startW = notesPanel.getBoundingClientRect().width;
function onMove(ev) {
const w = Math.min(560, Math.max(200, startW + (startX - ev.clientX)));
notesPanel.style.width = w + 'px';
}
function onUp() {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
localStorage.setItem(widthKey, String(Math.round(notesPanel.getBoundingClientRect().width)));
}
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
});
const notesText = document.getElementById('notes-text');
const notesStatus = document.getElementById('notes-status');
let notesTimer = null;
async function saveNotes() {
const fd = new FormData();
fd.append('repo_full_name', repoFullName);
fd.append('note', notesText.value);
fd.append('csrf_token', csrfToken);
notesStatus.textContent = '💾 Enregistrement...';
try {
const r = await fetch('/api/notes', { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
notesStatus.textContent = '✓ Enregistré à ' + new Date().toLocaleTimeString('fr-CA');
} catch (err) {
notesStatus.textContent = '❌ Échec : ' + err.message;
}
}
notesText?.addEventListener('input', () => {
notesStatus.textContent = '✎ Modifié...';
clearTimeout(notesTimer);
notesTimer = setTimeout(saveNotes, 1200);
});
document.getElementById('notes-form')?.addEventListener('submit', (e) => {
e.preventDefault();
clearTimeout(notesTimer);
saveNotes();
});
}
// ── Label manager ──────────────────────────────────────
let repoLabelsCache = null;
async function fetchRepoLabels() {
if (repoLabelsCache) return repoLabelsCache;
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels`);
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
repoLabelsCache = data.labels;
return repoLabelsCache;
}
function labelChipEl(label) {
const span = document.createElement('span');
span.className = 'label-tag';
const color = /^[0-9a-fA-F]{6}$/.test(label.color || '') ? label.color : '888888';
span.style.background = '#' + color + '30';
span.style.color = '#' + color;
span.textContent = label.name;
return span;
}
const labelModal = document.getElementById('label-modal');
async function renderLabelList() {
const box = document.getElementById('label-list');
box.innerHTML = '';
try {
const labels = await fetchRepoLabels();
if (!labels.length) box.innerHTML = '<p class="detail-empty">Aucune étiquette dans ce dépôt.</p>';
labels.forEach(label => {
const row = document.createElement('div');
row.className = 'label-row';
row.appendChild(labelChipEl(label));
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn-sm label-del';
del.textContent = '🗑️ Supprimer';
del.addEventListener('click', async () => {
if (!confirm('Supprimer l\'étiquette « ' + label.name + ' » de tout le dépôt ?')) return;
try {
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels/${label.id}`,
{ method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken } });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
repoLabelsCache = repoLabelsCache.filter(l => l.id !== label.id);
row.remove();
showToast('✅ Étiquette supprimée');
} catch (err) { showToast('❌ ' + err.message, true); }
});
row.appendChild(del);
box.appendChild(row);
});
} catch (err) {
box.innerHTML = '<p class="detail-empty">Erreur : ' + err.message + '</p>';
}
}
document.getElementById('labels-btn')?.addEventListener('click', () => {
labelModal.hidden = false;
renderLabelList();
});
document.getElementById('label-modal-close')?.addEventListener('click', () => { labelModal.hidden = true; });
labelModal?.addEventListener('click', (e) => { if (e.target === labelModal) labelModal.hidden = true; });
document.querySelectorAll('.swatch').forEach(sw => sw.addEventListener('click', () => {
document.getElementById('new-label-color').value = sw.dataset.color;
}));
document.getElementById('label-create-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('new-label-name').value.trim();
const color = document.getElementById('new-label-color').value.replace('#', '');
if (!name) return;
const fd = new FormData();
fd.append('name', name);
fd.append('color', color);
fd.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels`, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
if (repoLabelsCache) repoLabelsCache.push(data.label);
document.getElementById('new-label-name').value = '';
renderLabelList();
showToast('✅ Étiquette « ' + data.label.name + ' » créée');
} catch (err) { showToast('❌ ' + err.message, true); }
});
// ── Card detail drawer ─────────────────────────────────
// A click opens the panel; a drag must not. Track pointer travel.
let downX = 0, downY = 0;
@@ -215,6 +370,7 @@ document.addEventListener('DOMContentLoaded', () => {
span.textContent = l.name;
labelsBox.appendChild(span);
});
renderLabelToggles(issue);
document.getElementById('detail-body').innerHTML =
issue.body ? renderMarkdown(issue.body) : '<p class="detail-empty">Aucune description.</p>';
document.getElementById('detail-gitea-link').href = issue.html_url || '#';
@@ -226,6 +382,60 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
async function renderLabelToggles(issue) {
const box = document.getElementById('detail-label-toggles');
if (!box) return;
box.innerHTML = '';
let all = [];
try { all = await fetchRepoLabels(); } catch { return; }
const activeIds = new Set((issue.labels || []).map(l => l.id));
all.forEach(label => {
const chip = labelChipEl(label);
chip.classList.add('label-toggle');
if (activeIds.has(label.id)) chip.classList.add('on');
chip.title = activeIds.has(label.id) ? 'Retirer de l\'issue' : 'Ajouter à l\'issue';
chip.addEventListener('click', async () => {
const action = activeIds.has(label.id) ? 'remove' : 'add';
const fd = new FormData();
fd.append('label_id', label.id);
fd.append('action', action);
fd.append('csrf_token', csrfToken);
try {
const [owner, repo] = currentIssue.repo.split('/', 2);
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/labels`,
{ method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
issue.labels = data.labels;
// Refresh the chips in the drawer and on the board card.
const labelsBox = document.getElementById('detail-labels');
labelsBox.innerHTML = '';
data.labels.forEach(l => labelsBox.appendChild(labelChipEl(l)));
updateCardLabels(currentIssue.number, data.labels);
renderLabelToggles(issue);
} catch (err) { showToast('❌ ' + err.message, true); }
});
box.appendChild(chip);
});
}
function updateCardLabels(number, labels) {
const card = document.querySelector(`.kanban-card[data-issue="${number}"]`);
if (!card) return;
card.dataset.labels = labels.map(l => (l.name || '').toLowerCase()).join(',');
let box = card.querySelector('.card-labels');
if (!box && labels.length) {
box = document.createElement('div');
box.className = 'card-labels';
card.querySelector('.card-title')?.after(box);
}
if (box) {
box.innerHTML = '';
labels.forEach(l => box.appendChild(labelChipEl(l)));
box.style.display = labels.length ? '' : 'none';
}
}
function commentNode(c) {
const div = document.createElement('div');
div.className = 'comment';