Files
hyprcrd/public/js/modules/buckets.js
T
2026-04-09 19:14:52 +02:00

331 lines
16 KiB
JavaScript

// ── 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 = `<div class="py-1">
<button class="ctx-item" data-baction="rename"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">rename</span></button>
<button class="ctx-item" data-baction="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
<button class="ctx-item ${!canLeft ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-left"><i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">move left</span></button>
<button class="ctx-item ${!canRight ? 'opacity-30 pointer-events-none' : ''}" data-baction="move-right"><i class="ph ph-arrow-right text-sm"></i><span class="font-pixel text-[10px]">move right</span></button>
<div style="height:1px;background:var(--t-border);margin:2px 0"></div>
${hasCards
? `<button class="ctx-item" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete (keep cards)</span></button>
<button class="ctx-item danger" data-baction="delete-all"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete + cards</span></button>`
: `<button class="ctx-item danger" data-baction="delete-keep"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span></button>`
}
</div>
<div id="bctx-colors" class="hidden px-3 py-2 border-t border-t-border">
<div class="flex flex-wrap gap-1.5">
${CARD_COLORS.map(c => `<button class="w-5 h-5 rounded-sm border-2 transition-all hover:scale-125 ${c.hex === (bucket.color || '#64748b') ? 'border-t-bright' : 'border-transparent'}" style="background:${c.hex}" data-bctx-color="${c.hex}"></button>`).join('')}
</div>
</div>`;
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 ? `"${bucket.name}" loeschen? ${cards.length} Karte(n) bleiben erhalten.` : `"${bucket.name}" loeschen?`, 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 => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
if (inpBucket) inpBucket.innerHTML = `<option value="">no bucket</option>` + bucketOpts;
if (searchFilter) searchFilter.innerHTML = `<option value="">all buckets</option><option value="none">unsorted</option>` + bucketOpts;
if (tasksFilter) {
const cur = tasksFilter.value;
tasksFilter.innerHTML = `<option value="">all buckets</option><option value="none">unsorted</option>` + 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 = `
<button class="${tab(state.activeBucket === 'all')}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i></button>
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" title="current"><i class="ph ph-lightning text-sm"></i></button>
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" title="favorites"><i class="ph ph-star text-sm"></i></button>
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" title="journal"><i class="ph ph-notebook text-sm"></i></button>
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none" title="unsorted"><i class="ph ph-tray text-sm"></i></button>
${state.buckets.map(b => {
const c = b.color || '#64748b';
const active = state.activeBucket == b.id;
return `<button class="${tab(active)}" data-bucket="${b.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`;
}).join('')}
<button id="add-bucket-btn" class="bucket-add flex items-center gap-1 px-2 py-1 text-xs text-t-dim hover:text-t-accent transition-colors shrink-0"><i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">bucket</span></button>`;
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 = `<i class="ph ph-funnel text-xs text-t-accent"></i>` +
state.activeTags.map(t => `
<span class="tag-chip" style="font-size:10px;">
<span class="font-pixel">#${esc(t)}</span>
<i class="ph ph-x text-[9px] ml-0.5 cursor-pointer hover:text-t-danger" data-remove-tag="${t}"></i>
</span>`).join('') +
`<button id="clear-tag-filter" class="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-pixel text-t-dim hover:text-t-danger transition-colors">
<i class="ph ph-x text-xs"></i>clear all
</button>`;
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();
});
}