// ── 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; ctxMenu.innerHTML = `
`; 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 ok = await showDialog({ title: 'delete_bucket', message: `"${bucket.name}" loeschen? Karten bleiben erhalten.`, 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: `"${bucket.name}" und ${cards.length} Karte(n) loeschen?`, 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 tint = $('header-tint'); if (!tint) return; const bucket = state.buckets.find(b => b.id == state.activeBucket); if (bucket && bucket.color) { tint.style.background = `linear-gradient(135deg, ${bucket.color}30 0%, ${bucket.color}12 100%)`; tint.style.opacity = '1'; } else { tint.style.opacity = '0'; } } 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 const cardId = e.dataTransfer.getData('text/plain'); if (!cardId) return; const bucketId = bid === 'none' ? null : ((bid === 'all' || bid === 'current') ? undefined : bid); if (bucketId === undefined) return; await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } }); 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 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 api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } }); await loadBuckets(); await window._reloadAll(); }); }