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
@@ -0,0 +1,719 @@
|
||||
// ── Collab Buckets Plugin Frontend ──
|
||||
//
|
||||
// Phase 1+2 shape (post-refactor): collab buckets are first-class bucket
|
||||
// rows in the standard bucket bar. The plugin just owns the sync + the
|
||||
// visual unread-state decoration:
|
||||
//
|
||||
// - Polls /api/collab-buckets/poll every POLL_INTERVAL_MS. The backend
|
||||
// mirrors each remote bucket into the main `buckets` table so the core
|
||||
// bucket bar renders it automatically.
|
||||
// - Exposes `window._collabBucketsCreateDialog` — an async function that
|
||||
// the core + Bucket button calls so we can pop a richer "new bucket"
|
||||
// modal with an optional "collaborative" checkbox + member picker.
|
||||
// - Watches #bucket-bar via MutationObserver. After every render, finds
|
||||
// tabs whose data-bucket id corresponds to a collab bucket and attaches
|
||||
// a pulse animation + red unread dot if the bucket has unread state.
|
||||
// - Delegated click handler on #bucket-bar calls mark-seen for collab tabs.
|
||||
//
|
||||
// The plugin never touches the header anymore. No more standalone tiles.
|
||||
|
||||
const hyprcrd = window.hyprcrd;
|
||||
if (!hyprcrd) throw new Error('[collab-buckets] window.hyprcrd missing');
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// uid -> collab bucket meta (from /api/collab-buckets)
|
||||
let collabByUid = new Map();
|
||||
let pollHandle = null;
|
||||
const POLL_INTERVAL_MS = 30000;
|
||||
|
||||
// Phase 4: edit locks
|
||||
// card_uid -> { bucket_uid, holder_username, expires_at }
|
||||
let collabLocks = new Map();
|
||||
// Active heartbeat for the currently open editor (if editing a collab card)
|
||||
let activeLock = null; // { bucket_uid, card_uid, timer }
|
||||
const LOCK_HEARTBEAT_MS = 30000;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ── Loading / polling ─────────────────────────────────────────────
|
||||
|
||||
async function loadCollabMeta() {
|
||||
try {
|
||||
const res = await fetch('api/collab-buckets').then(r => r.json());
|
||||
if (!res?.ok) return;
|
||||
const next = new Map();
|
||||
for (const b of res.buckets || []) next.set(b.uid, b);
|
||||
collabByUid = next;
|
||||
// Expose for other plugin code + for debugging
|
||||
window._collabBucketsByUid = collabByUid;
|
||||
decorateBucketBar();
|
||||
} catch (err) { /* silent; relay may be offline */ }
|
||||
}
|
||||
|
||||
async function pollOnce() {
|
||||
try {
|
||||
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
|
||||
if (!res?.ok) return;
|
||||
// Reload if:
|
||||
// * a bucket changed (metadata / members)
|
||||
// * the bucket set shrunk or grew
|
||||
// * card sync pushed / pulled / deleted anything (Phase 3)
|
||||
const bucketChanged = res.changed > 0 || res.total !== collabByUid.size;
|
||||
const cardsChanged = !!res.cards_changed;
|
||||
if (bucketChanged || cardsChanged) {
|
||||
await loadCollabMeta();
|
||||
if (typeof window._reloadAll === 'function') await window._reloadAll();
|
||||
}
|
||||
// If LWW clobbered local edits, the backend stashed them in a local
|
||||
// "Conflicts" bucket — surface that visibly so the user knows.
|
||||
const conflicts = res.card_stats?.conflicts || 0;
|
||||
if (conflicts > 0) {
|
||||
showConflictToast(conflicts);
|
||||
}
|
||||
// Phase 5: if a queued bucket create finally pushed through, tell
|
||||
// the user. Retry failures stay silent.
|
||||
if ((res.outbox_processed || 0) > 0) {
|
||||
const n = res.outbox_processed;
|
||||
showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`);
|
||||
}
|
||||
// Phase 4: edit-lock list comes back with the poll
|
||||
if (Array.isArray(res.locks)) {
|
||||
const next = new Map();
|
||||
for (const l of res.locks) next.set(l.card_uid, l);
|
||||
collabLocks = next;
|
||||
window._collabLocks = collabLocks;
|
||||
decorateBoardLocks();
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
function showConflictToast(n) {
|
||||
const existing = document.getElementById('collab-conflict-toast');
|
||||
if (existing) existing.remove();
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'collab-conflict-toast';
|
||||
toast.className = 'collab-conflict-toast';
|
||||
toast.innerHTML = `
|
||||
<i class="ph ph-warning text-sm"></i>
|
||||
<span>${n} local change${n === 1 ? '' : 's'} overwritten by a collaborator — backup in the <b>Conflicts</b> bucket.</span>
|
||||
<button type="button" class="collab-conflict-close" aria-label="dismiss"><i class="ph ph-x text-sm"></i></button>
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
toast.querySelector('.collab-conflict-close').addEventListener('click', () => toast.remove());
|
||||
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 10000);
|
||||
}
|
||||
|
||||
/** Informational toast — used when the relay is unreachable and a bucket
|
||||
* create was queued, or when the outbox retry succeeds later. */
|
||||
function showQueuedToast(message) {
|
||||
const existing = document.getElementById('collab-queued-toast');
|
||||
if (existing) existing.remove();
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'collab-queued-toast';
|
||||
toast.className = 'collab-conflict-toast collab-queued-toast';
|
||||
toast.innerHTML = `
|
||||
<i class="ph ph-cloud-arrow-up text-sm"></i>
|
||||
<span>${escapeHtml(message)}</span>
|
||||
<button type="button" class="collab-conflict-close" aria-label="dismiss"><i class="ph ph-x text-sm"></i></button>
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
toast.querySelector('.collab-conflict-close').addEventListener('click', () => toast.remove());
|
||||
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
pollHandle = setInterval(pollOnce, POLL_INTERVAL_MS);
|
||||
}
|
||||
function stopPolling() {
|
||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
||||
}
|
||||
|
||||
// ── Bucket bar decoration ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* For every tab in #bucket-bar whose bucket has a collab_uid, apply
|
||||
* collab-specific classes (.bucket-tab-collab, .has-unread, .pulsing)
|
||||
* and inject an unread dot. Runs after every core re-render via the
|
||||
* MutationObserver below.
|
||||
*/
|
||||
function decorateBucketBar() {
|
||||
const bar = document.getElementById('bucket-bar');
|
||||
if (!bar || !window.state?.buckets) return;
|
||||
|
||||
// Build bucket-id -> row map so we can look up collab_uid
|
||||
const byId = new Map();
|
||||
for (const b of window.state.buckets) byId.set(String(b.id), b);
|
||||
|
||||
for (const tab of bar.querySelectorAll('.bucket-tab[data-bucket]')) {
|
||||
const bid = tab.dataset.bucket;
|
||||
const row = byId.get(bid);
|
||||
if (!row || !row.collab_uid) continue;
|
||||
|
||||
tab.classList.add('bucket-tab-collab');
|
||||
tab.dataset.collabUid = row.collab_uid;
|
||||
|
||||
// Replace the default folder icon with the "users" icon so the tab
|
||||
// reads as collaborative at a glance. We query by class and swap
|
||||
// the phosphor icon class set. Safe to run on every render — the
|
||||
// "icon swap" is idempotent.
|
||||
const icon = tab.querySelector('i.ph');
|
||||
if (icon && !icon.classList.contains('ph-users-three')) {
|
||||
icon.classList.remove('ph-folder');
|
||||
icon.classList.add('ph-users-three');
|
||||
}
|
||||
|
||||
// Unread state
|
||||
const meta = collabByUid.get(row.collab_uid);
|
||||
const wasUnread = tab.classList.contains('has-unread');
|
||||
const isUnread = !!meta?.has_unread;
|
||||
tab.classList.toggle('has-unread', isUnread);
|
||||
|
||||
// Inject / remove dot element
|
||||
let dot = tab.querySelector('.collab-tab-dot');
|
||||
if (isUnread && !dot) {
|
||||
dot = document.createElement('span');
|
||||
dot.className = 'collab-tab-dot';
|
||||
tab.appendChild(dot);
|
||||
} else if (!isUnread && dot) {
|
||||
dot.remove();
|
||||
}
|
||||
|
||||
// Fresh unread -> pulse 3x. JS strips the class after 3.7s so
|
||||
// subsequent decorate-calls don't retrigger mid-animation.
|
||||
if (isUnread && !wasUnread) {
|
||||
tab.classList.add('pulsing');
|
||||
setTimeout(() => tab.classList.remove('pulsing'), 3700);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mark-seen on click (delegated, fires after core's click handler) ──
|
||||
|
||||
function setupClickInterceptor() {
|
||||
const bar = document.getElementById('bucket-bar');
|
||||
if (!bar) return;
|
||||
bar.addEventListener('click', async (e) => {
|
||||
const tab = e.target.closest('.bucket-tab[data-bucket]');
|
||||
if (!tab) return;
|
||||
const uid = tab.dataset.collabUid;
|
||||
if (!uid) return;
|
||||
// Clear unread locally immediately for snappy feedback
|
||||
const meta = collabByUid.get(uid);
|
||||
if (meta) meta.has_unread = false;
|
||||
tab.classList.remove('has-unread', 'pulsing');
|
||||
tab.querySelector('.collab-tab-dot')?.remove();
|
||||
// Persist to the plugin backend
|
||||
hyprcrd.api('api/collab-buckets/mark-seen', { method: 'POST', body: { uid } }).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
// ── MutationObserver: redecorate on every bar re-render ───────────
|
||||
|
||||
function setupBarObserver() {
|
||||
const bar = document.getElementById('bucket-bar');
|
||||
if (!bar) return;
|
||||
const obs = new MutationObserver(() => decorateBucketBar());
|
||||
obs.observe(bar, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
// ── Create dialog (hooked by core's + Bucket button) ──────────────
|
||||
|
||||
async function fetchRelayTenants() {
|
||||
try {
|
||||
const res = await hyprcrd.api('api/collab-buckets/relay-tenants');
|
||||
return res?.ok ? res : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when the user clicks the core "+ Bucket" button. Returns true when
|
||||
* a bucket was created (so core can reload) and false / null when canceled.
|
||||
*/
|
||||
async function createBucketDialog() {
|
||||
// Lazy-load the relay tenants so a local-only bucket doesn't pay for
|
||||
// the round trip when the user just wants a simple bucket.
|
||||
const relayP = fetchRelayTenants();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'collab-modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="collab-modal">
|
||||
<div class="collab-modal-header">
|
||||
<i class="ph ph-folder-plus text-t-accent"></i>
|
||||
<span class="font-pixel text-xs text-t-accent">new bucket</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
|
||||
</div>
|
||||
<div class="collab-modal-body">
|
||||
<label class="collab-field">
|
||||
<span class="collab-label">Name</span>
|
||||
<input type="text" class="collab-input" id="__cb_name" placeholder="bucket name...">
|
||||
</label>
|
||||
<label class="collab-field">
|
||||
<span class="collab-label">Color</span>
|
||||
<input type="color" class="collab-color" id="__cb_color" value="#64748b">
|
||||
</label>
|
||||
<label class="collab-field collab-toggle-row">
|
||||
<input type="checkbox" id="__cb_collab">
|
||||
<span>Collaborative — share with relay users</span>
|
||||
</label>
|
||||
<div class="collab-field collab-members-wrap hidden" id="__cb_members_wrap">
|
||||
<span class="collab-label">Members</span>
|
||||
<div class="collab-members" id="__cb_members_list">
|
||||
<div class="collab-members-empty">loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collab-modal-footer">
|
||||
<div class="collab-modal-status" id="__cb_status"></div>
|
||||
<div style="flex:1"></div>
|
||||
<button class="collab-btn-cancel" type="button">Cancel</button>
|
||||
<button class="collab-btn-submit" type="button">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const done = (created) => { overlay.remove(); resolve(created); };
|
||||
overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false));
|
||||
overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false));
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); });
|
||||
document.addEventListener('keydown', function esc(e) {
|
||||
if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); }
|
||||
});
|
||||
|
||||
const collabEl = overlay.querySelector('#__cb_collab');
|
||||
const membersWrap = overlay.querySelector('#__cb_members_wrap');
|
||||
const membersList = overlay.querySelector('#__cb_members_list');
|
||||
const status = overlay.querySelector('#__cb_status');
|
||||
|
||||
let peers = [];
|
||||
let tenantsLoaded = false;
|
||||
|
||||
collabEl.addEventListener('change', async () => {
|
||||
if (!collabEl.checked) {
|
||||
membersWrap.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
membersWrap.classList.remove('hidden');
|
||||
if (tenantsLoaded) return;
|
||||
const relay = await relayP;
|
||||
tenantsLoaded = true;
|
||||
if (!relay) {
|
||||
membersList.innerHTML = '<div class="collab-members-empty">could not reach the relay — uncheck collaborative to create a local bucket</div>';
|
||||
return;
|
||||
}
|
||||
const self = relay.self || '';
|
||||
peers = (relay.tenants || []).filter(t => (t.username || t) !== self);
|
||||
if (!peers.length) {
|
||||
membersList.innerHTML = '<div class="collab-members-empty">no other relay users</div>';
|
||||
return;
|
||||
}
|
||||
membersList.innerHTML = peers.map(t => {
|
||||
const u = typeof t === 'string' ? t : (t.username || '');
|
||||
return `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}"><span>${escapeHtml(u)}</span></label>`;
|
||||
}).join('');
|
||||
});
|
||||
|
||||
overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => {
|
||||
const name = overlay.querySelector('#__cb_name').value.trim();
|
||||
const color = overlay.querySelector('#__cb_color').value;
|
||||
const isCollab = collabEl.checked;
|
||||
if (!name) { status.textContent = 'name required'; status.style.color = 'var(--t-danger)'; return; }
|
||||
status.textContent = 'creating…';
|
||||
status.style.color = 'var(--t-dim)';
|
||||
try {
|
||||
if (isCollab) {
|
||||
const members = [...overlay.querySelectorAll('#__cb_members_list input:checked')].map(c => c.value);
|
||||
const res = await hyprcrd.api('api/collab-buckets', { method: 'POST', body: { name, color, members } });
|
||||
if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; }
|
||||
// Relay was unreachable; the bucket exists locally and the
|
||||
// upgrade is queued. Show a toast so the user knows.
|
||||
if (res.queued) {
|
||||
showQueuedToast('Bucket created locally — will sync to the relay on the next successful poll.');
|
||||
}
|
||||
} else {
|
||||
// Regular bucket: POST /api/buckets (core route), then PATCH color.
|
||||
const created = await hyprcrd.api('api/buckets', { method: 'POST', body: { name } });
|
||||
if (created?.id && color) {
|
||||
await hyprcrd.api(`api/buckets/${created.id}`, { method: 'PATCH', body: { color } });
|
||||
}
|
||||
}
|
||||
await loadCollabMeta();
|
||||
done(true);
|
||||
} catch (err) {
|
||||
status.textContent = String(err?.message || err);
|
||||
status.style.color = 'var(--t-danger)';
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => overlay.querySelector('#__cb_name')?.focus(), 50);
|
||||
});
|
||||
}
|
||||
|
||||
// Expose for core's + Bucket handler
|
||||
window._collabBucketsCreateDialog = createBucketDialog;
|
||||
|
||||
/**
|
||||
* Edit dialog for an existing collab bucket. Shown by the core bucket
|
||||
* context menu's "Members & owner…" item. The current owner sees a
|
||||
* member picker + an owner dropdown; other members see everything as
|
||||
* read-only labels since only the owner can push updates. Returns true
|
||||
* when the PATCH succeeded so core can reload.
|
||||
*/
|
||||
async function editBucketDialog(collabUid) {
|
||||
const bucket = collabByUid.get(collabUid);
|
||||
if (!bucket) return false;
|
||||
const relay = await fetchRelayTenants();
|
||||
if (!relay) {
|
||||
await hyprcrd.dialog.show({
|
||||
title: 'collab buckets',
|
||||
message: 'Could not reach the relay.',
|
||||
okLabel: 'Ok', cancelLabel: '',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const self = relay.self || '';
|
||||
const allTenants = (relay.tenants || []).map(t => typeof t === 'string' ? t : (t.username || ''));
|
||||
const peers = allTenants.filter(u => u && u !== self);
|
||||
const isOwner = bucket.owner_username === self;
|
||||
const currentMembers = new Set((bucket.members || []).filter(u => u && u !== self));
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'collab-modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="collab-modal">
|
||||
<div class="collab-modal-header">
|
||||
<i class="ph ph-users-three text-t-accent"></i>
|
||||
<span class="font-pixel text-xs text-t-accent">${escapeHtml(bucket.name)} — members</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
|
||||
</div>
|
||||
<div class="collab-modal-body">
|
||||
<div class="collab-field">
|
||||
<span class="collab-label">Owner</span>
|
||||
${isOwner
|
||||
? `<select class="collab-input" id="__cb_owner">
|
||||
<option value="${escapeHtml(self)}">${escapeHtml(self)} (you)</option>
|
||||
${[...currentMembers].sort().map(u => `<option value="${escapeHtml(u)}">${escapeHtml(u)}</option>`).join('')}
|
||||
</select>
|
||||
<div class="collab-hint">Transferring will hand over all edit rights. You stay a member.</div>`
|
||||
: `<div class="collab-readonly">${escapeHtml(bucket.owner_username)}</div>`
|
||||
}
|
||||
</div>
|
||||
<div class="collab-field">
|
||||
<span class="collab-label">Members (${peers.length} available)</span>
|
||||
${isOwner
|
||||
? `<div class="collab-members" id="__cb_edit_members">
|
||||
${peers.map(u => {
|
||||
const checked = currentMembers.has(u) ? 'checked' : '';
|
||||
return `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}" ${checked}><span>${escapeHtml(u)}</span></label>`;
|
||||
}).join('') || '<div class="collab-members-empty">no other relay users</div>'}
|
||||
</div>`
|
||||
: `<div class="collab-members">
|
||||
${(bucket.members || []).map(u => `<div class="collab-member-row"><span>${escapeHtml(u)}${u === self ? ' (you)' : ''}</span></div>`).join('')}
|
||||
</div>
|
||||
<div class="collab-hint">Only the owner can change members or transfer ownership.</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="collab-modal-footer">
|
||||
<div class="collab-modal-status" id="__cb_status"></div>
|
||||
<div style="flex:1"></div>
|
||||
<button class="collab-btn-cancel" type="button">${isOwner ? 'Cancel' : 'Close'}</button>
|
||||
${isOwner ? `<button class="collab-btn-submit" type="button">Save</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const done = (changed) => { overlay.remove(); resolve(changed); };
|
||||
overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false));
|
||||
overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false));
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); });
|
||||
document.addEventListener('keydown', function esc(e) {
|
||||
if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); }
|
||||
});
|
||||
|
||||
if (!isOwner) return; // read-only view; no submit handler
|
||||
|
||||
overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => {
|
||||
const newOwner = overlay.querySelector('#__cb_owner').value;
|
||||
const members = [...overlay.querySelectorAll('#__cb_edit_members input:checked')].map(c => c.value);
|
||||
const status = overlay.querySelector('#__cb_status');
|
||||
const payload = { members };
|
||||
if (newOwner && newOwner !== self) payload.owner = newOwner;
|
||||
status.textContent = 'saving…';
|
||||
status.style.color = 'var(--t-dim)';
|
||||
try {
|
||||
const res = await hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), {
|
||||
method: 'PATCH', body: payload,
|
||||
});
|
||||
if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; }
|
||||
await pollOnce();
|
||||
await loadCollabMeta();
|
||||
done(true);
|
||||
} catch (err) {
|
||||
status.textContent = String(err?.message || err);
|
||||
status.style.color = 'var(--t-danger)';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window._collabBucketsEditDialog = editBucketDialog;
|
||||
|
||||
/**
|
||||
* Upgrade dialog for turning an existing local bucket into a collab
|
||||
* bucket. The core bucket context menu shows "Make collaborative…" for
|
||||
* non-collab buckets when the plugin is loaded. The dialog just picks
|
||||
* members — name/color come from the existing bucket and are reused.
|
||||
* Returns true on success so core can reload.
|
||||
*/
|
||||
async function upgradeBucketDialog(localBucketId) {
|
||||
const bucket = window.state?.buckets?.find(b => String(b.id) === String(localBucketId));
|
||||
if (!bucket || bucket.collab_uid) return false;
|
||||
const relay = await fetchRelayTenants();
|
||||
if (!relay) {
|
||||
await hyprcrd.dialog.show({
|
||||
title: 'collab buckets',
|
||||
message: 'Could not reach the relay. Check the relay settings first.',
|
||||
okLabel: 'Ok', cancelLabel: '',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const self = relay.self || '';
|
||||
const peers = (relay.tenants || []).map(t => typeof t === 'string' ? t : (t.username || '')).filter(u => u && u !== self);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'collab-modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="collab-modal">
|
||||
<div class="collab-modal-header">
|
||||
<i class="ph ph-users-three text-t-accent"></i>
|
||||
<span class="font-pixel text-xs text-t-accent">make "${escapeHtml(bucket.name)}" collaborative</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="collab-modal-close" type="button" aria-label="close"><i class="ph ph-x text-sm"></i></button>
|
||||
</div>
|
||||
<div class="collab-modal-body">
|
||||
<div class="collab-field">
|
||||
<div class="collab-hint">This bucket and all its cards will be shared with the members you pick. Existing cards are pushed on the next sync (~30s). You stay the owner.</div>
|
||||
</div>
|
||||
<div class="collab-field">
|
||||
<span class="collab-label">Members (${peers.length} available)</span>
|
||||
${peers.length === 0
|
||||
? '<div class="collab-members-empty">no other relay users</div>'
|
||||
: `<div class="collab-members" id="__cb_up_members">
|
||||
${peers.map(u => `<label class="collab-member-row"><input type="checkbox" value="${escapeHtml(u)}"><span>${escapeHtml(u)}</span></label>`).join('')}
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="collab-modal-footer">
|
||||
<div class="collab-modal-status" id="__cb_status"></div>
|
||||
<div style="flex:1"></div>
|
||||
<button class="collab-btn-cancel" type="button">Cancel</button>
|
||||
<button class="collab-btn-submit" type="button">Make collaborative</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const done = (changed) => { overlay.remove(); resolve(changed); };
|
||||
overlay.querySelector('.collab-modal-close').addEventListener('click', () => done(false));
|
||||
overlay.querySelector('.collab-btn-cancel').addEventListener('click', () => done(false));
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) done(false); });
|
||||
document.addEventListener('keydown', function esc(e) {
|
||||
if (e.key === 'Escape') { done(false); document.removeEventListener('keydown', esc); }
|
||||
});
|
||||
|
||||
overlay.querySelector('.collab-btn-submit').addEventListener('click', async () => {
|
||||
const status = overlay.querySelector('#__cb_status');
|
||||
const members = [...overlay.querySelectorAll('#__cb_up_members input:checked')].map(c => c.value);
|
||||
status.textContent = 'upgrading…';
|
||||
status.style.color = 'var(--t-dim)';
|
||||
try {
|
||||
const res = await hyprcrd.api('api/collab-buckets/upgrade', {
|
||||
method: 'POST',
|
||||
body: { bucket_id: localBucketId, members },
|
||||
});
|
||||
if (!res?.ok) { status.textContent = res?.error || 'failed'; status.style.color = 'var(--t-danger)'; return; }
|
||||
await pollOnce();
|
||||
await loadCollabMeta();
|
||||
done(true);
|
||||
} catch (err) {
|
||||
status.textContent = String(err?.message || err);
|
||||
status.style.color = 'var(--t-danger)';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window._collabBucketsUpgradeDialog = upgradeBucketDialog;
|
||||
|
||||
// Expose helper so the core bucket context menu can route collab-bucket
|
||||
// rename / color / delete through the plugin endpoints.
|
||||
window._collabBucketsPatch = async function (collabUid, payload) {
|
||||
return hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), { method: 'PATCH', body: payload });
|
||||
};
|
||||
window._collabBucketsDelete = async function (collabUid) {
|
||||
return hyprcrd.api('api/collab-buckets/' + encodeURIComponent(collabUid), { method: 'DELETE' });
|
||||
};
|
||||
|
||||
// ── Phase 4: board-card lock decoration ───────────────────────────
|
||||
|
||||
/**
|
||||
* Walk every rendered board card and add / remove a lock indicator if
|
||||
* another user currently holds an edit lock on it. Idempotent — safe to
|
||||
* call on every render / lock-poll.
|
||||
*/
|
||||
function decorateBoardLocks() {
|
||||
const board = document.getElementById('board');
|
||||
if (!board || !window.state?.cards) return;
|
||||
// Build card_id -> card row lookup so we can resolve relay_uid
|
||||
const byId = new Map();
|
||||
for (const c of window.state.cards) byId.set(String(c.id), c);
|
||||
|
||||
const self = window._collabSelf || null;
|
||||
for (const el of board.querySelectorAll('.board-card[data-id]')) {
|
||||
const card = byId.get(el.dataset.id);
|
||||
const lock = card && card.relay_uid ? collabLocks.get(card.relay_uid) : null;
|
||||
// Ignore our own lock — no point showing the user their own editing state
|
||||
const foreign = lock && lock.holder_username && lock.holder_username !== self;
|
||||
el.classList.toggle('collab-locked', !!foreign);
|
||||
let badge = el.querySelector('.collab-lock-badge');
|
||||
if (foreign) {
|
||||
if (!badge) {
|
||||
badge = document.createElement('div');
|
||||
badge.className = 'collab-lock-badge';
|
||||
badge.innerHTML = '<i class="ph ph-lock-simple text-xs"></i>';
|
||||
el.appendChild(badge);
|
||||
}
|
||||
badge.title = `Being edited by ${lock.holder_username}`;
|
||||
} else if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-decorate whenever the board re-renders (reuses the existing #board
|
||||
// MutationObserver pattern).
|
||||
function setupBoardObserver() {
|
||||
const board = document.getElementById('board');
|
||||
if (!board) return;
|
||||
const obs = new MutationObserver(() => decorateBoardLocks());
|
||||
obs.observe(board, { childList: true, subtree: false });
|
||||
}
|
||||
|
||||
// ── Phase 4: heartbeat the lock while editing a collab card ───────
|
||||
|
||||
/**
|
||||
* Resolve a card to its collab bucket_uid + relay_uid. Returns null for
|
||||
* non-collab cards (which don't get a lock).
|
||||
*/
|
||||
function collabIdentityOf(card) {
|
||||
if (!card || !card.bucket_id) return null;
|
||||
const bucket = window.state?.buckets?.find(b => String(b.id) === String(card.bucket_id));
|
||||
if (!bucket?.collab_uid) return null;
|
||||
if (!card.relay_uid) return null; // not yet pushed → no lock
|
||||
return { bucket_uid: bucket.collab_uid, card_uid: card.relay_uid };
|
||||
}
|
||||
|
||||
async function acquireLock(bucket_uid, card_uid) {
|
||||
try {
|
||||
await hyprcrd.api('api/collab-locks', {
|
||||
method: 'POST',
|
||||
body: { bucket_uid, card_uid },
|
||||
});
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function releaseLock(bucket_uid, card_uid) {
|
||||
try {
|
||||
await hyprcrd.api(
|
||||
'api/collab-locks/' + encodeURIComponent(bucket_uid) + '/' + encodeURIComponent(card_uid),
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
function startHeartbeat(bucket_uid, card_uid) {
|
||||
stopHeartbeat();
|
||||
activeLock = { bucket_uid, card_uid, timer: null };
|
||||
acquireLock(bucket_uid, card_uid);
|
||||
activeLock.timer = setInterval(() => acquireLock(bucket_uid, card_uid), LOCK_HEARTBEAT_MS);
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (!activeLock) return;
|
||||
const { bucket_uid, card_uid, timer } = activeLock;
|
||||
if (timer) clearInterval(timer);
|
||||
releaseLock(bucket_uid, card_uid);
|
||||
activeLock = null;
|
||||
}
|
||||
|
||||
window.addEventListener('hyprcrd:editor-open', (e) => {
|
||||
const card = e.detail?.card;
|
||||
const id = collabIdentityOf(card);
|
||||
if (id) startHeartbeat(id.bucket_uid, id.card_uid);
|
||||
});
|
||||
window.addEventListener('hyprcrd:editor-close', () => {
|
||||
stopHeartbeat();
|
||||
});
|
||||
// Graceful release on tab close
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (activeLock) {
|
||||
// Best-effort sync release; browsers may block regular fetch, so
|
||||
// use sendBeacon when available.
|
||||
try {
|
||||
if (navigator.sendBeacon) {
|
||||
const url = 'api/collab-locks/' + encodeURIComponent(activeLock.bucket_uid) + '/' + encodeURIComponent(activeLock.card_uid);
|
||||
// sendBeacon only supports POST; use fetch keepalive as the
|
||||
// DELETE fallback. Both are best-effort at unload time.
|
||||
fetch(url, { method: 'DELETE', keepalive: true }).catch(() => {});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────
|
||||
|
||||
async function init() {
|
||||
// Wait for #bucket-bar to exist (core renders it on startup)
|
||||
if (!document.getElementById('bucket-bar')) {
|
||||
setTimeout(init, 100);
|
||||
return;
|
||||
}
|
||||
setupBarObserver();
|
||||
setupClickInterceptor();
|
||||
setupBoardObserver();
|
||||
await loadCollabMeta();
|
||||
await pollOnce();
|
||||
await loadCollabMeta();
|
||||
decorateBucketBar();
|
||||
decorateBoardLocks();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
hyprcrd.registerPlugin?.({
|
||||
name: 'collab-buckets',
|
||||
version: '1.1.0',
|
||||
description: 'Collaborative buckets shared via the relay. First-class rows in the bucket bar with unread pulse/dot.',
|
||||
});
|
||||
Reference in new issue
Block a user