// ── Bucket management ── import { $, esc, api } from './util.js'; import { state, CARD_COLORS } 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; const idx = state.buckets.findIndex(b => b.id == bucket.id); const canLeft = idx > 0; const canRight = idx < state.buckets.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 swapIdx = a === 'move-left' ? idx - 1 : idx + 1; const other = state.buckets[swapIdx]; if (!other) return; await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { position: swapIdx } }); await api(`api/buckets/${other.id}`, { method: 'PATCH', body: { position: idx } }); 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) state.activeBucket = '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) state.activeBucket = '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'); export async function loadBuckets() { state.buckets = await api('api/buckets'); 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 = ''; } } 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`; bar.innerHTML = ` ${state.buckets.map(b => { const c = b.color || '#64748b'; const active = state.activeBucket == b.id; return ``; }).join('')} `; updateHeaderTint(); let draggingBucketId = null; bar.querySelectorAll('.bucket-tab').forEach(t => { const bid = t.dataset.bucket; const isUser = bid !== 'all' && bid !== 'none'; t.addEventListener('click', () => { state.activeBucket = 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; bar.querySelectorAll('.bucket-drop-left,.bucket-drop-right').forEach(x => x.classList.remove('bucket-drop-left', 'bucket-drop-right')); }); } t.addEventListener('dragover', e => { e.preventDefault(); if (draggingBucketId && isUser && draggingBucketId !== bid) { // Bucket reorder indicator const rect = t.getBoundingClientRect(); const left = e.clientX < rect.left + rect.width / 2; t.classList.toggle('bucket-drop-left', left); t.classList.toggle('bucket-drop-right', !left); 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', 'bucket-drop-left', 'bucket-drop-right'); }); t.addEventListener('drop', async e => { e.preventDefault(); t.classList.remove('drag-over', 'bucket-drop-left', 'bucket-drop-right'); // Bucket reorder const dragBid = e.dataTransfer.getData('application/x-bucket'); if (dragBid && isUser && dragBid !== bid) { const rect = t.getBoundingClientRect(); const before = e.clientX < rect.left + rect.width / 2; const ids = state.buckets.map(b => String(b.id)).filter(i => i !== dragBid); const targetIdx = ids.indexOf(String(bid)); ids.splice(before ? targetIdx : targetIdx + 1, 0, dragBid); await Promise.all(ids.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; } const bucketId = bid === 'none' ? null : (['all','current','journal'].includes(bid) ? undefined : bid); 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(); }); }