// ── Bucket management ── import { $, esc, api } from './util.js'; import { state, CARD_COLORS } from './state.js'; import { showDialog } from './dialog.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); ctxMenu.innerHTML = `
${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 === 'rename') { hideBucketCtx(); const name = await showDialog({ title: 'rename_bucket', input: true, placeholder: bucket.name }); if (!name || !name.trim()) return; await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } }); await loadBuckets(); window._renderBoard(); } 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 ok = await showDialog({ title: 'delete_bucket', message: cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`, danger: true, okLabel: 'delete' }); if (!ok) return; } if (state.activeBucket == bucket.id) state.activeBucket = 'all'; await api(`api/buckets/${bucket.id}`, { method: 'DELETE' }); await loadBuckets(); 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'; await api(`api/buckets/${bucket.id}`, { method: 'DELETE' }); await loadBuckets(); await window._reloadAll(); } })); // Bucket color clicks ctxMenu.querySelectorAll('[data-bctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideBucketCtx(); await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: btn.dataset.bctxColor } }); await loadBuckets(); window._renderBoard(); })); } 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 () => { 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(); }); }