diff --git a/config.php b/config.php
index a5da473..4b587d2 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
execute(['id' => $m[1]]);
$existing = $b->fetch();
$isCollab = $existing && !empty($existing['collab_uid']);
- $allowed = $isCollab ? ['position'] : ['name', 'position', 'color'];
+ $allowed = $isCollab ? ['position'] : ['name', 'position', 'color', 'parent_id'];
+ // Super-bucket depth + cycle enforcement: a bucket may only become a
+ // child if (a) it has no children itself, (b) the prospective parent
+ // is top-level (parent_id IS NULL), and (c) self-parent is forbidden.
+ if (!$isCollab && array_key_exists('parent_id', $input)) {
+ $newParent = $input['parent_id'];
+ if ($newParent !== null && (int)$newParent !== 0) {
+ $newParent = (int)$newParent;
+ if ($newParent === (int)$m[1]) {
+ http_response_code(400); echo json_encode(['error' => 'cannot parent to self']); exit;
+ }
+ $h = $db->prepare('SELECT COUNT(*) FROM buckets WHERE parent_id = :id');
+ $h->execute(['id' => $m[1]]);
+ if ((int)$h->fetchColumn() > 0) {
+ http_response_code(400); echo json_encode(['error' => 'this bucket has children — flatten it first']); exit;
+ }
+ $p = $db->prepare('SELECT parent_id FROM buckets WHERE id = :id');
+ $p->execute(['id' => $newParent]);
+ $parentRow = $p->fetch();
+ if (!$parentRow) { http_response_code(400); echo json_encode(['error' => 'parent not found']); exit; }
+ if ($parentRow['parent_id'] !== null) {
+ http_response_code(400); echo json_encode(['error' => 'cannot nest under a child bucket']); exit;
+ }
+ } else {
+ $input['parent_id'] = null;
+ }
+ }
$fields = []; $params = ['id' => $m[1]];
foreach ($allowed as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } }
if ($fields) $db->prepare('UPDATE buckets SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
@@ -374,6 +400,9 @@ if (str_starts_with($route, '/api/')) {
exit;
}
$db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $m[1]]);
+ // If this was a super-bucket parent, un-nest its children back to top-level
+ // instead of cascade-deleting them.
+ $db->prepare('UPDATE buckets SET parent_id = NULL WHERE parent_id = :id')->execute(['id' => $m[1]]);
$db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $m[1]]);
echo json_encode(['ok' => true]); exit;
}
@@ -654,6 +683,14 @@ if (str_starts_with($route, '/api/')) {
]);
$bucketMap[$oldId] = $db->lastInsertId();
}
+ // Second pass: resolve parent_id (old id → new id) now that all
+ // buckets have been inserted and the map is complete.
+ foreach ($data['buckets'] as $b) {
+ if (!empty($b['parent_id']) && isset($bucketMap[$b['id']]) && isset($bucketMap[$b['parent_id']])) {
+ $db->prepare('UPDATE buckets SET parent_id = :p WHERE id = :id')
+ ->execute(['p' => $bucketMap[$b['parent_id']], 'id' => $bucketMap[$b['id']]]);
+ }
+ }
// Import cards (preserve all fields including CalDAV sync state)
$cardMap = []; // old id → new id
@@ -1218,7 +1255,7 @@ if (str_starts_with($route, '/api/')) {
-
+
diff --git a/public/js/modules/board.js b/public/js/modules/board.js
index 9c0b7b3..8f69138 100644
--- a/public/js/modules/board.js
+++ b/public/js/modules/board.js
@@ -23,6 +23,12 @@ const boardPreviewTags = $('board-preview-tags');
const boardPreviewMeta = $('board-preview-meta');
let activeDragEl = null;
let dragGhost = null;
+// Set of card ids currently being dragged. Module-scoped so a poll-driven
+// re-render (caldav/collab) can re-apply the `.card-dragging` ghost class
+// to the matching DOM nodes after `board.innerHTML = …` wipes them. Without
+// this the dragged card would visually pop back to its normal state every
+// time a background poll completed mid-drag.
+const draggingCardIds = new Set();
// ── Multi-select ──
const selectedIds = state.selectedIds;
@@ -614,6 +620,16 @@ export function renderBoard() {
bindCardEvents();
+ // Re-apply the dragging ghost to any card whose drag is still in
+ // flight. Polls (caldav, collab) call _reloadAll → renderBoard mid-
+ // drag, which would otherwise wipe the visual ghost state without
+ // the underlying drag operation noticing.
+ if (draggingCardIds.size) {
+ draggingCardIds.forEach(did => {
+ board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging');
+ });
+ }
+
// Restore selection state
selectedIds.forEach(id => {
const el = board.querySelector(`[data-id="${id}"]`);
@@ -843,6 +859,36 @@ function hideFan() {
// ── Card event binding ──
function bindCardEvents() {
+ // Single insertion-cursor element that lives inside #board (which is
+ // `relative`) and gets repositioned by the card-level dragover handlers
+ // below. Replaces the previous `.card-drop-before::before` /
+ // `.card-drop-after::after` pseudo-elements so the cursor is a real
+ // element in the gap rather than a sliver glued to the side of a tile.
+ let cardDropCursor = board.querySelector('#card-drop-cursor');
+ if (!cardDropCursor) {
+ cardDropCursor = document.createElement('div');
+ cardDropCursor.id = 'card-drop-cursor';
+ board.appendChild(cardDropCursor);
+ }
+ function showCardDropCursor(targetEl, before) {
+ // offsetLeft / offsetTop / offsetHeight are relative to the
+ // offsetParent (#board) so the cursor positions correctly even
+ // when #board is scrolled vertically.
+ const x = before
+ ? targetEl.offsetLeft - 6
+ : targetEl.offsetLeft + targetEl.offsetWidth + 2;
+ cardDropCursor.style.left = x + 'px';
+ cardDropCursor.style.top = (targetEl.offsetTop + 8) + 'px';
+ cardDropCursor.style.height = (targetEl.offsetHeight - 16) + 'px';
+ cardDropCursor.style.display = 'block';
+ }
+ function hideCardDropCursor() {
+ cardDropCursor.style.display = 'none';
+ }
+ // Expose so the global dragend handler (defined outside this function)
+ // can hide the cursor too.
+ board._hideCardDropCursor = hideCardDropCursor;
+
board.querySelectorAll('[data-group]').forEach(badge => {
badge.addEventListener('click', e => { e.stopPropagation(); state.activeGroup = badge.dataset.group; renderBoard(); });
});
@@ -932,8 +978,11 @@ function bindCardEvents() {
const isMulti = selectedIds.has(String(id)) && selectedIds.size > 1;
const dragIds = isMulti ? [...selectedIds] : [String(id)];
- // Ghost all dragged cards
+ // Ghost all dragged cards + remember the ids so a poll-driven
+ // re-render can re-apply the ghost state.
+ draggingCardIds.clear();
dragIds.forEach(did => {
+ draggingCardIds.add(String(did));
board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging');
});
@@ -979,20 +1028,36 @@ function bindCardEvents() {
el.addEventListener('dragend', () => {
// Remove ghost class from all dragged cards
board.querySelectorAll('.card-dragging').forEach(c => c.classList.remove('card-dragging'));
+ draggingCardIds.clear();
activeDragEl = null;
if (dragGhost) { dragGhost.remove(); dragGhost = null; }
- board.querySelectorAll('.card-drop-before,.card-drop-after,.card-drop-group').forEach(x =>
- x.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'));
+ board.querySelectorAll('.card-drop-group').forEach(x => x.classList.remove('card-drop-group'));
+ hideCardDropCursor();
});
el.addEventListener('dragover', e => {
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const edge = rect.width * 0.25, x = e.clientX - rect.left;
- el.classList.toggle('card-drop-before', x < edge);
- el.classList.toggle('card-drop-after', x > rect.width - edge);
- el.classList.toggle('card-drop-group', x >= edge && x <= rect.width - edge);
+ // Three zones (matching the bucket fix): left 25% / right 25%
+ // → insertion cursor in the gap, center 50% → group highlight
+ // on the target card.
+ const isBefore = x < edge;
+ const isAfter = x > rect.width - edge;
+ const isGroup = !isBefore && !isAfter;
+ el.classList.toggle('card-drop-group', isGroup);
+ if (isGroup) {
+ hideCardDropCursor();
+ } else {
+ showCardDropCursor(el, isBefore);
+ }
+ });
+ el.addEventListener('dragleave', () => {
+ el.classList.remove('card-drop-group');
+ // Don't hide the cursor here — the next card's dragover will
+ // reposition it. Hiding on dragleave makes the cursor flicker
+ // as the mouse moves across adjacent cards. Final cleanup
+ // happens on dragend.
});
- el.addEventListener('dragleave', () => el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'));
el.addEventListener('drop', e => handleCardDrop(e, el, id));
});
}
@@ -1001,9 +1066,17 @@ function bindCardEvents() {
async function handleCardDrop(e, el, targetId) {
e.preventDefault();
- const wasGroup = el.classList.contains('card-drop-group');
- const wasBefore = el.classList.contains('card-drop-before');
- el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group');
+ // Recompute the drop zone from the mouse position rather than reading
+ // it back from CSS classes — the new cursor-based dragover doesn't
+ // tag the target with `.card-drop-before` / `.card-drop-after` anymore,
+ // only `.card-drop-group` for the center hover state.
+ const rect = el.getBoundingClientRect();
+ const edge = rect.width * 0.25;
+ const x = e.clientX - rect.left;
+ const wasGroup = !(x < edge) && !(x > rect.width - edge);
+ const wasBefore = x < edge;
+ el.classList.remove('card-drop-group');
+ board.querySelector('#card-drop-cursor')?.style.setProperty('display', 'none');
const dragId = e.dataTransfer.getData('text/plain');
if (!dragId || dragId === targetId) return;
diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js
index fa24cbe..352cb0b 100644
--- a/public/js/modules/buckets.js
+++ b/public/js/modules/buckets.js
@@ -20,9 +20,16 @@ document.addEventListener('click', hideBucketCtx);
async function showBucketCtx(x, y, bucket) {
ctxBucketId = bucket.id;
- const idx = state.buckets.findIndex(b => b.id == 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 < state.buckets.length - 1;
+ const canRight = idx >= 0 && idx < siblings.length - 1;
const hasCards = state.cards.some(c => c.bucket_id == bucket.id);
const isCollab = !!bucket.collab_uid;
@@ -98,11 +105,12 @@ async function showBucketCtx(x, y, bucket) {
}
else if (a === 'move-left' || a === 'move-right') {
hideBucketCtx();
- const swapIdx = a === 'move-left' ? idx - 1 : idx + 1;
- const other = state.buckets[swapIdx];
+ const other = siblings[a === 'move-left' ? idx - 1 : idx + 1];
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 } });
+ // 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') {
@@ -222,13 +230,42 @@ 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;
+ };
+
bar.innerHTML = `
- ${state.buckets.map(b => {
+ ${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 `
`;
+ }
const c = b.color || '#64748b';
const active = state.activeBucket == b.id;
return `
`;
@@ -239,11 +276,152 @@ function renderBar() {
let draggingBucketId = null;
- bar.querySelectorAll('.bucket-tab').forEach(t => {
- const bid = t.dataset.bucket;
- const isUser = bid !== 'all' && bid !== 'none';
+ // 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';
+ }
- t.addEventListener('click', () => { state.activeBucket = bid; state.activeGroup = null; renderBar(); window._renderBoard(); });
+ /** 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 `
`;
+ };
+ 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', () => {
+ state.activeBucket = 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', () => { state.activeBucket = bid; state.activeGroup = null; renderBar(); window._renderBoard(); });
+ }
if (isUser) {
t.addEventListener('contextmenu', e => {
@@ -262,18 +440,31 @@ function renderBar() {
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'));
+ 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) {
- // Bucket reorder indicator
+ // 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 left = e.clientX < rect.left + rect.width / 2;
- t.classList.toggle('bucket-drop-left', left);
- t.classList.toggle('bucket-drop-right', !left);
+ 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
@@ -281,23 +472,44 @@ function renderBar() {
}
});
t.addEventListener('dragleave', () => {
- t.classList.remove('drag-over', 'bucket-drop-left', 'bucket-drop-right');
+ t.classList.remove('drag-over');
});
t.addEventListener('drop', async e => {
e.preventDefault();
- t.classList.remove('drag-over', 'bucket-drop-left', 'bucket-drop-right');
+ t.classList.remove('drag-over', 'bucket-nest-target');
+ hideDropCursor();
- // Bucket reorder
+ // Bucket reorder / nest
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();
+ 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;
}
@@ -314,7 +526,10 @@ function renderBar() {
window._renderBoard();
return;
}
- const bucketId = bid === 'none' ? null : (['all','current','journal'].includes(bid) ? undefined : bid);
+ // 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?.();
diff --git a/public/plugins/tests/super-buckets.test.js b/public/plugins/tests/super-buckets.test.js
new file mode 100644
index 0000000..5cba72d
--- /dev/null
+++ b/public/plugins/tests/super-buckets.test.js
@@ -0,0 +1,269 @@
+import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=2';
+import { state } from '../../js/modules/state.js';
+
+// Helper: create a bucket with the given name and return its row.
+async function makeBucket(name) {
+ return fetch('api/buckets', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
+ body: JSON.stringify({ name }),
+ }).then(r => r.json());
+}
+
+async function patchBucket(id, body) {
+ const res = await fetch(`api/buckets/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
+ body: JSON.stringify(body),
+ });
+ return { status: res.status, body: await res.json() };
+}
+
+async function deleteBucket(id) {
+ return fetch(`api/buckets/${id}`, {
+ method: 'DELETE',
+ headers: { 'X-CSRF-Token': csrf() },
+ }).then(r => r.json());
+}
+
+async function getBuckets() {
+ return fetch('api/buckets').then(r => r.json());
+}
+
+describe('Super-buckets: API parent_id', () => {
+ afterEach(async () => {
+ // These tests don't touch state.activeBucket but the bar-rendering
+ // suite below does — keep cleanup symmetric so reordering tests
+ // doesn't introduce a regression.
+ state.activeBucket = 'all';
+ await closeAll();
+ });
+
+ it('PATCH parent_id nests one bucket under another', async () => {
+ const parent = await makeBucket('__sb_parent_a__');
+ const child = await makeBucket('__sb_child_a__');
+ const res = await patchBucket(child.id, { parent_id: parent.id });
+ expect(res.status).toBe(200);
+ const all = await getBuckets();
+ const found = all.find(b => b.id === child.id);
+ expect(found.parent_id).toBe(parent.id);
+ });
+
+ it('PATCH parent_id = null un-nests', async () => {
+ const parent = await makeBucket('__sb_parent_b__');
+ const child = await makeBucket('__sb_child_b__');
+ await patchBucket(child.id, { parent_id: parent.id });
+ const res = await patchBucket(child.id, { parent_id: null });
+ expect(res.status).toBe(200);
+ const all = await getBuckets();
+ const found = all.find(b => b.id === child.id);
+ expect(found.parent_id).toBeFalsy();
+ });
+
+ it('refuses self as parent', async () => {
+ const b = await makeBucket('__sb_self__');
+ const res = await patchBucket(b.id, { parent_id: b.id });
+ expect(res.status).toBe(400);
+ expect(res.body.error).toContain('self');
+ });
+
+ it('refuses level 2 — bucket with own children cannot become a child', async () => {
+ const grandparent = await makeBucket('__sb_grand__');
+ const middle = await makeBucket('__sb_middle__');
+ const leaf = await makeBucket('__sb_leaf__');
+ // middle becomes a parent of leaf
+ await patchBucket(leaf.id, { parent_id: middle.id });
+ // Now try to make middle a child of grandparent — would create level 2
+ const res = await patchBucket(middle.id, { parent_id: grandparent.id });
+ expect(res.status).toBe(400);
+ expect(res.body.error.toLowerCase()).toContain('children');
+ });
+
+ it('refuses nesting under a bucket that is itself a child', async () => {
+ const parent = await makeBucket('__sb_parent_c__');
+ const child = await makeBucket('__sb_child_c__');
+ const other = await makeBucket('__sb_other_c__');
+ await patchBucket(child.id, { parent_id: parent.id });
+ // Try to nest `other` under `child` — child is already nested
+ const res = await patchBucket(other.id, { parent_id: child.id });
+ expect(res.status).toBe(400);
+ expect(res.body.error.toLowerCase()).toContain('child');
+ });
+
+ it('DELETE on a parent un-nests its children back to top-level', async () => {
+ const parent = await makeBucket('__sb_parent_d__');
+ const c1 = await makeBucket('__sb_child_d1__');
+ const c2 = await makeBucket('__sb_child_d2__');
+ await patchBucket(c1.id, { parent_id: parent.id });
+ await patchBucket(c2.id, { parent_id: parent.id });
+ await deleteBucket(parent.id);
+ const all = await getBuckets();
+ const f1 = all.find(b => b.id === c1.id);
+ const f2 = all.find(b => b.id === c2.id);
+ expect(f1).toExist();
+ expect(f2).toExist();
+ expect(f1.parent_id).toBeFalsy();
+ expect(f2.parent_id).toBeFalsy();
+ });
+
+ it('export → import preserves parent_id', async () => {
+ const parent = await makeBucket('__sb_parent_e__');
+ const child = await makeBucket('__sb_child_e__');
+ await patchBucket(child.id, { parent_id: parent.id });
+
+ // Round-trip through export → replace-import
+ const blob = await fetch('api/export').then(r => r.blob());
+ const form = new FormData();
+ form.append('file', blob, 'roundtrip.zip');
+ await fetch('api/import', { method: 'POST', body: form, headers: { 'X-CSRF-Token': csrf() } });
+
+ const all = await getBuckets();
+ const p = all.find(b => b.name === '__sb_parent_e__');
+ const c = all.find(b => b.name === '__sb_child_e__');
+ expect(p).toExist();
+ expect(c).toExist();
+ expect(c.parent_id).toBe(p.id);
+ });
+});
+
+describe('Super-buckets: Bar rendering', () => {
+ afterEach(async () => {
+ // Reset activeBucket — several tests in this suite set it to a
+ // child id that wouldn't otherwise survive the run. Without this
+ // reset, downstream tests that create cards with bucket_id=null
+ // can't find them on the board (the activeBucket filter excludes
+ // them) and start failing in nondeterministic order.
+ state.activeBucket = 'all';
+ document.getElementById('bucket-super-popover')?.remove();
+ await closeAll();
+ });
+
+ it('a parent with one child renders a single super-tile in the bar', async () => {
+ const parent = await makeBucket('__sb_render_parent__');
+ const child = await makeBucket('__sb_render_child__');
+ await patchBucket(child.id, { parent_id: parent.id });
+ await window._reloadAll?.();
+ await sleep(200);
+
+ // The super-tile carries data-bucket-super=
; the child is
+ // not rendered directly in the bar.
+ const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
+ expect(superTile).toExist();
+ const childTile = document.querySelector(`#bucket-bar [data-bucket="${child.id}"]:not(#bucket-super-popover [data-bucket])`);
+ expect(childTile).toBe(null);
+ });
+
+ it('super-tile shows the parent name when no child is active', async () => {
+ const parent = await makeBucket('__sb_label_parent__');
+ const child = await makeBucket('__sb_label_child__');
+ await patchBucket(child.id, { parent_id: parent.id });
+ // Reset active filter so the parent is the default shown member.
+ state.activeBucket = 'all';
+ await window._reloadAll?.();
+ await sleep(200);
+
+ const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
+ if (!superTile) return;
+ expect(superTile.textContent).toContain('__sb_label_parent__');
+ });
+
+ it('super-tile relabels to the active child name when a child is selected', async () => {
+ const parent = await makeBucket('__sb_active_parent__');
+ const child = await makeBucket('__sb_active_child__');
+ await patchBucket(child.id, { parent_id: parent.id });
+ state.activeBucket = String(child.id);
+ await window._reloadAll?.();
+ await sleep(200);
+
+ const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
+ if (!superTile) return;
+ expect(superTile.textContent).toContain('__sb_active_child__');
+ });
+
+ it('clicking the super-tile opens a popover with parent + child entries', async () => {
+ const parent = await makeBucket('__sb_pop_parent__');
+ const child1 = await makeBucket('__sb_pop_child1__');
+ const child2 = await makeBucket('__sb_pop_child2__');
+ await patchBucket(child1.id, { parent_id: parent.id });
+ await patchBucket(child2.id, { parent_id: parent.id });
+ await window._reloadAll?.();
+ await sleep(200);
+
+ const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
+ if (!superTile) return;
+ click(superTile);
+ await sleep(100);
+
+ const popover = document.getElementById('bucket-super-popover');
+ expect(popover).toExist();
+ // Parent + 2 children = 3 entries
+ const entries = popover.querySelectorAll('[data-bucket]');
+ expect(entries.length).toBe(3);
+ // All three names appear
+ const text = popover.textContent;
+ expect(text).toContain('__sb_pop_parent__');
+ expect(text).toContain('__sb_pop_child1__');
+ expect(text).toContain('__sb_pop_child2__');
+ });
+
+ it('clicking a child entry in the popover sets activeBucket', async () => {
+ const parent = await makeBucket('__sb_act_parent__');
+ const child = await makeBucket('__sb_act_child__');
+ await patchBucket(child.id, { parent_id: parent.id });
+ await window._reloadAll?.();
+ await sleep(200);
+
+ const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
+ if (!superTile) return;
+ click(superTile);
+ await sleep(100);
+
+ const childEntry = document.querySelector(`#bucket-super-popover [data-bucket="${child.id}"]`);
+ expect(childEntry).toExist();
+ click(childEntry);
+ await sleep(150);
+
+ expect(String(state.activeBucket)).toBe(String(child.id));
+ });
+
+ it('clicking the super-tile twice toggles the popover closed', async () => {
+ const parent = await makeBucket('__sb_toggle_parent__');
+ const child = await makeBucket('__sb_toggle_child__');
+ await patchBucket(child.id, { parent_id: parent.id });
+ await window._reloadAll?.();
+ await sleep(200);
+
+ const superTile = document.querySelector(`#bucket-bar [data-bucket-super="${parent.id}"]`);
+ if (!superTile) return;
+ click(superTile);
+ await sleep(100);
+ expect(document.getElementById('bucket-super-popover')).toExist();
+ click(superTile);
+ await sleep(100);
+ expect(document.getElementById('bucket-super-popover')).toBe(null);
+ });
+
+ it('regular bucket (no children) keeps its plain data-bucket tile', async () => {
+ const plain = await makeBucket('__sb_plain__');
+ await window._reloadAll?.();
+ await sleep(200);
+ const tile = document.querySelector(`#bucket-bar [data-bucket="${plain.id}"]`);
+ expect(tile).toExist();
+ // Plain tile is NOT a super-tile
+ expect(tile.classList.contains('bucket-super')).toBe(false);
+ });
+});
+
+describe('Super-buckets: Drop cursor', () => {
+ afterEach(() => closeAll());
+
+ it('#bucket-drop-cursor element exists in the bar', async () => {
+ // Force a fresh render so the cursor element is appended
+ await window._reloadAll?.();
+ await sleep(100);
+ const cursor = document.querySelector('#bucket-bar #bucket-drop-cursor');
+ expect(cursor).toExist();
+ // Hidden by default
+ expect(cursor.style.display === 'none' || cursor.style.display === '').toBe(true);
+ });
+});
diff --git a/public/plugins/tests/tests.js b/public/plugins/tests/tests.js
index 34798bb..613bb69 100644
--- a/public/plugins/tests/tests.js
+++ b/public/plugins/tests/tests.js
@@ -13,6 +13,7 @@ const FILES = [
'./toolbar.test.js',
'./buckets.test.js',
'./bucket-context.test.js',
+ './super-buckets.test.js',
'./context-menu.test.js',
'./card-interactions.test.js',
'./filtering.test.js',
diff --git a/src/Database.php b/src/Database.php
index 91aed10..1988828 100644
--- a/src/Database.php
+++ b/src/Database.php
@@ -144,5 +144,12 @@ class Database
self::$pdo->exec('ALTER TABLE buckets ADD COLUMN collab_uid TEXT DEFAULT NULL');
self::$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_buckets_collab_uid ON buckets(collab_uid) WHERE collab_uid IS NOT NULL');
}
+ // Super-buckets: 1-level nesting. parent_id = NULL for top-level
+ // (regular bucket OR super-bucket parent), or another bucket id for
+ // a child. Depth is enforced in the API layer (no grandchildren).
+ if (!in_array('parent_id', $bcolNames)) {
+ self::$pdo->exec('ALTER TABLE buckets ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES buckets(id)');
+ self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_buckets_parent_id ON buckets(parent_id)');
+ }
}
}