Typing a name and hitting Enter did nothing — users had to move to the Create button. Wire Enter on the name input to click the submit path so the common flow is keyboard-only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1012 lines
46 KiB
JavaScript
1012 lines
46 KiB
JavaScript
// ── 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 on every `sync.tick` (shared timer
|
|
// in sync.js). 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();
|
|
// card_id (number) -> true for cards with foreign edits we haven't opened yet
|
|
let unreadCards = new Set();
|
|
// Collab card sync is driven by the shared `sync.tick` hook from
|
|
// sync.js — one timer fans out to every plugin poller, the central
|
|
// timer honors the Autosync pause for us.
|
|
|
|
// Phase 4: edit locks (now exclusive — server-enforced)
|
|
// card_uid -> { bucket_uid, holder_username, expires_at }
|
|
let collabLocks = new Map();
|
|
// Tracks the editor session: { bucket_uid, card_uid, cardId, hbTimer,
|
|
// inputCleanup, acquired, takeover }
|
|
let editorSession = null;
|
|
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 */ }
|
|
}
|
|
|
|
let pollLocked = false;
|
|
|
|
async function pollOnce() {
|
|
if (pollLocked) return;
|
|
// Don't poll while user is actively editing — the push would race
|
|
// with the in-progress save and corrupt card state.
|
|
if (document.getElementById('modal')?.classList.contains('open')) return;
|
|
try {
|
|
pollLocked = true;
|
|
hyprcrd.hooks?.emit?.('sync.pulse');
|
|
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
|
|
if (!res?.ok) return;
|
|
if (res.self) window._collabSelf = res.self;
|
|
// 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.`);
|
|
}
|
|
// Per-card unread list: cards with foreign edits we haven't opened
|
|
// yet. Decorated on the board via decorateBoardUnread().
|
|
if (Array.isArray(res.unread_cards)) {
|
|
unreadCards = new Set(res.unread_cards.map(Number));
|
|
decorateBoardUnread();
|
|
}
|
|
// 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();
|
|
// If the editor is currently open on a card that just got
|
|
// locked by someone else, take over immediately. Covers the
|
|
// race where two users started typing nearly simultaneously
|
|
// and the relay arbitration only resolves on the next poll.
|
|
if (editorSession && !editorSession.takeover) {
|
|
const lock = collabLocks.get(editorSession.card_uid);
|
|
const self = window._collabSelf || null;
|
|
if (lock && lock.holder_username && lock.holder_username !== self) {
|
|
enterEditorTakeover(lock.holder_username);
|
|
}
|
|
}
|
|
}
|
|
// The push phase couldn't deliver because someone else holds an
|
|
// exclusive lock on a dirty card. Surface so the user knows their
|
|
// edit isn't lost — it's just queued.
|
|
const blocked = res.card_stats?.blocked || 0;
|
|
if (blocked > 0) {
|
|
const by = Array.isArray(res.card_stats?.blocked_by) ? res.card_stats.blocked_by : [];
|
|
showBlockedToast(by);
|
|
}
|
|
} catch { /* silent */ } finally { pollLocked = false; }
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Subscribe to the global sync heartbeat. The central timer in sync.js
|
|
// honors the Autosync pause + the configurable interval for us, and
|
|
// the header Sync button emits the same tick so manual sync pushes
|
|
// collab cards too.
|
|
hyprcrd.hooks?.on?.('sync.tick', () => { pollOnce().catch(() => {}); });
|
|
|
|
// ── 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)';
|
|
}
|
|
});
|
|
|
|
// Enter in the name field = Create (users expect the submit path
|
|
// without reaching for the mouse). Stop propagation so nothing
|
|
// upstream grabs it. No-op if name is empty — the submit button's
|
|
// own handler validates.
|
|
overlay.querySelector('#__cb_name').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
overlay.querySelector('.collab-btn-submit').click();
|
|
}
|
|
});
|
|
|
|
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';
|
|
el.appendChild(badge);
|
|
}
|
|
badge.innerHTML = `
|
|
<i class="ph ph-lock-simple-fill"></i>
|
|
<span>${escapeHtml(lock.holder_username)}</span>
|
|
`;
|
|
badge.title = `Locked by ${lock.holder_username}`;
|
|
} else if (badge) {
|
|
badge.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Walk every rendered board card and add / remove the "red dot" unread
|
|
* indicator for cards with foreign edits we haven't opened yet.
|
|
* Idempotent — safe to re-run on every board re-render.
|
|
*/
|
|
function decorateBoardUnread() {
|
|
const board = document.getElementById('board');
|
|
if (!board) return;
|
|
for (const el of board.querySelectorAll('.board-card[data-id]')) {
|
|
const cid = parseInt(el.dataset.id);
|
|
const isUnread = unreadCards.has(cid);
|
|
el.classList.toggle('collab-card-unread', isUnread);
|
|
let dot = el.querySelector('.collab-card-dot');
|
|
if (isUnread && !dot) {
|
|
dot = document.createElement('span');
|
|
dot.className = 'collab-card-dot';
|
|
el.appendChild(dot);
|
|
} else if (!isUnread && dot) {
|
|
dot.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear a card's unread state locally + on the backend. Called from
|
|
// - board click (piggy-backing on the natural open-card interaction)
|
|
// - detail.opened hook (when the detail view opens via any path:
|
|
// keyboard shortcut, related-card link, backlink, search result, etc.)
|
|
function clearCardUnread(cid) {
|
|
cid = parseInt(cid);
|
|
if (!cid || !unreadCards.has(cid)) return;
|
|
unreadCards.delete(cid);
|
|
const el = document.querySelector(`#board .board-card[data-id="${cid}"]`);
|
|
if (el) {
|
|
el.classList.remove('collab-card-unread');
|
|
el.querySelector('.collab-card-dot')?.remove();
|
|
}
|
|
hyprcrd.api('api/collab-buckets/mark-card-seen', {
|
|
method: 'POST', body: { card_id: cid },
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function setupCardClickInterceptor() {
|
|
const board = document.getElementById('board');
|
|
if (board) {
|
|
board.addEventListener('click', (e) => {
|
|
const el = e.target.closest('.board-card[data-id]');
|
|
if (el) clearCardUnread(el.dataset.id);
|
|
});
|
|
}
|
|
// Detail view has its own open path (keyboard shortcuts, backlinks,
|
|
// related-card navigation) that doesn't route through a board-card
|
|
// click. Listen for the explicit detail.opened hook so every open
|
|
// method clears the dot.
|
|
hyprcrd.hooks?.on?.('detail.opened', (data) => clearCardUnread(data?.id));
|
|
}
|
|
|
|
// 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();
|
|
decorateBoardUnread();
|
|
});
|
|
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 };
|
|
}
|
|
|
|
/** Acquire (or refresh) the exclusive lock. Returns:
|
|
* { ok: true } on success
|
|
* { ok: false, locked_by: '…' } when another tenant already holds it (409)
|
|
* { ok: false } on transport / unknown error */
|
|
async function tryAcquireLock(bucket_uid, card_uid) {
|
|
try {
|
|
const res = await hyprcrd.api('api/collab-locks', {
|
|
method: 'POST',
|
|
body: { bucket_uid, card_uid },
|
|
});
|
|
if (res?.ok) return { ok: true };
|
|
if (res?.locked_by) return { ok: false, locked_by: res.locked_by };
|
|
return { ok: false };
|
|
} catch {
|
|
return { ok: false };
|
|
}
|
|
}
|
|
|
|
async function releaseLock(bucket_uid, card_uid) {
|
|
try {
|
|
await hyprcrd.api(
|
|
'api/collab-locks/' + encodeURIComponent(bucket_uid) + '/' + encodeURIComponent(card_uid),
|
|
{ method: 'DELETE' },
|
|
);
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
/** First-keystroke lock: sets up one-shot input listeners on the editor
|
|
* inputs. The first input event tries to acquire the exclusive lock; on
|
|
* success we start the heartbeat, on failure we slap the editor into
|
|
* takeover mode. */
|
|
function armFirstKeystrokeLock() {
|
|
if (!editorSession) return;
|
|
const inputs = [document.getElementById('inp-title'), document.getElementById('editor-body')].filter(Boolean);
|
|
let fired = false;
|
|
const onInput = async () => {
|
|
if (fired) return;
|
|
fired = true;
|
|
// Drop the listeners — heartbeat or takeover takes over from here
|
|
for (const el of inputs) el.removeEventListener('input', onInput);
|
|
if (!editorSession) return;
|
|
const { bucket_uid, card_uid } = editorSession;
|
|
const res = await tryAcquireLock(bucket_uid, card_uid);
|
|
if (!editorSession) return; // editor closed during the await
|
|
if (res.ok) {
|
|
editorSession.acquired = true;
|
|
editorSession.hbTimer = setInterval(() => tryAcquireLock(bucket_uid, card_uid), LOCK_HEARTBEAT_MS);
|
|
} else {
|
|
enterEditorTakeover(res.locked_by || 'someone');
|
|
}
|
|
};
|
|
for (const el of inputs) el.addEventListener('input', onInput);
|
|
editorSession.inputCleanup = () => {
|
|
for (const el of inputs) el.removeEventListener('input', onInput);
|
|
};
|
|
}
|
|
|
|
/** Switch the editor to read-only takeover mode. The user can still see /
|
|
* copy their text but the inputs are disabled and save is blocked. */
|
|
function enterEditorTakeover(holder) {
|
|
if (!editorSession || editorSession.takeover) return;
|
|
editorSession.takeover = true;
|
|
const modal = document.getElementById('modal');
|
|
const title = document.getElementById('inp-title');
|
|
const body = document.getElementById('editor-body');
|
|
if (title) title.disabled = true;
|
|
if (body) body.disabled = true;
|
|
modal?.classList.add('collab-readonly-modal');
|
|
|
|
// Block save buttons by capturing clicks before the editor handler.
|
|
// stopImmediatePropagation also blocks any sibling listeners registered
|
|
// on the same element (the editor's own click handler) and skips both
|
|
// target + bubble phases for ancestors.
|
|
const saveIds = ['modal-save', 'modal-save-view', 'modal-save-close'];
|
|
const blockers = saveIds.map(id => {
|
|
const btn = document.getElementById(id);
|
|
if (!btn) return null;
|
|
const handler = (e) => {
|
|
e.stopImmediatePropagation();
|
|
e.preventDefault();
|
|
};
|
|
btn.addEventListener('click', handler, true); // capture phase
|
|
btn.setAttribute('disabled', 'disabled');
|
|
btn.classList.add('collab-save-blocked');
|
|
return { btn, handler };
|
|
}).filter(Boolean);
|
|
editorSession.saveBlockers = blockers;
|
|
|
|
// Also intercept Ctrl/Cmd+S — app.js binds this directly to saveCard/
|
|
// saveOnly without going through the buttons we just disabled.
|
|
const keyBlocker = (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', keyBlocker, true);
|
|
editorSession.keyBlocker = keyBlocker;
|
|
|
|
// Banner inside the modal explaining the situation
|
|
let banner = document.getElementById('collab-takeover-banner');
|
|
if (!banner) {
|
|
banner = document.createElement('div');
|
|
banner.id = 'collab-takeover-banner';
|
|
banner.className = 'collab-takeover-banner';
|
|
const inner = document.querySelector('#modal .modal-inner') || document.querySelector('#modal > div') || modal;
|
|
inner?.prepend(banner);
|
|
}
|
|
banner.innerHTML = `
|
|
<i class="ph ph-lock-simple-fill"></i>
|
|
<span><b>Locked by ${escapeHtml(holder)}.</b> This card was locked by another user while you were typing. Copy your text out — saving is disabled.</span>
|
|
`;
|
|
|
|
// Also show a one-off toast so the user can't miss it
|
|
showLockToast(holder);
|
|
}
|
|
|
|
function exitEditorTakeover() {
|
|
const modal = document.getElementById('modal');
|
|
const title = document.getElementById('inp-title');
|
|
const body = document.getElementById('editor-body');
|
|
if (title) title.disabled = false;
|
|
if (body) body.disabled = false;
|
|
modal?.classList.remove('collab-readonly-modal');
|
|
document.getElementById('collab-takeover-banner')?.remove();
|
|
if (editorSession?.saveBlockers) {
|
|
for (const { btn, handler } of editorSession.saveBlockers) {
|
|
btn.removeEventListener('click', handler, true);
|
|
btn.removeAttribute('disabled');
|
|
btn.classList.remove('collab-save-blocked');
|
|
}
|
|
editorSession.saveBlockers = null;
|
|
}
|
|
if (editorSession?.keyBlocker) {
|
|
window.removeEventListener('keydown', editorSession.keyBlocker, true);
|
|
editorSession.keyBlocker = null;
|
|
}
|
|
}
|
|
|
|
function showLockToast(holder) {
|
|
const existing = document.getElementById('collab-lock-toast');
|
|
if (existing) existing.remove();
|
|
const toast = document.createElement('div');
|
|
toast.id = 'collab-lock-toast';
|
|
toast.className = 'collab-conflict-toast collab-lock-toast';
|
|
toast.innerHTML = `
|
|
<i class="ph ph-lock-simple-fill"></i>
|
|
<span><b>Locked by ${escapeHtml(holder)}.</b> Copy your text — saving is blocked.</span>
|
|
<button class="collab-conflict-close" aria-label="dismiss">✕</button>
|
|
`;
|
|
toast.querySelector('.collab-conflict-close').addEventListener('click', () => {
|
|
toast.classList.add('fade-out');
|
|
setTimeout(() => toast.remove(), 400);
|
|
});
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 8000);
|
|
}
|
|
|
|
function showBlockedToast(by) {
|
|
const existing = document.getElementById('collab-blocked-toast');
|
|
if (existing) existing.remove();
|
|
const toast = document.createElement('div');
|
|
toast.id = 'collab-blocked-toast';
|
|
toast.className = 'collab-conflict-toast collab-lock-toast';
|
|
const who = by.length ? by.join(', ') : 'another user';
|
|
toast.innerHTML = `
|
|
<i class="ph ph-lock-simple-fill"></i>
|
|
<span>Your edit is queued — <b>${escapeHtml(who)}</b> is currently editing. It'll sync when they're done.</span>
|
|
<button class="collab-conflict-close" aria-label="dismiss">✕</button>
|
|
`;
|
|
toast.querySelector('.collab-conflict-close').addEventListener('click', () => {
|
|
toast.classList.add('fade-out');
|
|
setTimeout(() => toast.remove(), 400);
|
|
});
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => { toast.classList.add('fade-out'); setTimeout(() => toast.remove(), 400); }, 6000);
|
|
}
|
|
|
|
/** Tear down everything we own for the active editor session. Called both
|
|
* on editor-close and on plugin reload. */
|
|
function endEditorSession() {
|
|
if (!editorSession) return;
|
|
const { hbTimer, inputCleanup, acquired, bucket_uid, card_uid } = editorSession;
|
|
if (hbTimer) clearInterval(hbTimer);
|
|
if (typeof inputCleanup === 'function') inputCleanup();
|
|
if (acquired) releaseLock(bucket_uid, card_uid);
|
|
exitEditorTakeover();
|
|
editorSession = null;
|
|
}
|
|
|
|
window.addEventListener('hyprcrd:editor-open', (e) => {
|
|
endEditorSession();
|
|
const card = e.detail?.card;
|
|
const id = collabIdentityOf(card);
|
|
if (!id) return;
|
|
editorSession = {
|
|
bucket_uid: id.bucket_uid,
|
|
card_uid: id.card_uid,
|
|
cardId: card?.id,
|
|
hbTimer: null,
|
|
inputCleanup: null,
|
|
acquired: false,
|
|
takeover: false,
|
|
saveBlockers: null,
|
|
};
|
|
// If a foreign lock is already known from the last poll, take over
|
|
// immediately — no point letting the user type into a doomed editor.
|
|
const existing = collabLocks.get(id.card_uid);
|
|
const self = window._collabSelf || null;
|
|
if (existing && existing.holder_username && existing.holder_username !== self) {
|
|
enterEditorTakeover(existing.holder_username);
|
|
return;
|
|
}
|
|
armFirstKeystrokeLock();
|
|
});
|
|
window.addEventListener('hyprcrd:editor-close', () => {
|
|
endEditorSession();
|
|
});
|
|
// Graceful release on tab close
|
|
window.addEventListener('beforeunload', () => {
|
|
if (editorSession?.acquired) {
|
|
try {
|
|
const url = 'api/collab-locks/' + encodeURIComponent(editorSession.bucket_uid) + '/' + encodeURIComponent(editorSession.card_uid);
|
|
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();
|
|
setupCardClickInterceptor();
|
|
setupBoardObserver();
|
|
await loadCollabMeta();
|
|
await pollOnce();
|
|
await loadCollabMeta();
|
|
decorateBucketBar();
|
|
decorateBoardLocks();
|
|
decorateBoardUnread();
|
|
// No local timer: subsequent polls come from the global `sync.tick`
|
|
// hook (central timer in sync.js honors Autosync pause + interval).
|
|
}
|
|
|
|
// Defer init until the CSRF token is live — `checkAuth()` sets it async on
|
|
// boot, and firing POST /api/collab-buckets/poll before that racing lookup
|
|
// resolves returns 403. If the token is already in the util state when we
|
|
// load (e.g. fast reload, cached session), kick off init straight away.
|
|
function bootWhenCsrfReady() {
|
|
if (hyprcrd?.util?.csrf?.()) { init(); return; }
|
|
hyprcrd?.hooks?.on?.('csrf.ready', () => init());
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', bootWhenCsrfReady, { once: true });
|
|
} else {
|
|
bootWhenCsrfReady();
|
|
}
|
|
|
|
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.',
|
|
});
|