plugin(collab-buckets): collaborative buckets via relay
End-to-end shared buckets between relay tenants. Bucket metadata + members sync through new relay routes (collab-buckets, collab-cards, collab-locks); card content rides the existing inter-tenant encryption hop. Edit locks, ownership transfer, LWW conflict stashing, undo for local-bucket destructive actions, "Make collaborative…" upgrade path, and a resilient outbox for relay-down create attempts. See plugins/collab-buckets/README.md and TODO.md for the full picture. Core hooks added so the plugin can integrate without monkey-patching: - buckets.js context menu: collab-aware rename/color/delete + Members & owner / Make collaborative entries - editor.js dispatches hyprcrd:editor-open / hyprcrd:editor-close for the per-card edit-lock heartbeat - app.js exposes window.state for the plugin's MutationObserver - index.php POST/PATCH/DELETE on /api/buckets refuse to mutate collab buckets directly (must go through the relay) - Database.php adds buckets.collab_uid column + unique partial index - import handler now restores color and collab_uid (was silently dropping color, which caused the test runner's auto-restore to wipe custom bucket colors) - relay bootstrap creates collab_buckets, collab_bucket_members, collab_cards, collab_card_locks tables and prunes tombstones / expired locks on every request Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a29708df85
commit
26ffa0e71f
16 files changed
+3017
-13
No files matched your search
@@ -39,6 +39,9 @@ window._getSelectedIds = getSelectedIds;
|
||||
window._clearSelection = clearSelection;
|
||||
window._refreshTasks = refreshTasksView;
|
||||
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
|
||||
// Exposed for plugins that need to read the shared state (e.g. collab-buckets
|
||||
// reads `state.buckets` in its MutationObserver to find collab_uid mapping).
|
||||
window.state = state;
|
||||
window._extractToCard = async (text) => {
|
||||
if (!text?.trim()) return;
|
||||
const lines = text.trim().split('\n');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { $, esc, api } from './util.js';
|
||||
import { state, CARD_COLORS } from './state.js';
|
||||
import { showDialog } from './dialog.js';
|
||||
import { pushUndo } from './undo.js';
|
||||
|
||||
// ── Bucket context menu ──
|
||||
|
||||
@@ -24,9 +25,18 @@ async function showBucketCtx(x, y, bucket) {
|
||||
const canRight = idx < state.buckets.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>
|
||||
@@ -56,12 +66,35 @@ async function showBucketCtx(x, y, bucket) {
|
||||
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;
|
||||
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } });
|
||||
await loadBuckets(); window._renderBoard();
|
||||
// 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();
|
||||
@@ -76,12 +109,37 @@ async function showBucketCtx(x, y, bucket) {
|
||||
hideBucketCtx();
|
||||
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
|
||||
if (cards.length > 0 || localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||
const ok = await showDialog({ title: 'delete_bucket', message: cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`, danger: true, okLabel: 'Delete' });
|
||||
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) state.activeBucket = 'all';
|
||||
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
|
||||
await loadBuckets(); await window._reloadAll();
|
||||
// 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();
|
||||
@@ -90,8 +148,13 @@ async function showBucketCtx(x, y, bucket) {
|
||||
if (!ok) return;
|
||||
for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
|
||||
await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
|
||||
await loadBuckets(); await window._reloadAll();
|
||||
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();
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -99,9 +162,14 @@ async function showBucketCtx(x, y, bucket) {
|
||||
ctxMenu.querySelectorAll('[data-bctx-color]').forEach(btn => btn.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
hideBucketCtx();
|
||||
await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: btn.dataset.bctxColor } });
|
||||
await loadBuckets();
|
||||
window._renderBoard();
|
||||
const 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();
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -256,6 +324,14 @@ function renderBar() {
|
||||
|
||||
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() } });
|
||||
|
||||
@@ -200,6 +200,8 @@ export function openModal(card = null) {
|
||||
currentLine = -1;
|
||||
updateActiveLine();
|
||||
inpTitle.focus();
|
||||
// Plugins (collab-buckets) hook into this to heartbeat an edit lock
|
||||
window.dispatchEvent(new CustomEvent('hyprcrd:editor-open', { detail: { card } }));
|
||||
}
|
||||
|
||||
inpTitle.addEventListener('input', updatePreview);
|
||||
@@ -354,6 +356,7 @@ body.addEventListener('keyup', updateActiveLine);
|
||||
window.addEventListener('resize', () => { if (linesVisible) updateGutter(); });
|
||||
|
||||
export function closeModal({ animate = true } = {}) {
|
||||
const closingId = state.editingId;
|
||||
if (animate) startCloseMorph();
|
||||
else clearMorphGhost();
|
||||
hideLinkAc();
|
||||
@@ -366,6 +369,7 @@ export function closeModal({ animate = true } = {}) {
|
||||
state._returnToCalendar = false;
|
||||
window._openCalendar?.();
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('hyprcrd:editor-close', { detail: { cardId: closingId } }));
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
|
||||
Reference in new issue
Block a user