// ── Bucket management ── import { $, esc, api } from './util.js'; import { state, CARD_COLORS, setActiveBucket, ensureActiveBucketValid } from './state.js'; import { showDialog } from './dialog.js'; import { pushUndo } from './undo.js'; // ── Bucket context menu ── const ctxMenu = document.createElement('div'); ctxMenu.id = 'bucket-ctx'; ctxMenu.className = 'fixed z-[85] bg-t-surface border border-t-border shadow-2xl hidden'; ctxMenu.style.minWidth = '170px'; document.body.appendChild(ctxMenu); let ctxBucketId = null; function hideBucketCtx() { ctxMenu.classList.add('hidden'); ctxBucketId = null; } document.addEventListener('click', hideBucketCtx); async function showBucketCtx(x, y, bucket) { ctxBucketId = bucket.id; // Move-left / move-right operates within the bucket's siblings — // top-level peers if the bucket is top-level, dropdown peers if it's // a super-bucket child. This keeps reordering scoped to where the // user actually sees the bucket in the UI. const siblings = state.buckets .filter(b => (b.parent_id || null) === (bucket.parent_id || null)) .sort((a, b) => (a.position || 0) - (b.position || 0)); const idx = siblings.findIndex(b => b.id == bucket.id); const canLeft = idx > 0; const canRight = idx >= 0 && idx < siblings.length - 1; const hasCards = state.cards.some(c => c.bucket_id == bucket.id); const isCollab = !!bucket.collab_uid; ctxMenu.innerHTML = `
${isCollab && typeof window._collabBucketsEditDialog === 'function' ? `` : '' } ${!isCollab && typeof window._collabBucketsUpgradeDialog === 'function' ? `` : '' }
${hasCards ? ` ` : `` }
`; ctxMenu.style.left = Math.min(x, innerWidth - 178) + 'px'; ctxMenu.style.top = Math.min(y, innerHeight - 250) + 'px'; ctxMenu.classList.remove('hidden'); ctxMenu.querySelectorAll('[data-baction]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); const a = btn.dataset.baction; if (a === 'color') { e.stopPropagation(); document.getElementById('bctx-colors').classList.toggle('hidden'); return; } else if (a === 'collab-manage') { hideBucketCtx(); if (typeof window._collabBucketsEditDialog === 'function') { const changed = await window._collabBucketsEditDialog(bucket.collab_uid); if (changed) await window._reloadAll(); } return; } else if (a === 'collab-upgrade') { hideBucketCtx(); if (typeof window._collabBucketsUpgradeDialog === 'function') { const changed = await window._collabBucketsUpgradeDialog(bucket.id); if (changed) await window._reloadAll(); } return; } else if (a === 'rename') { hideBucketCtx(); const name = await showDialog({ title: 'rename_bucket', input: true, placeholder: bucket.name }); if (!name || !name.trim()) return; // Collab buckets: push name change through the plugin (relay + // mirror). The next poll pulls the new name into the local row. if (bucket.collab_uid && typeof window._collabBucketsPatch === 'function') { const res = await window._collabBucketsPatch(bucket.collab_uid, { name: name.trim() }); if (!res?.ok) { alert(res?.error || 'rename failed'); return; } } else { await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } }); } await window._reloadAll(); } else if (a === 'move-left' || a === 'move-right') { hideBucketCtx(); const other = siblings[a === 'move-left' ? idx - 1 : idx + 1]; if (!other) return; // Swap positions of two siblings — works for both top-level // and dropdown reordering since `siblings` is scoped above. await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { position: other.position ?? 0 } }); await api(`api/buckets/${other.id}`, { method: 'PATCH', body: { position: bucket.position ?? 0 } }); await loadBuckets(); window._renderBoard(); } else if (a === 'delete-keep') { hideBucketCtx(); const cards = state.cards.filter(c => c.bucket_id == bucket.id); if (cards.length > 0 || localStorage.getItem('hyprcrd-confirm-delete') !== '0') { const msg = bucket.collab_uid ? (cards.length ? `Leave / delete "${bucket.name}"? ${cards.length} card(s) will be kept locally.` : `Leave / delete "${bucket.name}"?`) : (cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`); const ok = await showDialog({ title: 'delete_bucket', message: msg, danger: true, okLabel: bucket.collab_uid ? 'Leave / delete' : 'Delete' }); if (!ok) return; } if (state.activeBucket == bucket.id) setActiveBucket('all'); // Collab bucket: route through plugin so the relay is notified // (owner = delete for everyone; member = just leave). if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') { const res = await window._collabBucketsDelete(bucket.collab_uid); if (!res?.ok) { alert(res?.error || 'delete failed'); return; } // Undo is intentionally NOT wired for collab deletes/leaves // — restoring would require re-creating on the relay and // re-attaching cards under a new uid. Too messy for MVP. } else { // Snapshot the row + affected card ids before deleting so // the undo entry can restore them. const snapshot = { name: bucket.name, color: bucket.color, position: bucket.position }; const affected = cards.map(c => c.id); await api(`api/buckets/${bucket.id}`, { method: 'DELETE' }); pushUndo(`delete bucket "${bucket.name}"`, async () => { const created = await api('api/buckets', { method: 'POST', body: { name: snapshot.name } }); if (created?.id) { await api(`api/buckets/${created.id}`, { method: 'PATCH', body: { color: snapshot.color, position: snapshot.position } }); // Re-attach cards to the recreated bucket await Promise.all(affected.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id } }))); } }); } await window._reloadAll(); } else if (a === 'delete-all') { hideBucketCtx(); const cards = state.cards.filter(c => c.bucket_id == bucket.id); const ok = await showDialog({ title: 'delete_bucket', message: `Delete "${bucket.name}" and ${cards.length} card(s)?`, danger: true, okLabel: 'Delete all' }); if (!ok) return; for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' }); if (state.activeBucket == bucket.id) setActiveBucket('all'); if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') { const res = await window._collabBucketsDelete(bucket.collab_uid); if (!res?.ok) { alert(res?.error || 'delete failed'); return; } } else { await api(`api/buckets/${bucket.id}`, { method: 'DELETE' }); } await window._reloadAll(); } })); // Bucket color clicks ctxMenu.querySelectorAll('[data-bctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideBucketCtx(); const newColor = btn.dataset.bctxColor; if (bucket.collab_uid && typeof window._collabBucketsPatch === 'function') { const res = await window._collabBucketsPatch(bucket.collab_uid, { color: newColor }); if (!res?.ok) { alert(res?.error || 'color change failed'); return; } } else { await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: newColor } }); } await window._reloadAll(); })); } const bar = $('bucket-bar'); const inpBucket = $('inp-bucket'); const searchFilter = $('search-bucket-filter'); const tasksFilter = $('tasks-bucket-filter'); /** Switch the active bucket without going through the click path — * used by the number-key hotkeys (and any other code that wants to * activate a bucket programmatically without triggering the super- * bucket dropdown). Persists via setActiveBucket and re-renders the * bar + board so the active highlight tracks. */ export function activateBucket(idOrSpecial) { setActiveBucket(idOrSpecial); state.activeGroup = null; renderBar(); window._renderBoard?.(); } export async function loadBuckets() { state.buckets = await api('api/buckets'); // The persisted activeBucket may point to a bucket that no longer // exists (deleted in another tab, removed via import, etc.). Reset // to 'all' in that case before rendering. ensureActiveBucketValid(); renderBar(); populateSelects(); } export function populateSelects() { const bucketOpts = state.buckets.map(b => ``).join(''); if (inpBucket) inpBucket.innerHTML = `` + bucketOpts; if (searchFilter) searchFilter.innerHTML = `` + bucketOpts; if (tasksFilter) { const cur = tasksFilter.value; tasksFilter.innerHTML = `` + bucketOpts; const valid = cur === '' || cur === 'none' || state.buckets.some(b => String(b.id) === cur); tasksFilter.value = valid ? cur : ''; } } function autoBucketNameFromCard(card) { const existing = new Set(state.buckets.map(b => String(b.name || '').trim().toLowerCase()).filter(Boolean)); const base = String(card?.title || '').trim().replace(/\s+/g, ' ').slice(0, 40) || 'bucket'; if (!existing.has(base.toLowerCase())) return base; let i = 2; while (existing.has(`${base} ${i}`.toLowerCase())) i++; return `${base} ${i}`; } function updateHeaderTint() { const header = $('main-header'); if (!header) return; const bucket = state.buckets.find(b => b.id == state.activeBucket); if (bucket && bucket.color) { header.style.borderBottomWidth = '4px'; header.style.borderBottomColor = bucket.color; } else { header.style.borderBottomWidth = ''; header.style.borderBottomColor = ''; } } // Remembers the last active member per super-bucket so the tile doesn't // fall back to the parent label when the user navigates away and comes // back. Keyed by parent bucket id → last selected member id (could be // the parent itself or any child). const lastSuperSelection = new Map(); function renderBar() { if (!bar) return; const tab = (active) => `bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors shrink-0`; // Top-level buckets only — children are rendered inside their parent's // dropdown popover, not directly in the bar. const topLevel = state.buckets.filter(b => !b.parent_id); const childrenOf = (parentId) => state.buckets .filter(b => b.parent_id == parentId) .sort((a, b) => (a.position || 0) - (b.position || 0)); // Track which super-bucket member is active NOW so we can recall it // later when the user comes back from a different bucket. for (const b of topLevel) { const kids = childrenOf(b.id); if (!kids.length) continue; if (state.activeBucket == b.id) lastSuperSelection.set(b.id, b.id); const activeKid = kids.find(c => c.id == state.activeBucket); if (activeKid) lastSuperSelection.set(b.id, activeKid.id); } // Resolve which child / parent to *show* on a super-bucket tile. // Priority: currently active member → last selected member → parent. const tileFor = (parent) => { const kids = childrenOf(parent.id); if (!kids.length) return null; // not a super-bucket const activeChild = kids.find(c => c.id == state.activeBucket); if (activeChild) return activeChild; if (state.activeBucket == parent.id) return parent; // Fall back to the last selection rather than the parent const lastId = lastSuperSelection.get(parent.id); if (lastId) { if (lastId == parent.id) return parent; const lastKid = kids.find(c => c.id == lastId); if (lastKid) return lastKid; } return parent; }; // Show an unread dot on the fixed `all` tab when state.unseenCardIds // has anything in it AND the user isn't currently looking at the // all view. board.js drives the unseen set; we just read it here so // the dot survives bar re-renders that aren't followed by a board // render (e.g. bucket reorder, plain renderBar() call). const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0; bar.innerHTML = ` ${topLevel.map(b => { const kids = childrenOf(b.id); const isSuper = kids.length > 0; if (isSuper) { // Super-bucket tile: shows the active member's name + a stack icon. // The shown member's id goes on `data-super-shown` so we can // highlight + recolor without taking over the dropdown click. const shown = tileFor(b); const c = shown.color || '#64748b'; const groupActive = state.activeBucket == b.id || kids.some(k => k.id == state.activeBucket); return ``; } const c = b.color || '#64748b'; const active = state.activeBucket == b.id; return ``; }).join('')} `; updateHeaderTint(); let draggingBucketId = null; // Single insertion-cursor element shared by all bucket tabs. Lives // inside #bucket-bar (which is `position: relative` so absolute // children align to its scroll-aware coord space) and gets repositioned // by the dragover handlers below. The cursor sits *between* tiles, // not as a border on the target tile, so it stays unambiguous when // we add the super-bucket nest gesture later. let dropCursor = bar.querySelector('#bucket-drop-cursor'); if (!dropCursor) { dropCursor = document.createElement('div'); dropCursor.id = 'bucket-drop-cursor'; bar.appendChild(dropCursor); } function showDropCursor(targetTab, before) { // Position the cursor at the gap on the chosen side of the target. // offsetLeft / offsetWidth are relative to the offsetParent // (#bucket-bar) so they survive horizontal scroll. const x = before ? targetTab.offsetLeft - 4 : targetTab.offsetLeft + targetTab.offsetWidth + 1; dropCursor.style.left = x + 'px'; dropCursor.style.display = 'block'; } function hideDropCursor() { dropCursor.style.display = 'none'; } /** Can `draggedId` legally become a child of `targetId`? * Rules: 1-level deep only, no self-parenting, target must be a real * user bucket and currently top-level, dragged must not have children. */ function canNestInto(draggedId, targetId) { if (!Number.isFinite(draggedId) || !Number.isFinite(targetId)) return false; if (draggedId === targetId) return false; const target = state.buckets.find(b => b.id === targetId); if (!target || target.parent_id) return false; const dragged = state.buckets.find(b => b.id === draggedId); if (!dragged) return false; // Already a child of the same target → no-op nest if (dragged.parent_id === targetId) return false; // Dragged has its own children → would create level-2 if (state.buckets.some(b => b.parent_id === draggedId)) return false; return true; } /** Open a popover under a super-bucket tile, listing parent + every * child. Each entry is a real `.bucket-tab[data-bucket=...]` element * so the collab plugin's MutationObserver still picks them up and * decorates them with the unread dot / users-three icon. */ function openSuperDropdown(anchorTab, parentId) { // Toggle: if a popover is already open for this parent, close it. const existing = document.getElementById('bucket-super-popover'); if (existing) { const wasFor = existing.dataset.parentId; existing.remove(); if (wasFor === String(parentId)) return; } const parent = state.buckets.find(b => b.id === parentId); if (!parent) return; const kids = state.buckets .filter(b => b.parent_id === parentId) .sort((a, b) => (a.position || 0) - (b.position || 0)); const members = [parent, ...kids]; const pop = document.createElement('div'); pop.id = 'bucket-super-popover'; pop.dataset.parentId = String(parentId); // Anchor under the tile, clamped to the viewport. const rect = anchorTab.getBoundingClientRect(); pop.style.position = 'fixed'; pop.style.top = (rect.bottom + 4) + 'px'; pop.style.left = rect.left + 'px'; // Highlight the currently active member OR the last remembered // selection (so the user sees which child was picked last time // even when they're currently on a different bucket). const lastId = lastSuperSelection.get(parentId); const renderEntry = (m) => { const c = m.color || '#64748b'; const active = state.activeBucket == m.id || (!members.some(x => x.id == state.activeBucket) && lastId == m.id); const isParent = m.id === parentId; return ``; }; pop.innerHTML = members.map(renderEntry).join(''); // Append inside #bucket-bar (not document.body) so the collab // plugin's MutationObserver on #bucket-bar still picks up these // .bucket-tab entries and decorates collab children with the // unread dot + users-three icon. Position is `fixed` anyway so // the bar's overflow:auto can't clip the popover. bar.appendChild(pop); // Wire each entry: click selects, contextmenu opens bucket ctx, // drag starts a re-parent / re-order operation against the bar. pop.querySelectorAll('.bucket-tab[data-bucket]').forEach(entry => { const ebid = entry.dataset.bucket; entry.addEventListener('click', () => { setActiveBucket(ebid); state.activeGroup = null; pop.remove(); renderBar(); window._renderBoard(); }); entry.addEventListener('contextmenu', e => { e.preventDefault(); e.stopPropagation(); const bk = state.buckets.find(b => b.id == ebid); if (bk) showBucketCtx(e.clientX, e.clientY, bk); }); entry.addEventListener('dragstart', e => { draggingBucketId = ebid; e.dataTransfer.setData('application/x-bucket', ebid); e.dataTransfer.effectAllowed = 'move'; entry.style.opacity = '0.3'; }); entry.addEventListener('dragend', () => { entry.style.opacity = ''; draggingBucketId = null; hideDropCursor(); bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target')); }); }); // Click outside the popover closes it. setTimeout(() => { const onDocClick = (e) => { if (!pop.contains(e.target) && !anchorTab.contains(e.target)) { pop.remove(); document.removeEventListener('click', onDocClick); } }; document.addEventListener('click', onDocClick); }, 0); } bar.querySelectorAll('.bucket-tab').forEach(t => { // Super-bucket tile uses `data-bucket-super`; regular tiles use // `data-bucket`. The id we resolve here is the parent's id for a // super-tile (so drag/contextmenu/etc. operate on the parent). const isSuperTile = t.classList.contains('bucket-super'); const bid = isSuperTile ? t.dataset.bucketSuper : t.dataset.bucket; const SPECIAL = ['all', 'current', 'favorites', 'journal', 'none']; const isUser = isSuperTile || !SPECIAL.includes(bid); if (isSuperTile) { // Caret arrow opens the dropdown; clicking the rest of the // tab activates the last-shown child (or parent). const caret = t.querySelector('[data-super-caret]'); if (caret) { caret.addEventListener('click', (e) => { e.stopPropagation(); openSuperDropdown(t, parseInt(bid, 10)); }); } t.addEventListener('click', () => { const shownId = t.dataset.superShown; setActiveBucket(shownId || bid); state.activeGroup = null; renderBar(); window._renderBoard(); }); } else { t.addEventListener('click', () => { setActiveBucket(bid); state.activeGroup = null; renderBar(); window._renderBoard(); }); } if (isUser) { t.addEventListener('contextmenu', e => { e.preventDefault(); e.stopPropagation(); const bucket = state.buckets.find(b => b.id == bid); if (bucket) showBucketCtx(e.clientX, e.clientY, bucket); }); // Bucket drag t.addEventListener('dragstart', e => { draggingBucketId = bid; e.dataTransfer.setData('application/x-bucket', bid); e.dataTransfer.effectAllowed = 'move'; t.style.opacity = '0.3'; }); t.addEventListener('dragend', () => { t.style.opacity = ''; draggingBucketId = null; hideDropCursor(); bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target')); }); } t.addEventListener('dragover', e => { e.preventDefault(); if (draggingBucketId && isUser && draggingBucketId !== bid) { // Three drop zones along the tile's width: // left 25% → insert before // right 25% → insert after // center 50% → nest into this bucket const rect = t.getBoundingClientRect(); const x = e.clientX - rect.left; const isLeft = x < rect.width * 0.25; const isRight = x > rect.width * 0.75; const isCenter = !isLeft && !isRight; if (isCenter && canNestInto(parseInt(draggingBucketId, 10), parseInt(bid, 10))) { hideDropCursor(); bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target')); t.classList.add('bucket-nest-target'); } else { bar.querySelectorAll('.bucket-nest-target').forEach(x => x.classList.remove('bucket-nest-target')); showDropCursor(t, isLeft); } t.classList.remove('drag-over'); } else if (!draggingBucketId) { // Card drop on bucket t.classList.add('drag-over'); } }); t.addEventListener('dragleave', () => { t.classList.remove('drag-over'); }); t.addEventListener('drop', async e => { e.preventDefault(); t.classList.remove('drag-over', 'bucket-nest-target'); hideDropCursor(); // Bucket reorder / nest const dragBid = e.dataTransfer.getData('application/x-bucket'); if (dragBid && isUser && dragBid !== bid) { const rect = t.getBoundingClientRect(); const x = e.clientX - rect.left; const isLeft = x < rect.width * 0.25; const isRight = x > rect.width * 0.75; const isCenter = !isLeft && !isRight; if (isCenter && canNestInto(parseInt(dragBid, 10), parseInt(bid, 10))) { // Nest: dragged becomes a child of target. Backend // refuses cycles / level-2 with a 400 — bubble that // up via alert so the user knows. const res = await api(`api/buckets/${dragBid}`, { method: 'PATCH', body: { parent_id: parseInt(bid, 10) } }); if (res?.error) { alert(res.error); return; } await loadBuckets(); renderBar(); window._renderBoard(); return; } // Reorder among top-level buckets only. const before = isLeft || (!isRight && x < rect.width / 2); const topIds = state.buckets.filter(b => !b.parent_id).map(b => String(b.id)).filter(i => i !== dragBid); const targetIdx = topIds.indexOf(String(bid)); if (targetIdx >= 0) { topIds.splice(before ? targetIdx : targetIdx + 1, 0, dragBid); // The dragged bucket also gets un-nested if it was a child. await api(`api/buckets/${dragBid}`, { method: 'PATCH', body: { parent_id: null } }); await Promise.all(topIds.map((id, i) => api(`api/buckets/${id}`, { method: 'PATCH', body: { position: i } }))); await loadBuckets(); window._renderBoard(); } return; } // Card drop on bucket — move dragged card + all selected const cardId = e.dataTransfer.getData('text/plain'); if (!cardId) return; const selected = window._getSelectedIds?.() || []; const ids = selected.includes(String(cardId)) ? selected : [String(cardId)]; if (bid === 'favorites') { const { toggleFavorite, isFavorite } = await import('./state.js'); for (const id of ids) { if (!isFavorite(id)) toggleFavorite(id); } window._clearSelection?.(); window._renderBoard(); return; } // Card drop on a super-bucket: card goes into the *currently // shown* member of that group, not the parent itself. const targetBid = isSuperTile ? (t.dataset.superShown || bid) : bid; const bucketId = targetBid === 'none' ? null : (['all','current','journal'].includes(targetBid) ? undefined : targetBid); if (bucketId === undefined) return; await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } }))); window._clearSelection?.(); await window._reloadAll(); }); }); const addBtn = $('add-bucket-btn'); addBtn?.addEventListener('click', async () => { // Plugins (e.g. collab-buckets) can override the default create // dialog by exposing window._collabBucketsCreateDialog. It returns // true when a bucket was created, in which case we reload. if (typeof window._collabBucketsCreateDialog === 'function') { const created = await window._collabBucketsCreateDialog(); if (created) { await loadBuckets(); window._renderBoard(); } return; } const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' }); if (!name || !name.trim()) return; await api('api/buckets', { method: 'POST', body: { name: name.trim() } }); await loadBuckets(); window._renderBoard(); }); const isCardDrag = e => Array.from(e.dataTransfer?.types || []).includes('text/plain'); addBtn?.addEventListener('dragover', e => { if (!isCardDrag(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; addBtn.classList.add('drag-over'); }); addBtn?.addEventListener('dragleave', () => addBtn.classList.remove('drag-over')); addBtn?.addEventListener('drop', async e => { addBtn.classList.remove('drag-over'); const cardId = e.dataTransfer.getData('text/plain'); if (!cardId) return; e.preventDefault(); const selected = window._getSelectedIds?.() || []; const ids = selected.includes(String(cardId)) ? selected : [String(cardId)]; const card = state.cards.find(c => String(c.id) === String(cardId)); const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } }); if (!created?.id) return; await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } }))); window._clearSelection?.(); await loadBuckets(); await window._reloadAll(); }); renderTagBar(); } export function renderTagBar() { let tagBar = $('tag-bar'); if (!tagBar) { tagBar = document.createElement('div'); tagBar.id = 'tag-bar'; tagBar.className = 'flex items-center gap-1.5 px-6 py-1'; const bucketRow = $('bucket-row'); bucketRow?.parentNode.insertBefore(tagBar, bucketRow.nextSibling); } if (!state.activeTags.length) { tagBar.classList.add('hidden'); return; } tagBar.classList.remove('hidden'); tagBar.innerHTML = `` + state.activeTags.map(t => ` #${esc(t)} `).join('') + ``; tagBar.querySelectorAll('[data-remove-tag]').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); state.activeTags = state.activeTags.filter(t => t !== btn.dataset.removeTag); renderTagBar(); window._renderBoard(); }); }); $('clear-tag-filter')?.addEventListener('click', () => { state.activeTags = []; renderTagBar(); window._renderBoard(); }); }