slugify + branch_name_for (issue-<n>-<slug>, reconnu par #1/#2);
client create_branch; POST /api/issues/{o}/{r}/{n}/branch (nom
libre validé, idempotent si existante); la carte passe en En
cours, journalisée source branch, étiquette synchronisée;
formulaire dans la section Code lié du panneau détail.
569 lines
27 KiB
JavaScript
569 lines
27 KiB
JavaScript
/**
|
|
* Kanbante — Kanban board: drag & drop with persisted order,
|
|
* filters, and issue creation.
|
|
*
|
|
* Failure contract: if the server rejects a move (HTTP error, network
|
|
* error, or ok:false), the card is put back where it came from and an
|
|
* error (red) toast is shown — the UI never claims a move that the
|
|
* backend did not record.
|
|
*/
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
const repoFullName = document.querySelector('.kanban-card')?.dataset.repo
|
|
|| document.querySelector('footer span')?.textContent || '';
|
|
|
|
function orderOf(columnEl) {
|
|
return Array.from(columnEl.querySelectorAll('.kanban-card'))
|
|
.map(card => card.dataset.issue).join(',');
|
|
}
|
|
|
|
function revert(evt) {
|
|
const from = evt.from;
|
|
const ref = from.children[evt.oldIndex] || null;
|
|
from.insertBefore(evt.item, ref);
|
|
updateCounts();
|
|
}
|
|
|
|
document.querySelectorAll('.column-cards').forEach(col => {
|
|
new Sortable(col, {
|
|
group: 'kanban',
|
|
animation: 200,
|
|
draggable: '.kanban-card',
|
|
ghostClass: 'dragging',
|
|
onStart: function () { window.__kanbanteDragging = true; },
|
|
dragClass: 'dragging',
|
|
onEnd: function (evt) {
|
|
window.__kanbanteDragging = false;
|
|
if (evt.from === evt.to && evt.oldIndex === evt.newIndex) return;
|
|
refreshEmptyHints();
|
|
const card = evt.item;
|
|
const formData = new FormData();
|
|
formData.append('repo_full_name', card.dataset.repo);
|
|
formData.append('issue_number', card.dataset.issue);
|
|
formData.append('to_column', evt.to.dataset.column);
|
|
formData.append('from_column', evt.from.dataset.column);
|
|
formData.append('to_order', orderOf(evt.to));
|
|
formData.append('from_order', orderOf(evt.from));
|
|
formData.append('csrf_token', csrfToken);
|
|
|
|
fetch('/api/move', { method: 'POST', body: formData })
|
|
.then(async r => {
|
|
const data = await r.json().catch(() => ({}));
|
|
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
|
return data;
|
|
})
|
|
.then(data => {
|
|
updateCounts();
|
|
const targetCol = evt.to.closest('.kanban-column');
|
|
const badge = targetCol?.querySelector('.column-count');
|
|
if (badge?.classList.contains('over')) {
|
|
showToast('⚠️ Limite WIP dépassée dans ' + evt.to.dataset.column.replace('_', ' ') + ' (' + badge.textContent + ')', true);
|
|
}
|
|
let msg = '✅ Déplacé vers ' + evt.to.dataset.column.replace('_', ' ');
|
|
if (data.gitea_sync === 'closed') msg += ' · issue fermée dans Gitea';
|
|
if (data.gitea_sync === 'reopened') msg += ' · issue rouverte dans Gitea';
|
|
if (data.gitea_sync === 'failed') {
|
|
showToast('⚠️ Déplacé localement, mais la synchro Gitea a échoué', true);
|
|
return;
|
|
}
|
|
showToast(msg);
|
|
})
|
|
.catch(err => {
|
|
console.error('Move failed:', err);
|
|
revert(evt);
|
|
refreshEmptyHints();
|
|
showToast('❌ Échec du déplacement — carte replacée', true);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Filters (text + label), client-side ────────────────
|
|
const textFilter = document.getElementById('card-filter');
|
|
const labelFilter = document.getElementById('label-filter');
|
|
function applyFilters() {
|
|
const q = (textFilter?.value || '').toLowerCase();
|
|
const label = (labelFilter?.value || '').toLowerCase();
|
|
document.querySelectorAll('.kanban-card').forEach(card => {
|
|
const matchesText = !q || (card.dataset.title || '').includes(q);
|
|
const matchesLabel = !label || (card.dataset.labels || '').split(',').includes(label);
|
|
card.style.display = (matchesText && matchesLabel) ? '' : 'none';
|
|
});
|
|
updateCounts();
|
|
}
|
|
textFilter?.addEventListener('input', applyFilters);
|
|
labelFilter?.addEventListener('change', applyFilters);
|
|
|
|
// ── Create issue (goes to Gitea, card lands in Backlog) ─
|
|
const createForm = document.getElementById('create-issue-form');
|
|
createForm?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const titleInput = document.getElementById('new-issue-title');
|
|
const title = titleInput.value.trim();
|
|
if (!title || !repoFullName) return;
|
|
const formData = new FormData();
|
|
formData.append('repo_full_name', repoFullName);
|
|
formData.append('title', title);
|
|
formData.append('column', 'backlog');
|
|
formData.append('csrf_token', csrfToken);
|
|
try {
|
|
const r = await fetch('/api/issues', { method: 'POST', body: formData });
|
|
const data = await r.json().catch(() => ({}));
|
|
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
|
showToast('✅ Issue #' + data.issue.number + ' créée');
|
|
setTimeout(() => location.reload(), 600);
|
|
} catch (err) {
|
|
console.error('Create failed:', err);
|
|
showToast('❌ Échec de la création : ' + err.message, true);
|
|
}
|
|
});
|
|
|
|
// The "Glissez une carte ici" hint is rendered by the server only
|
|
// for columns empty at page load. After a drag, the DOM is the
|
|
// truth: remove the hint from columns that hold cards, restore it
|
|
// on columns that became empty. (Bug reported 2026-10-11: the
|
|
// hint stayed forever after dropping a card into an empty column.)
|
|
function refreshEmptyHints() {
|
|
document.querySelectorAll('.column-cards').forEach(list => {
|
|
const hint = list.querySelector('.column-empty');
|
|
const hasCards = !!list.querySelector('.kanban-card');
|
|
if (hasCards && hint) hint.remove();
|
|
if (!hasCards && !hint) {
|
|
const p = document.createElement('p');
|
|
p.className = 'column-empty';
|
|
p.textContent = 'Glissez une carte ici';
|
|
list.appendChild(p);
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateCounts() {
|
|
document.querySelectorAll('.kanban-column').forEach(col => {
|
|
const count = col.querySelectorAll('.kanban-card').length;
|
|
const badge = col.querySelector('.column-count');
|
|
if (!badge) return;
|
|
const limit = parseInt(badge.dataset.limit || '0', 10);
|
|
if (limit > 0) {
|
|
badge.textContent = count + '/' + limit;
|
|
badge.classList.toggle('over', count > limit);
|
|
} else {
|
|
badge.textContent = count;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Exposed for board-live.js (issue #15): after a remote refresh
|
|
// swaps column fragments, filters/counts/hints must be recomputed.
|
|
window.kanbanteApplyFilters = applyFilters;
|
|
window.kanbanteRefreshHints = refreshEmptyHints;
|
|
window.kanbanteUpdateCounts = updateCounts;
|
|
|
|
// Keyboard: "/" jumps to the filter box.
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === '/' && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || '')) {
|
|
e.preventDefault();
|
|
document.getElementById('card-filter')?.focus();
|
|
}
|
|
});
|
|
|
|
// ── 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;
|
|
document.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
|
|
document.querySelectorAll('.kanban-card').forEach(card => {
|
|
card.addEventListener('click', (e) => {
|
|
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return;
|
|
if (e.target.closest('a')) return;
|
|
openDetail(card.dataset.repo, card.dataset.issue);
|
|
});
|
|
});
|
|
|
|
const drawer = document.getElementById('card-detail');
|
|
const overlay = document.getElementById('detail-overlay');
|
|
let currentIssue = null; // {repo, number}
|
|
|
|
function renderMarkdown(text) {
|
|
if (window.marked && window.DOMPurify) {
|
|
return DOMPurify.sanitize(marked.parse(text || ''));
|
|
}
|
|
const div = document.createElement('div');
|
|
div.textContent = text || '';
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function closeDetail() {
|
|
drawer.hidden = true;
|
|
overlay.hidden = true;
|
|
currentIssue = null;
|
|
}
|
|
document.getElementById('detail-close')?.addEventListener('click', closeDetail);
|
|
overlay?.addEventListener('click', closeDetail);
|
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); });
|
|
|
|
async function openDetail(repoFull, number) {
|
|
const [owner, repo] = repoFull.split('/', 2);
|
|
currentIssue = { repo: repoFull, number };
|
|
drawer.hidden = false;
|
|
overlay.hidden = false;
|
|
document.getElementById('detail-number').textContent = '#' + number;
|
|
document.getElementById('detail-title').textContent = 'Chargement...';
|
|
document.getElementById('detail-body').innerHTML = '';
|
|
document.getElementById('detail-comments').innerHTML = '';
|
|
try {
|
|
const r = await fetch(`/api/issues/${owner}/${repo}/${number}`);
|
|
const data = await r.json().catch(() => ({}));
|
|
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
|
fillDetail(data.issue, data.comments);
|
|
loadCodeLinks(repoFull, number);
|
|
} catch (err) {
|
|
document.getElementById('detail-title').textContent = 'Erreur de chargement';
|
|
showToast('❌ Détail indisponible : ' + err.message, true);
|
|
}
|
|
}
|
|
|
|
|
|
async function loadCodeLinks(repoFull, number) {
|
|
const box = document.getElementById('detail-code');
|
|
if (!box) return;
|
|
box.innerHTML = '';
|
|
const [owner, repo] = repoFull.split('/', 2);
|
|
try {
|
|
const r = await fetch(`/api/issues/${owner}/${repo}/${number}/code`);
|
|
const data = await r.json().catch(() => ({}));
|
|
if (!r.ok || !data.ok) return;
|
|
const parts = [];
|
|
if ((data.branches || []).length) {
|
|
parts.push('🌿 Branches : ' + data.branches.map(b => `<code>${b}</code>`).join(', '));
|
|
}
|
|
for (const pr of data.pulls || []) {
|
|
const state = pr.merged ? 'fusionnée' : (pr.state === 'open' ? 'ouverte' : 'fermée');
|
|
parts.push(`🔀 <a href="${pr.html_url}" target="_blank" rel="noopener">PR #${pr.number}</a> ${pr.title} — ${state}`);
|
|
}
|
|
for (const c of (data.commits || []).slice(0, 8)) {
|
|
parts.push(`⑂ <a href="${c.html_url}" target="_blank" rel="noopener"><code>${c.sha}</code></a> ${c.message}${c.date ? ' · ' + c.date : ''}`);
|
|
}
|
|
const hasBranch = (data.branches || []).length > 0;
|
|
if (!hasBranch && data.suggested_branch) {
|
|
parts.push(`🌿 <input id="branch-name" class="branch-input" value="${data.suggested_branch}" size="36" spellcheck="false"> <button type="button" class="btn-sm" id="branch-create">Créer la branche</button>`);
|
|
}
|
|
box.innerHTML = '<h3 class="detail-comments-title">🧬 Code lié</h3>' + (
|
|
parts.length
|
|
? parts.map(p => `<div class="code-line">${p}</div>`).join('')
|
|
: '<div class="code-line">Aucun élément lié pour l\'instant.</div>'
|
|
);
|
|
const createBtn = document.getElementById('branch-create');
|
|
if (createBtn) {
|
|
createBtn.addEventListener('click', async () => {
|
|
const name = (document.getElementById('branch-name') || {}).value || '';
|
|
const form = new FormData();
|
|
form.append('name', name);
|
|
form.append('csrf_token', csrfToken);
|
|
createBtn.disabled = true;
|
|
try {
|
|
const r2 = await fetch(`/api/issues/${owner}/${repo}/${number}/branch`, { method: 'POST', body: form });
|
|
const body = await r2.json().catch(() => ({}));
|
|
if (!r2.ok || !body.ok) throw new Error(body.error || ('HTTP ' + r2.status));
|
|
showToast(body.exists
|
|
? `🌿 La branche ${body.branch} existait déjà`
|
|
: `🌿 Branche ${body.branch} créée depuis ${body.from} — carte en « En cours »`);
|
|
loadCodeLinks(repoFull, number);
|
|
} catch (err) {
|
|
createBtn.disabled = false;
|
|
showToast('❌ Branche non créée : ' + err.message, true);
|
|
}
|
|
});
|
|
}
|
|
} catch (err) { /* le code est un bonus, jamais bloquant */ }
|
|
}
|
|
|
|
function fillDetail(issue, comments) {
|
|
document.getElementById('detail-title').textContent = issue.title;
|
|
const stateChip = document.getElementById('detail-state');
|
|
stateChip.textContent = issue.state === 'closed' ? '✅ Fermée' : '🟢 Ouverte';
|
|
const meta = [];
|
|
if (issue.milestone) meta.push('🎯 ' + issue.milestone);
|
|
if (issue.due_date) meta.push('📅 échéance ' + String(issue.due_date).slice(0, 10));
|
|
meta.push('créée le ' + String(issue.created_at).slice(0, 10));
|
|
for (const a of issue.assignees || []) {
|
|
meta.push('👤 ' + a.login);
|
|
}
|
|
document.getElementById('detail-meta').textContent = meta.join(' · ');
|
|
const labelsBox = document.getElementById('detail-labels');
|
|
labelsBox.innerHTML = '';
|
|
(issue.labels || []).forEach(l => {
|
|
const span = document.createElement('span');
|
|
span.className = 'label-tag';
|
|
const color = /^[0-9a-fA-F]{6}$/.test(l.color || '') ? l.color : '888888';
|
|
span.style.background = '#' + color + '30';
|
|
span.style.color = '#' + color;
|
|
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 || '#';
|
|
const box = document.getElementById('detail-comments');
|
|
box.innerHTML = '';
|
|
(comments || []).forEach(c => box.appendChild(commentNode(c)));
|
|
if (!comments || comments.length === 0) {
|
|
box.innerHTML = '<p class="detail-empty">Aucun commentaire.</p>';
|
|
}
|
|
}
|
|
|
|
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';
|
|
const head = document.createElement('div');
|
|
head.className = 'comment-head';
|
|
head.textContent = '👤 ' + (c.user?.login || '?') + ' · ' + String(c.created_at).slice(0, 16).replace('T', ' ');
|
|
const bodyEl = document.createElement('div');
|
|
bodyEl.className = 'comment-body';
|
|
bodyEl.innerHTML = renderMarkdown(c.body || '');
|
|
div.appendChild(head);
|
|
div.appendChild(bodyEl);
|
|
return div;
|
|
}
|
|
|
|
document.getElementById('comment-form')?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
if (!currentIssue) return;
|
|
const textarea = document.getElementById('comment-body');
|
|
const body = textarea.value.trim();
|
|
if (!body) return;
|
|
const [owner, repo] = currentIssue.repo.split('/', 2);
|
|
const formData = new FormData();
|
|
formData.append('body', body);
|
|
formData.append('csrf_token', csrfToken);
|
|
try {
|
|
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/comments`, { method: 'POST', body: formData });
|
|
const data = await r.json().catch(() => ({}));
|
|
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
|
const box = document.getElementById('detail-comments');
|
|
if (box.querySelector('.detail-empty')) box.innerHTML = '';
|
|
box.appendChild(commentNode(data.comment));
|
|
textarea.value = '';
|
|
showToast('✅ Commentaire publié dans Gitea');
|
|
} catch (err) {
|
|
showToast('❌ Échec du commentaire : ' + err.message, true);
|
|
}
|
|
});
|
|
|
|
function showToast(msg, isError = false) {
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast' + (isError ? ' error' : '');
|
|
toast.textContent = msg;
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 3000);
|
|
}
|
|
});
|