The horizontal scrollbar on the bucket row was ugly and inconsistent with the rest of the app's scrollbar-less panels. Hide it, redirect vertical wheel over the bar to horizontal scroll, and surface overflow with soft bg-coloured gradients on whichever side has more content off-screen: right only while anchored at the start, both in the middle, left only at the end. Fade overlays live on #bucket-row (parent) as pseudo-elements with pointer-events:none, rather than mask-image on the bar itself — a mask would also clip super-bucket popovers that are intentionally rendered inside #bucket-bar for the collab MutationObserver. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
723 lines
38 KiB
JavaScript
723 lines
38 KiB
JavaScript
// ── 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 = `<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>
|
|
${isCollab && typeof window._collabBucketsEditDialog === 'function'
|
|
? `<button class="ctx-item" data-baction="collab-manage"><i class="ph ph-users-three text-sm"></i><span class="font-pixel text-[10px]">Members & owner…</span></button>`
|
|
: ''
|
|
}
|
|
${!isCollab && typeof window._collabBucketsUpgradeDialog === 'function'
|
|
? `<button class="ctx-item" data-baction="collab-upgrade"><i class="ph ph-users-three text-sm"></i><span class="font-pixel text-[10px]">Make collaborative…</span></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 === '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');
|
|
|
|
// ── Overflow handling for the bucket bar ─────────────────────────
|
|
// Scrollbar is hidden (CSS). Mouse-wheel over the bar translates to
|
|
// horizontal scroll so long bucket rows stay usable without a bar.
|
|
// Edge fades kick in dynamically based on scroll position so the
|
|
// user sees there's more off-screen — both sides at once in the
|
|
// middle, one side at either extreme. Fade overlays live on the
|
|
// parent #bucket-row (see CSS) so they don't clip super-bucket
|
|
// popovers that render inside #bucket-bar itself.
|
|
const barRow = $('bucket-row');
|
|
function updateBucketBarFades() {
|
|
if (!bar || !barRow) return;
|
|
const canLeft = bar.scrollLeft > 2;
|
|
const canRight = bar.scrollLeft + bar.clientWidth < bar.scrollWidth - 2;
|
|
barRow.classList.toggle('fade-left', canLeft && !canRight);
|
|
barRow.classList.toggle('fade-right', !canLeft && canRight);
|
|
barRow.classList.toggle('fade-both', canLeft && canRight);
|
|
// Right-side fade overlay is anchored to the parent's right edge;
|
|
// the actual bar ends before the toolbar toggles. Compute the
|
|
// distance so the gradient lines up with the bar's right edge.
|
|
const rowRect = barRow.getBoundingClientRect();
|
|
const barRect = bar.getBoundingClientRect();
|
|
const rightInset = Math.max(0, rowRect.right - barRect.right);
|
|
barRow.style.setProperty('--bucket-row-right-inset', rightInset + 'px');
|
|
}
|
|
if (bar) {
|
|
bar.addEventListener('wheel', (e) => {
|
|
if (e.deltaY === 0) return;
|
|
if (bar.scrollWidth <= bar.clientWidth) return;
|
|
e.preventDefault();
|
|
bar.scrollLeft += e.deltaY;
|
|
}, { passive: false });
|
|
bar.addEventListener('scroll', updateBucketBarFades, { passive: true });
|
|
window.addEventListener('resize', updateBucketBarFades);
|
|
new MutationObserver(updateBucketBarFades).observe(bar, { childList: true, subtree: false });
|
|
updateBucketBarFades();
|
|
}
|
|
|
|
/** 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 => `<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 = '';
|
|
}
|
|
}
|
|
|
|
// 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 = `
|
|
<button class="${tab(state.activeBucket === 'all')}${allUnread ? ' has-unread' : ''}" data-bucket="all" data-tip="all"><i class="ph ph-squares-four text-sm"></i>${allUnread ? '<span class="bar-unread-dot"></span>' : ''}</button>
|
|
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" data-tip="current"><i class="ph ph-lightning text-sm"></i></button>
|
|
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" data-tip="favorites"><i class="ph ph-star text-sm"></i></button>
|
|
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" data-tip="journal"><i class="ph ph-notebook text-sm"></i></button>
|
|
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none" data-tip="unsorted"><i class="ph ph-tray text-sm"></i></button>
|
|
${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 `<button class="${tab(groupActive)} bucket-super" data-bucket-super="${b.id}" data-super-shown="${shown.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${groupActive ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">${esc(shown.name)}</span><span class="super-caret" data-super-caret="${b.id}"><i class="ph ph-caret-down text-[10px]"></i></span></button>`;
|
|
}
|
|
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;
|
|
|
|
// 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 `<button class="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 w-full" data-bucket="${m.id}" draggable="true" data-bucket-drag="${m.id}" style="border-left: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] flex-1 text-left">${esc(m.name)}</span>${isParent ? '<i class="ph ph-house text-[10px] opacity-60" data-tip="parent"></i>' : ''}</button>`;
|
|
};
|
|
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;
|
|
const created = await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
|
|
await loadBuckets();
|
|
if (created?.id) {
|
|
setActiveBucket(created.id);
|
|
state.activeGroup = null;
|
|
}
|
|
renderBar();
|
|
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();
|
|
});
|
|
}
|