// ── 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, '''); } // ── 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 = ` ${n} local change${n === 1 ? '' : 's'} overwritten by a collaborator — backup in the Conflicts bucket. `; 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 = ` ${escapeHtml(message)} `; 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 = `
new bucket
`; 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 = '
could not reach the relay — uncheck collaborative to create a local bucket
'; return; } const self = relay.self || ''; peers = (relay.tenants || []).filter(t => (t.username || t) !== self); if (!peers.length) { membersList.innerHTML = '
no other relay users
'; return; } membersList.innerHTML = peers.map(t => { const u = typeof t === 'string' ? t : (t.username || ''); return ``; }).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 = `
${escapeHtml(bucket.name)} — members
Owner ${isOwner ? `
Transferring will hand over all edit rights. You stay a member.
` : `
${escapeHtml(bucket.owner_username)}
` }
Members (${peers.length} available) ${isOwner ? `
${peers.map(u => { const checked = currentMembers.has(u) ? 'checked' : ''; return ``; }).join('') || '
no other relay users
'}
` : `
${(bucket.members || []).map(u => `
${escapeHtml(u)}${u === self ? ' (you)' : ''}
`).join('')}
Only the owner can change members or transfer ownership.
` }
`; 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 = `
make "${escapeHtml(bucket.name)}" collaborative
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.
Members (${peers.length} available) ${peers.length === 0 ? '
no other relay users
' : `
${peers.map(u => ``).join('')}
` }
`; 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 = ''; 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.', });