Rendu par le serveur pour les colonnes vides au chargement, il n'était jamais retiré après un dépôt (signalé par Bruno). Nouvelle fonction refreshEmptyHints() appelée après chaque déplacement et après chaque retour arrière: indice retiré si la colonne contient une carte, rétabli si elle devient vide. SortableJS restreint aux cartes (draggable: '.kanban-card'). Logique vérifiée par mini-test DOM (4 scénarios) en plus de node --check.
507 lines
24 KiB
JavaScript
507 lines
24 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;
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
} 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);
|
|
});
|
|
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);
|
|
}
|
|
});
|