feat(frontend): panneau détail de carte, badges d'âge, limites WIP
- drawer de détail: corps markdown rendu (marked + DOMPurify
vendorés), commentaires, formulaire de commentaire vers Gitea;
clic distingué du drag par la distance du pointeur
- badges ⏳ N j au-delà du seuil d'âge (rouge au double)
- en-têtes de colonnes: n/limite, classe .over en rouge, toast
d'avertissement au dépôt dans une colonne saturée
- page activity.html, liens Activité dans dashboard et board
This commit is contained in:
+139
-1
@@ -50,6 +50,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
})
|
||||
.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';
|
||||
@@ -112,10 +117,143 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.kanban-column').forEach(col => {
|
||||
const count = col.querySelectorAll('.kanban-card').length;
|
||||
const badge = col.querySelector('.column-count');
|
||||
if (badge) badge.textContent = 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
} catch (err) {
|
||||
document.getElementById('detail-title').textContent = 'Erreur de chargement';
|
||||
showToast('❌ Détail indisponible : ' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
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>';
|
||||
}
|
||||
}
|
||||
|
||||
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' : '');
|
||||
|
||||
Reference in New Issue
Block a user