Files
hyprcrd/public/js/modules/buckets.js
T
thandClaude Opus 4.6 c16659c2ad new-card unread dot + pulse fix, relay inbox readable, UI polish pass
Board: new-card unread indicator (public/js/modules/board.js,
public/js/modules/buckets.js, public/js/modules/state.js,
public/css/style.css):

* state.unseenCardIds (Set) tracks card ids that arrived since the
  last visit to the "all" view. Diff runs in renderBoard() against a
  module-private knownCardIds snapshot.
* Bug fix: the first renderBoard runs with state.cards = [] (before
  the API returns). The previous diff treated every card from the
  real load as "new" and pulsed the entire board. Now the diff is
  skipped when knownCardIds is null or empty — the first non-empty
  render is a silent prime.
* When activeBucket === 'all' and unseenCardIds is non-empty, the set
  is drained and every matching board card gets a 3-cycle accent glow
  (`.card-pulse-new`, 0.8s × 3, pinned for anim-fast/slow). Works
  both when navigating TO all and when a poll arrives while already
  on all.
* When the user is on a different bucket: unseenCardIds accumulates
  across polls and a red dot (`.bar-unread-dot`) appears on the "all"
  tab. Position is top:3px right:3px *inside* the tab to survive
  the double overflow clip (`#main-header overflow:hidden` +
  `#bucket-bar overflow-x:auto`). renderBar() bakes the dot into the
  all-tab template on every render so it survives bar re-renders that
  aren't followed by a board render.
* decorateAllUnreadDot() in board.js is a fallback called at the end
  of every renderBoard for cases where the diff changed unseenCardIds
  but renderBar hasn't run yet.

Relay: readable inbox (relay/inc/routes/messages.php,
relay/ui/app.js, relay/ui/layout.php, relay/ui/style.css):

* New session-based GET /api/messages/inbox — returns up to 200
  messages for the logged-in user, decrypted server-side with the
  inter-tenant key (no extra leakage — the relay already sees
  plaintext during the routing hop). Does NOT mark messages as
  fetched; the hyprcrd client is still the canonical receiver.
* loadInbox() now fetches from /api/messages/inbox and renders
  actual inbox items: sender (with user-circle icon + accent2),
  optional title (bold), body (pre-wrap, max-height 120px), relative
  timestamp (formatRelTime: "just now", "2 min ago", "today 14:32",
  "3 d ago", or YYYY-MM-DD for older). Unfetched messages show a
  small "new" accent badge next to the sender name.
* deleteMessage(id, btn) — confirms, calls existing DELETE
  /api/messages/{id} (already session-aware), removes the DOM row,
  re-loads the list, toasts "message deleted".
* Auto-poll: startInboxPoll / stopInboxPoll refresh the inbox every
  15s while the compose view is active. Stops on view switch.
* Header-status badge: mirrors the real message count (not just the
  /api/status count) and toggles the has-unread class + dot so the
  tap-to-scroll-to-inbox target stays fresh.

Relay UI polish (relay/ui/style.css):
* .header-status is now a clickable button with tray icon, hover
  tint, focus-ring, and a red dot (::after pseudo) when has-unread.
  jumpToInbox() scrolls smoothly to #inbox-section and flashes it
  with a 1.6s accent box-shadow pulse.
* .inbox-item .from is flex-row with icon + badge-new pill.
* .inbox-item .del gets hover-background for better mobile tappability.
* @keyframes flash-highlight for the scroll-to-inbox animation.

chore: bump version to 0.9.9

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:09:29 +02:00

644 lines
34 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 &amp; 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');
/** 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 = '';
}
}
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));
// Resolve which child / parent to *show* on a super-bucket tile. The
// active bucket wins; otherwise fall back to the parent itself.
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;
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" title="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" 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>
${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><i class="ph ph-caret-down text-[10px] ml-0.5 opacity-70"></i></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';
const renderEntry = (m) => {
const c = m.color || '#64748b';
const active = state.activeBucket == 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" title="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) {
// Click opens the dropdown popover instead of activating the
// parent directly. The dropdown lists parent + every child.
t.addEventListener('click', (e) => {
e.stopPropagation();
openSuperDropdown(t, parseInt(bid, 10));
});
} 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;
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();
});
}