diff --git a/public/index.php b/public/index.php
index abf573b..c469add 100644
--- a/public/index.php
+++ b/public/index.php
@@ -341,19 +341,38 @@ if (str_starts_with($route, '/api/')) {
$input = json_decode(file_get_contents('php://input'), true);
$name = trim($input['name'] ?? '');
if ($name === '') { http_response_code(400); echo json_encode(['error' => 'name is required']); exit; }
+ // Normal POST creates a local bucket. Collab buckets go through
+ // /api/collab-buckets (plugin-owned) and can't be created here.
$db->prepare('INSERT INTO buckets (name) VALUES (:name)')->execute(['name' => $name]);
echo json_encode($db->query('SELECT * FROM buckets WHERE id = ' . $db->lastInsertId())->fetch()); exit;
}
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'PATCH') {
$input = json_decode(file_get_contents('php://input'), true);
+ // Collab buckets: name and color must go through the relay plugin so
+ // all members see the update. Only position (local reorder) is allowed.
+ $b = $db->prepare('SELECT collab_uid FROM buckets WHERE id = :id');
+ $b->execute(['id' => $m[1]]);
+ $existing = $b->fetch();
+ $isCollab = $existing && !empty($existing['collab_uid']);
+ $allowed = $isCollab ? ['position'] : ['name', 'position', 'color'];
$fields = []; $params = ['id' => $m[1]];
- foreach (['name', 'position', 'color'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } }
+ foreach ($allowed as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $f === 'color' ? validColor($input[$f]) : $input[$f]; } }
if ($fields) $db->prepare('UPDATE buckets SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params);
$b = $db->prepare('SELECT * FROM buckets WHERE id = :id'); $b->execute(['id' => $m[1]]); echo json_encode($b->fetch()); exit;
}
if (preg_match('#^/api/buckets/(\d+)$#', $route, $m) && $method === 'DELETE') {
+ // Collab buckets must go through /api/collab-buckets/{uid} so the relay
+ // and plugin mirror stay consistent. Refuse deletion here.
+ $b = $db->prepare('SELECT collab_uid FROM buckets WHERE id = :id');
+ $b->execute(['id' => $m[1]]);
+ $existing = $b->fetch();
+ if ($existing && !empty($existing['collab_uid'])) {
+ http_response_code(400);
+ echo json_encode(['error' => 'collab buckets must be deleted via /api/collab-buckets/' . $existing['collab_uid']]);
+ exit;
+ }
$db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $m[1]]);
$db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $m[1]]);
echo json_encode(['ok' => true]); exit;
@@ -625,8 +644,14 @@ if (str_starts_with($route, '/api/')) {
$found = $existing->fetchColumn();
if ($found) { $bucketMap[$oldId] = $found; continue; }
}
- $stmt = $db->prepare('INSERT INTO buckets (name, position, created_at) VALUES (:name, :position, :created_at)');
- $stmt->execute(['name' => $b['name'], 'position' => $b['position'] ?? 0, 'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s')]);
+ $stmt = $db->prepare('INSERT INTO buckets (name, position, color, collab_uid, created_at) VALUES (:name, :position, :color, :collab_uid, :created_at)');
+ $stmt->execute([
+ 'name' => $b['name'],
+ 'position' => $b['position'] ?? 0,
+ 'color' => $b['color'] ?? '#64748b',
+ 'collab_uid' => $b['collab_uid'] ?? null,
+ 'created_at' => $b['created_at'] ?? date('Y-m-d H:i:s'),
+ ]);
$bucketMap[$oldId] = $db->lastInsertId();
}
diff --git a/public/js/app.js b/public/js/app.js
index f3c441d..8de42ad 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -39,6 +39,9 @@ window._getSelectedIds = getSelectedIds;
window._clearSelection = clearSelection;
window._refreshTasks = refreshTasksView;
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
+// Exposed for plugins that need to read the shared state (e.g. collab-buckets
+// reads `state.buckets` in its MutationObserver to find collab_uid mapping).
+window.state = state;
window._extractToCard = async (text) => {
if (!text?.trim()) return;
const lines = text.trim().split('\n');
diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js
index 6cd44a0..fa24cbe 100644
--- a/public/js/modules/buckets.js
+++ b/public/js/modules/buckets.js
@@ -3,6 +3,7 @@
import { $, esc, api } from './util.js';
import { state, CARD_COLORS } from './state.js';
import { showDialog } from './dialog.js';
+import { pushUndo } from './undo.js';
// ── Bucket context menu ──
@@ -24,9 +25,18 @@ async function showBucketCtx(x, y, bucket) {
const canRight = idx < state.buckets.length - 1;
const hasCards = state.cards.some(c => c.bucket_id == bucket.id);
+ const isCollab = !!bucket.collab_uid;
ctxMenu.innerHTML = `
+ ${isCollab && typeof window._collabBucketsEditDialog === 'function'
+ ? ``
+ : ''
+ }
+ ${!isCollab && typeof window._collabBucketsUpgradeDialog === 'function'
+ ? ``
+ : ''
+ }
@@ -56,12 +66,35 @@ async function showBucketCtx(x, y, bucket) {
document.getElementById('bctx-colors').classList.toggle('hidden');
return;
}
+ else if (a === 'collab-manage') {
+ hideBucketCtx();
+ if (typeof window._collabBucketsEditDialog === 'function') {
+ const changed = await window._collabBucketsEditDialog(bucket.collab_uid);
+ if (changed) await window._reloadAll();
+ }
+ return;
+ }
+ else if (a === 'collab-upgrade') {
+ hideBucketCtx();
+ if (typeof window._collabBucketsUpgradeDialog === 'function') {
+ const changed = await window._collabBucketsUpgradeDialog(bucket.id);
+ if (changed) await window._reloadAll();
+ }
+ return;
+ }
else if (a === 'rename') {
hideBucketCtx();
const name = await showDialog({ title: 'rename_bucket', input: true, placeholder: bucket.name });
if (!name || !name.trim()) return;
- await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } });
- await loadBuckets(); window._renderBoard();
+ // Collab buckets: push name change through the plugin (relay +
+ // mirror). The next poll pulls the new name into the local row.
+ if (bucket.collab_uid && typeof window._collabBucketsPatch === 'function') {
+ const res = await window._collabBucketsPatch(bucket.collab_uid, { name: name.trim() });
+ if (!res?.ok) { alert(res?.error || 'rename failed'); return; }
+ } else {
+ await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { name: name.trim() } });
+ }
+ await window._reloadAll();
}
else if (a === 'move-left' || a === 'move-right') {
hideBucketCtx();
@@ -76,12 +109,37 @@ async function showBucketCtx(x, y, bucket) {
hideBucketCtx();
const cards = state.cards.filter(c => c.bucket_id == bucket.id);
if (cards.length > 0 || localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
- const ok = await showDialog({ title: 'delete_bucket', message: cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`, danger: true, okLabel: 'Delete' });
+ const msg = bucket.collab_uid
+ ? (cards.length ? `Leave / delete "${bucket.name}"? ${cards.length} card(s) will be kept locally.` : `Leave / delete "${bucket.name}"?`)
+ : (cards.length ? `Delete "${bucket.name}"? ${cards.length} card(s) will be kept.` : `Delete "${bucket.name}"?`);
+ const ok = await showDialog({ title: 'delete_bucket', message: msg, danger: true, okLabel: bucket.collab_uid ? 'Leave / delete' : 'Delete' });
if (!ok) return;
}
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
- await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
- await loadBuckets(); await window._reloadAll();
+ // Collab bucket: route through plugin so the relay is notified
+ // (owner = delete for everyone; member = just leave).
+ if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
+ const res = await window._collabBucketsDelete(bucket.collab_uid);
+ if (!res?.ok) { alert(res?.error || 'delete failed'); return; }
+ // Undo is intentionally NOT wired for collab deletes/leaves
+ // — restoring would require re-creating on the relay and
+ // re-attaching cards under a new uid. Too messy for MVP.
+ } else {
+ // Snapshot the row + affected card ids before deleting so
+ // the undo entry can restore them.
+ const snapshot = { name: bucket.name, color: bucket.color, position: bucket.position };
+ const affected = cards.map(c => c.id);
+ await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
+ pushUndo(`delete bucket "${bucket.name}"`, async () => {
+ const created = await api('api/buckets', { method: 'POST', body: { name: snapshot.name } });
+ if (created?.id) {
+ await api(`api/buckets/${created.id}`, { method: 'PATCH', body: { color: snapshot.color, position: snapshot.position } });
+ // Re-attach cards to the recreated bucket
+ await Promise.all(affected.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id } })));
+ }
+ });
+ }
+ await window._reloadAll();
}
else if (a === 'delete-all') {
hideBucketCtx();
@@ -90,8 +148,13 @@ async function showBucketCtx(x, y, bucket) {
if (!ok) return;
for (const c of cards) await api(`api/cards/${c.id}`, { method: 'DELETE' });
if (state.activeBucket == bucket.id) state.activeBucket = 'all';
- await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
- await loadBuckets(); await window._reloadAll();
+ if (bucket.collab_uid && typeof window._collabBucketsDelete === 'function') {
+ const res = await window._collabBucketsDelete(bucket.collab_uid);
+ if (!res?.ok) { alert(res?.error || 'delete failed'); return; }
+ } else {
+ await api(`api/buckets/${bucket.id}`, { method: 'DELETE' });
+ }
+ await window._reloadAll();
}
}));
@@ -99,9 +162,14 @@ async function showBucketCtx(x, y, bucket) {
ctxMenu.querySelectorAll('[data-bctx-color]').forEach(btn => btn.addEventListener('click', async e => {
e.stopPropagation();
hideBucketCtx();
- await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: btn.dataset.bctxColor } });
- await loadBuckets();
- window._renderBoard();
+ const newColor = btn.dataset.bctxColor;
+ if (bucket.collab_uid && typeof window._collabBucketsPatch === 'function') {
+ const res = await window._collabBucketsPatch(bucket.collab_uid, { color: newColor });
+ if (!res?.ok) { alert(res?.error || 'color change failed'); return; }
+ } else {
+ await api(`api/buckets/${bucket.id}`, { method: 'PATCH', body: { color: newColor } });
+ }
+ await window._reloadAll();
}));
}
@@ -256,6 +324,14 @@ function renderBar() {
const addBtn = $('add-bucket-btn');
addBtn?.addEventListener('click', async () => {
+ // Plugins (e.g. collab-buckets) can override the default create
+ // dialog by exposing window._collabBucketsCreateDialog. It returns
+ // true when a bucket was created, in which case we reload.
+ if (typeof window._collabBucketsCreateDialog === 'function') {
+ const created = await window._collabBucketsCreateDialog();
+ if (created) { await loadBuckets(); window._renderBoard(); }
+ return;
+ }
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
if (!name || !name.trim()) return;
await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js
index 6add5d4..16c74e0 100644
--- a/public/js/modules/editor.js
+++ b/public/js/modules/editor.js
@@ -200,6 +200,8 @@ export function openModal(card = null) {
currentLine = -1;
updateActiveLine();
inpTitle.focus();
+ // Plugins (collab-buckets) hook into this to heartbeat an edit lock
+ window.dispatchEvent(new CustomEvent('hyprcrd:editor-open', { detail: { card } }));
}
inpTitle.addEventListener('input', updatePreview);
@@ -354,6 +356,7 @@ body.addEventListener('keyup', updateActiveLine);
window.addEventListener('resize', () => { if (linesVisible) updateGutter(); });
export function closeModal({ animate = true } = {}) {
+ const closingId = state.editingId;
if (animate) startCloseMorph();
else clearMorphGhost();
hideLinkAc();
@@ -366,6 +369,7 @@ export function closeModal({ animate = true } = {}) {
state._returnToCalendar = false;
window._openCalendar?.();
}
+ window.dispatchEvent(new CustomEvent('hyprcrd:editor-close', { detail: { cardId: closingId } }));
}
function updatePreview() {
diff --git a/public/plugins/collab-buckets/README.md b/public/plugins/collab-buckets/README.md
new file mode 100644
index 0000000..a447987
--- /dev/null
+++ b/public/plugins/collab-buckets/README.md
@@ -0,0 +1,358 @@
+# collab-buckets plugin
+
+Collaborative buckets shared between relay tenants. Each bucket has an owner
+and a set of members; membership changes and metadata updates propagate
+through the existing relay. Collab buckets are **first-class rows in the
+normal bucket bar** — the plugin just owns the sync plus an unread
+indicator (red dot + 3-cycle pulse) on any tab that has a fresh update.
+
+**Phase 1 through 5 shipped.** Metadata + membership sync, bucket-bar
+integration, full card sync (create / edit / delete, LWW conflict
+resolution with local Conflicts-bucket stashing, encrypted round-trip
+via the relay's decrypt-reencrypt hop), advisory per-card edit locks
+with a 🔒 badge on cards another user is currently editing,
+bucket-ownership transfer through a members & owner modal, and the
+Phase 5 hardening pass: **"Make collaborative…"** upgrade path for
+existing local buckets, **undo** for local bucket delete/leave,
+**resilient create** via a local outbox that queues intents when the
+relay is unreachable and retries on the next poll, and
+**rate-limiting** on all collab endpoints. Remaining nice-to-haves
+(position propagation, presence, read receipts, inline member picker,
+owner approval flow) are in `TODO.md`.
+
+---
+
+## What it does
+
+- **Create a collab bucket** from the normal `+ Bucket` button in the
+ bucket bar. The plugin intercepts the button click (via
+ `window._collabBucketsCreateDialog`) and shows a modal with an optional
+ "collaborative" checkbox. Unchecked → a regular local bucket, same as
+ before. Checked → reveals a member picker listing all other relay
+ tenants, and the submit path goes through `/api/collab-buckets` instead
+ of the core `/api/buckets`.
+- **See other members' buckets** appear automatically in the bucket bar.
+ Every 30 seconds the plugin polls the relay for a full resync; any new
+ or updated bucket the current user is a member of is mirrored into the
+ main `buckets` table, so the core bar renders it alongside everything
+ else.
+- **Collab tabs are visually distinguished**: the folder icon is swapped
+ for `ph-users-three`, there's a subtle left-edge accent stripe, and the
+ right-click menu's rename / color / delete actions route through the
+ plugin endpoints so changes reach the relay and other members.
+- **Unread state**: brand-new buckets and ones whose `updated_at` exceeds
+ the local `last_seen_at` show a red dot top-right of the tab and pulse 3
+ times on the next render. Clicking the tab triggers `mark-seen` which
+ clears the dot/pulse immediately and persists to the plugin backend.
+
+## Architecture
+
+Two places store bucket state locally:
+
+1. **Main `buckets` table** (core) — gains a `collab_uid` column so collab
+ buckets are indistinguishable from local buckets in the bucket bar,
+ bucket selects, card-to-bucket joins, etc. `collab_uid` is unique and
+ NULL for regular local buckets.
+2. **Plugin `collab_buckets` table** — holds collab-only metadata
+ (`members_json`, `owner_username`, `updated_at`, `last_seen_at`) that
+ the main table doesn't need. On every poll the plugin keeps both
+ tables in sync.
+
+```
+┌───── Browser ─────┐ ┌──── Relay ────┐
+│ │ │ │
+│ bucket-bar (core) │ │ │
+│ ├─ bucket rows │ POST /api/collab-buckets │ collab- │
+│ │ incl. collab │ (create via + Bucket modal) │ buckets.php │
+│ │ collab_uid │ ───────────────────────────▶ │ │
+│ │ │ │ collab_ │
+│ collab-buckets.js │ POST /api/collab-buckets │ buckets │
+│ ▲ observer │ /poll (every 30s) │ │
+│ │ MutationObs │ ◀─────────────────────────── │ collab_ │
+│ │ + delegation │ full resync │ bucket_ │
+│ ▼ │ │ members │
+│ backend.php │ │ │
+│ ├─ collab_buckets│ │ │
+│ │ plugin table │ │ │
+│ └─ upserts/ │ │ │
+│ deletes rows │ │ │
+│ in main │ │ │
+│ buckets table │ │ │
+└───────────────────┘ └───────────────┘
+```
+
+- **Relay is source of truth** for bucket metadata + membership. The two
+ local tables are mirrors that together add `last_seen_at` (unread state,
+ user-specific, never synced).
+- **Full resync every 30 s.** Locally cached buckets that are no longer
+ returned by the relay for this user = we were kicked (or the bucket was
+ deleted); both tables drop them and any cards referencing them fall back
+ to unsorted via `ON DELETE SET NULL`.
+- **LWW per bucket**: every patch bumps `updated_at` on the relay. The
+ local poll only re-saves rows whose `updated_at` actually changed,
+ otherwise it's a no-op and `last_seen_at` is untouched so unread state
+ survives unrelated polls.
+
+## Files
+
+| File | Purpose |
+|---|---|
+| `plugin.json` | Plugin manifest (auto-enabled unless listed in `data/plugins.json`) |
+| `backend.php` | Plugin backend: `collab_buckets` plugin table, relay proxy, full-resync poll, mark-seen, + `collabUpsertMainBucket` / `collabDeleteMainBucket` helpers that keep the core `buckets` table in sync |
+| `collab-buckets.js` | Frontend: polling loop, exposes `window._collabBucketsCreateDialog` for the core `+ Bucket` button, MutationObserver on `#bucket-bar` to decorate collab tabs with unread dot + pulse, delegated click handler for mark-seen, helpers for rename / color / delete that core's context menu calls for `collab_uid` buckets |
+| `collab-buckets.css` | Collab-tab decoration (left accent stripe, unread dot, pulse animation) + the create / edit modal styling |
+| `README.md` | This file |
+| `TODO.md` | Deferred features + Phase 3 roadmap |
+
+### Core-side touchpoints (not in this directory)
+
+| File | What changes |
+|---|---|
+| `src/Database.php` | Migration adds `buckets.collab_uid TEXT` + a unique partial index |
+| `public/index.php` | `api/buckets` GET returns `collab_uid` (via `SELECT *`). `PATCH` only allows `position` on collab buckets (name/color must go through the plugin so other members see them). `DELETE` refuses collab buckets with a 400 pointing to `/api/collab-buckets/{uid}` |
+| `public/js/app.js` | Exposes `window.state = state` so the plugin's observer can read `state.buckets` to resolve `collab_uid` per tab |
+| `public/js/modules/buckets.js` | `+ Bucket` click checks `window._collabBucketsCreateDialog` before falling back to the simple dialog. Rename / color / delete actions check `bucket.collab_uid` and route through `window._collabBucketsPatch` / `_collabBucketsDelete` when set |
+
+Relay-side files (not in this directory, part of the main relay deployment):
+
+| File | Purpose |
+|---|---|
+| `relay/inc/bootstrap.php` | Adds `collab_buckets` + `collab_bucket_members` tables (auto-migrated on first boot) |
+| `relay/inc/routes/collab-buckets.php` | `POST/GET/PATCH/DELETE /api/collab-buckets[/{uid}]` |
+| `relay/index.php` | `require`s the new route file after `chat.php` |
+
+## API (local)
+
+All routes live under `/api/collab-buckets` and are registered via
+`PluginManager::instance()->onApiRoute()`.
+
+| Method | Path | Purpose |
+|---|---|---|
+| `GET` | `/api/collab-buckets` | List local mirror. Each row has `has_unread` computed from `last_seen_at` vs `updated_at`. |
+| `POST` | `/api/collab-buckets` | `{name, color, members[]}` — generates UID, pushes to relay, mirrors local. The creator's own `last_seen_at = now` so the tile isn't unread for them. |
+| `PATCH` | `/api/collab-buckets/{uid}` | `{name?, color?, members?}` — owner-only, proxied to relay. Local row updates on next poll. |
+| `DELETE` | `/api/collab-buckets/{uid}` | Owner → delete; member → leave. Relay decides based on the authenticated caller. |
+| `POST` | `/api/collab-buckets/poll` | Full resync. Drops stale local rows, upserts changed remote rows, preserves `last_seen_at`. |
+| `POST` | `/api/collab-buckets/mark-seen` | `{uid}` — sets `last_seen_at = now` so the dot / pulse clear. |
+| `GET` | `/api/collab-buckets/relay-tenants` | Proxy to relay `/api/tenants` for the member-picker. |
+
+## API (relay)
+
+Exact endpoints the local plugin forwards to. Authentication: the standard
+`X-Relay-User` / `X-Relay-Key` client-API headers that the existing chat and
+messages routes also use.
+
+### Buckets
+
+| Method | Path | Behaviour |
+|---|---|---|
+| `POST` | `/api/collab-buckets` | `{uid, name, color, members: [usernames]}`. Unknown usernames are silently dropped; the owner is always added to the member list regardless of whether they included themselves. |
+| `GET` | `/api/collab-buckets` | Returns all buckets where the caller is a member (owner or otherwise). Each row carries its full member list as `members: [usernames]`. |
+| `PATCH` | `/api/collab-buckets/{uid}` | Owner-only. Member-list replacement is total — pass the full desired list. |
+| `DELETE` | `/api/collab-buckets/{uid}` | Owner → `DELETE` of bucket + memberships (with `action: deleted`). Member → just that one membership row (with `action: left`); the bucket's `updated_at` is bumped so the owner sees the change on next poll. |
+
+### Cards (Phase 3)
+
+| Method | Path | Behaviour |
+|---|---|---|
+| `POST` | `/api/collab-cards` | `{bucket_uid, card_uid, payload}` — upsert. `payload` is a `HYPRCRD-RELAY-V1` envelope encrypted with the sender's client secret. The relay decrypts it with the sender's secret (via `masterDecrypt` of their stored `secret_encrypted`), re-encrypts with the inter-tenant key, stores. Server generates `updated_at` so all clients agree on the authoritative LWW timestamp. Membership-guarded. |
+| `GET` | `/api/collab-cards?since=` | Pull cards updated since `` (ISO string from a previous response's `max_updated_at`) across all buckets the caller is a member of. Each row is re-encrypted under the caller's client secret on the way out. Tombstoned rows return with `deleted: true` and no payload. Hard-capped to 1000 cards per response. |
+| `DELETE` | `/api/collab-cards/{bucket_uid}/{card_uid}` | Mark tombstone (not hard-delete) so late pollers still observe the deletion within the 7-day TTL. Membership-guarded. |
+
+## Schema (local)
+
+```sql
+CREATE TABLE collab_buckets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ uid TEXT UNIQUE NOT NULL, -- 16-hex string generated by this client
+ name TEXT NOT NULL,
+ color TEXT NOT NULL DEFAULT '#38bdf8',
+ owner_username TEXT NOT NULL,
+ members_json TEXT NOT NULL DEFAULT '[]',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_seen_at TEXT -- NULL = never opened → unread
+);
+
+-- Phase 3: per-card sync state. Attached to the core `cards` table via
+-- card_id FK. `last_push_at` mirrors the updated_at we last sent to the
+-- relay; the push phase picks up cards where c.updated_at > last_push_at.
+-- `last_remote_at` mirrors the updated_at we last applied from the relay.
+CREATE TABLE collab_card_state (
+ card_id INTEGER PRIMARY KEY,
+ relay_uid TEXT NOT NULL,
+ bucket_uid TEXT NOT NULL,
+ last_push_at TEXT,
+ last_remote_at TEXT,
+ FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
+);
+
+-- Phase 3: deletion outbox. Populated by a BEFORE DELETE trigger on the
+-- cards table; drained in the push phase of the next poll.
+CREATE TABLE collab_tombstones (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ relay_uid TEXT NOT NULL,
+ bucket_uid TEXT NOT NULL,
+ queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+```
+
+`members_json` is a JSON array of usernames — flat and cheap to render.
+Because the relay is the source of truth, we don't need to normalise
+members into a separate local table.
+
+The plugin also creates a BEFORE-DELETE trigger on `cards` that inserts a
+tombstone row whenever a card in a collab bucket is deleted — unless the
+suppress temp table `collab_suppress_outbox` has a row, which the plugin
+sets during the poll's apply phase so remotely-triggered deletes don't
+re-enqueue.
+
+## Schema (relay)
+
+```sql
+CREATE TABLE collab_buckets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ uid TEXT UNIQUE NOT NULL,
+ owner_id INTEGER NOT NULL,
+ owner_username TEXT NOT NULL,
+ name TEXT NOT NULL,
+ color TEXT NOT NULL DEFAULT '#38bdf8',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE
+);
+CREATE TABLE collab_bucket_members (
+ bucket_uid TEXT NOT NULL,
+ tenant_id INTEGER NOT NULL,
+ username TEXT NOT NULL,
+ added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (bucket_uid, tenant_id),
+ FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
+);
+
+-- Phase 3: the actual synced card content. `payload` is the re-encrypted
+-- envelope stored at rest under the inter-tenant key. `deleted = 1`
+-- marks a tombstone; bootstrap.php prunes them after 7 days.
+CREATE TABLE collab_cards (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ bucket_uid TEXT NOT NULL,
+ card_uid TEXT NOT NULL,
+ author_username TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted INTEGER NOT NULL DEFAULT 0,
+ UNIQUE (bucket_uid, card_uid)
+);
+```
+
+Owner deletion cascades via `ON DELETE CASCADE` on the `owner_id` FK.
+Member rows are pruned explicitly on leave (or cascade when a tenant is
+deleted outright). `collab_cards` rows belonging to a deleted bucket are
+orphaned but unreachable — the GET query joins `collab_bucket_members`
+and returns nothing for them, so they're effectively gone. Future hardening
+could add a cascade trigger.
+
+## Card sync flow (Phase 3)
+
+Every poll does three things in strict order inside one request:
+
+1. **Drain tombstones.** The `collab_tombstones` outbox table is populated
+ by a BEFORE DELETE trigger on `cards` — whenever a user deletes a card
+ that sits in a collab bucket, the trigger inserts a row `{relay_uid,
+ bucket_uid}` into the outbox. The poll pushes each row to
+ `DELETE /api/collab-cards/{bucket}/{card}` and drops the outbox entry
+ on success. Failed pushes stay queued and retry next poll.
+
+2. **Push dirty cards.** One query finds every card in a collab bucket
+ whose `updated_at` is greater than `collab_card_state.last_push_at`
+ (or never-pushed). Each is encrypted with the local `relay_key` and
+ POSTed to `/api/collab-cards`. On success, `last_push_at` is stamped
+ with the server's returned `updated_at` so the next poll skips it.
+ Cards without a `relay_uid` get one assigned (16 hex chars) and the
+ core `cards.relay_uid` column is updated so the card has a stable
+ cross-tenant identity.
+
+3. **Pull deltas.** `GET /api/collab-cards?since=` fetches every
+ card updated or tombstoned since the stored marker (core `settings`
+ row `collab_cards_last_remote_at`). For each incoming card:
+ - If `deleted: true` → look up the local card by `relay_uid` and
+ `DELETE` it. The BEFORE DELETE trigger is suppressed via the
+ `collab_suppress_outbox` temp-table flag so it doesn't re-enqueue
+ the tombstone back into the outbox.
+ - Otherwise, decrypt the payload, find the local card by `relay_uid`,
+ and apply LWW (`remote.updated_at > local.updated_at` → overwrite).
+ New cards are INSERTed with `bucket_id` resolved via
+ `buckets.collab_uid`. In both cases `collab_card_state.last_push_at`
+ and `last_remote_at` are set to the remote's `updated_at` so the
+ next push phase doesn't re-send what was just received.
+
+Conflict resolution is strict LWW on `updated_at`. If two users save the
+same card within the same second, the second writer wins; the first
+writer's changes are silently overwritten on their next pull. Phase 4
+adds optional edit-locks to surface the conflict before it happens.
+
+## Unread / pulse state
+
+`has_unread` is computed by the plugin backend on every read of
+`/api/collab-buckets`:
+
+```
+has_unread = (last_seen_at IS NULL) OR (updated_at > last_seen_at)
+```
+
+- **Brand-new bucket** → poll inserts the plugin row with `last_seen_at =
+ NULL` → unread.
+- **Metadata / member change** → relay bumps `updated_at` → poll upserts →
+ `updated_at > last_seen_at` → unread.
+- **Click tab** → `mark-seen` sets `last_seen_at = now` → unread clears on
+ the next render.
+
+The plugin frontend watches `#bucket-bar` via `MutationObserver`. After
+every core re-render, it walks the tabs, looks up each bucket in
+`state.buckets`, and applies `.bucket-tab-collab` plus (if unread) the
+`.pulsing` class and an injected `.collab-tab-dot` element. The pulse
+animation is CSS-driven (`collab-bucket-pulse 1.2s ease-in-out 3`), and
+the JS strips `.pulsing` after 3.7 s so subsequent re-renders don't
+retrigger it mid-cycle.
+
+## Theming
+
+All chrome hooks into the standard design tokens:
+
+- `--ui-radius-button` — tile and button rounding
+- `--ui-radius-modal` — create/edit modal
+- `--ui-radius-input` — form inputs
+- `--ui-shadow-modal` — modal drop shadow
+- `--ui-blur-overlay` — modal backdrop blur
+- `--collab-tile-color` (per-tile) — bucket color, mixed into border, text,
+ hover background, and pulse ring
+
+So a collab tile automatically inherits the currently selected theme and the
+user's design-token choices without any plugin-side theming code.
+
+## Why this shape
+
+- **First-class bucket rows, not a separate UI.** Collab buckets show up
+ exactly where users already look for buckets. No extra header tiles, no
+ second "collab+" button — the existing `+ Bucket` workflow just gains a
+ "collaborative" checkbox. The mental model stays "this is a bucket", the
+ plugin only layers sync on top.
+- **Polling, not push.** No long-lived connection, no WebSocket, no daemon.
+ 30-second cadence is perfectly fine for the "someone added a card to our
+ shared bucket" latency envelope and reuses the existing relay HTTP path.
+- **Relay as source of truth.** The local mirror is almost stateless — only
+ `last_seen_at` is user-specific and never leaves the device. Wipes and
+ re-syncs are trivial.
+- **Owner-only writes.** Simpler than ACLs. Owners who want to hand off
+ control can delete-and-recreate, or we add a transfer endpoint in Phase 4.
+- **Soft coupling to core.** Core exposes two tiny hooks
+ (`window._collabBucketsCreateDialog`, `window.state`) plus a single
+ column on the buckets table. Everything else — dialog, decoration,
+ polling, mark-seen — lives in the plugin directory and goes away when
+ the plugin is disabled.
+
+## Disabling
+
+Like every other plugin, add `"collab-buckets"` to the `disabled` array in
+`data/plugins.json`. The local table is left in place (not dropped) so
+re-enabling doesn't lose unread state.
diff --git a/public/plugins/collab-buckets/TODO.md b/public/plugins/collab-buckets/TODO.md
new file mode 100644
index 0000000..426f451
--- /dev/null
+++ b/public/plugins/collab-buckets/TODO.md
@@ -0,0 +1,151 @@
+# collab-buckets plugin — roadmap
+
+> Phase 1, 2 and 3 are shipped: metadata + membership sync, bucket-bar
+> integration, and full card sync with encrypted round-trip and LWW
+> conflict resolution. Phase 4 is polish — edit locks, ownership
+> transfer, member presence, etc. Phase 5 is hardening.
+
+## Phase 3 — card sync (DONE ✓)
+
+- [x] **`collab_card_state` table (local)** — maps a local `cards.id` to
+ a collab `bucket_uid` + `relay_uid` plus `last_push_at` and
+ `last_remote_at` for LWW tracking. Poll phase 1 finds dirty cards
+ via `c.updated_at > cs.last_push_at`.
+- [x] **Save propagation without editor coupling** — the poll's push
+ phase is query-based (no trigger, no hook). Whenever the user
+ saves a card and the core bumps `cards.updated_at`, the next
+ poll within 30s finds it dirty and pushes.
+- [x] **Relay `collab_cards` table + routes** — `POST /api/collab-cards`
+ decrypts-reencrypts via inter-tenant key; `GET ?since=`
+ re-encrypts per recipient; `DELETE /{bucket}/{card}` marks a
+ tombstone with a 7-day TTL (cleaned up by bootstrap.php).
+- [x] **Poll extension** — `collabSyncCards()` runs three phases
+ (tombstones → push → pull) with a temp-table suppress flag so the
+ delete trigger doesn't re-enqueue during apply.
+- [x] **Card visibility** — collab buckets already take part in the core
+ bucket-bar's click-to-filter flow, so cards synced into them
+ automatically show up on the board.
+- [x] **Card deletion propagation** — BEFORE DELETE trigger writes to
+ `collab_tombstones`. Drained in the next poll as a DELETE.
+ Suppressed during the apply phase so remote deletes don't loop
+ back as local tombstones.
+- [x] **Conflict losers as "(conflict from X)" duplicates** — when the
+ apply phase is about to LWW-clobber a local card that had edits
+ since our last push (`local.updated_at > collab_card_state.last_push_at`),
+ we now INSERT a copy of the local version into a local-only
+ "Conflicts" bucket with a `(conflict from ) ...` title
+ prefix before the overwrite. The poll response carries a
+ `conflicts` count; the frontend pops a toast pointing the user at
+ the Conflicts bucket when it's non-zero.
+- [x] **Moving a card between collab buckets (or out to a local one)**
+ — the push phase now has a pre-pass that finds cards where
+ `collab_card_state.bucket_uid` no longer matches the card's
+ current bucket collab_uid. For each such card it sends a
+ tombstone to the old bucket. If the card is still in a (different)
+ collab bucket, state is rewritten with the new `bucket_uid` and
+ `last_push_at = NULL` so the main push loop re-uploads under the
+ new bucket. If the card left collab entirely, the state row is
+ removed.
+
+## Phase 4 — polish
+
+- [x] **Per-card edit lock** — when the editor opens on a collab card the
+ plugin heartbeats `POST /api/collab-locks {bucket_uid, card_uid}`
+ every 30 s with a 60 s TTL on the relay. The lock list is folded
+ into the regular `/api/collab-buckets/poll` response so no extra
+ round-trips are needed. Foreign locks show a 🔒 badge on the
+ affected board card + a subtle inset outline. Releases on editor
+ close and best-effort on `beforeunload` via `fetch(..., keepalive)`.
+ Advisory only — users can still force-edit; LWW + conflict stashing
+ remain the final arbiter. Core `editor.js` dispatches
+ `hyprcrd:editor-open` / `hyprcrd:editor-close` `CustomEvent`s so
+ the plugin hooks in without monkey-patching.
+- [x] **Ownership transfer** — `PATCH /api/collab-buckets/{uid}`
+ accepts `{owner: }`. The relay validates that the new
+ owner is currently a member, swaps `owner_id` + `owner_username`,
+ and forces both the caller and the new owner to stay in the member
+ list on the same request. Surfaced in the UI via a new "Members &
+ owner…" entry in the bucket context menu that opens a plugin
+ dialog with a member picker + owner dropdown (only shown when the
+ caller is the current owner; other members see a read-only view).
+- [ ] **Position propagation (optional)** — reorder is purely local right
+ now. If multiple members expect the same order, we'd need to sync
+ `position` via the relay, but it's probably fine to leave this as a
+ per-user preference.
+- [ ] **Member presence indicator** — show a small dot next to usernames in
+ the members panel when they were seen by the relay within the last
+ minute (reuse chat's heartbeat or add a collab-specific ping).
+- [ ] **"Recently active members"** — show avatars (first letter) of
+ members who touched a card in the last hour, on the collab bucket tab.
+- [ ] **Read receipts** — "3 / 5 seen" for the latest update on each
+ collab bucket. Requires the relay to store per-member `last_seen_at`
+ — bigger schema change.
+
+## Phase 5 — hardening & UX
+
+- [x] **Migration of existing local buckets** — the bucket context menu
+ on a non-collab bucket shows a "Make collaborative…" entry when
+ the plugin is loaded. It opens a plugin dialog where the owner
+ picks members, then `POST /api/collab-buckets/upgrade
+ {bucket_id, members}` generates a new `uid`, pushes it to the
+ relay, and writes `collab_uid` onto the same local `buckets` row
+ — no card shuffling, no id rewrite. The first poll after the
+ upgrade sees every card in the bucket as dirty (no
+ `collab_card_state`) and pushes them.
+- [x] **Undo for destructive actions** — local bucket delete/leave now
+ hooks into the existing `pushUndo` system: the undo entry
+ recreates the bucket via `POST /api/buckets`, restores the
+ original color+position with PATCH, and re-attaches every card
+ that pointed at the old row. Collab bucket delete/leave is
+ intentionally *not* wired — restoring would need to recreate on
+ the relay under a new uid and re-attach cards there too, which
+ is messy and less valuable since collab deletes are usually
+ deliberate.
+- [x] **Resilient create via outbox** — a new
+ `collab_bucket_outbox(id, bucket_id, members_json, queued_at)`
+ table. The create/upgrade flow always creates the local `buckets`
+ row first and then tries `collabAttemptUpgrade()`. On relay
+ failure the intent is queued in the outbox and the next poll
+ retries. The response includes `queued: true` so the frontend can
+ show a "saved locally, will sync later" info toast; a follow-up
+ poll that successfully drains the outbox fires a second toast.
+ Stale outbox entries for deleted buckets are dropped automatically.
+- [x] **Rate limiting on collab endpoints** — every collab route now
+ funnels through the existing relay `isRateLimited()` helper with
+ generous caps (120/min for GET, 240/min for POST). Over-limit
+ requests get a clean `429 rate limited` response.
+- [ ] **Inline member picker in the bucket context menu** — currently
+ needs the manage-modal for any change. A quick `+user` / `-user`
+ flyout would help for tiny edits.
+- [ ] **Owner-side member approval flow** — owners can still add any
+ relay user unilaterally. A pending-invite state with accept/reject
+ on the invitee side would be nicer for consent but requires a new
+ relay table + plumbing.
+
+## Known limitations (current)
+
+- **LWW conflicts still clobber on the remote side.** When two users save
+ the same card within a few seconds, the later writer wins. The earlier
+ writer gets their local version stashed into a `Conflicts` bucket and
+ a toast notification — nothing is lost — but their version is no
+ longer the canonical one anyone else sees. Phase 4 edit-locks prevent
+ the race in the first place.
+- **Up to ~30 s sync latency.** Changes propagate on the next poll, not
+ instantly. Acceptable for MVP; a "push now" hook after save would cut
+ latency to ~1 s.
+- **Full resync on every poll.** Cheap for small member counts but grows
+ O(N_buckets × N_members) on the relay side. A `since=updated_at` filter
+ would help — adding it is safe because the poll also has to handle full
+ removals (kicked from bucket), and we can fold the deletion list into the
+ response.
+- **Bucket metadata is plaintext on the relay.** Name + color + member list
+ are visible to a relay admin with DB access. Card content (Phase 3) will
+ be encrypted end-to-end via the existing decrypt-reencrypt hop; bucket
+ metadata could follow the same pattern but adds complexity for minimal
+ gain and makes member picking harder.
+- **No offline support.** Creating a bucket while the relay is unreachable
+ fails hard. Phase 5 outbox pattern would fix this.
+- **Race on UID generation.** The client generates `bin2hex(random_bytes(8))`
+ = 64 bits of entropy. Collisions between independent clients are
+ astronomically unlikely, but the relay rejects them with 409 if they ever
+ happen. No retry on the client side yet.
diff --git a/public/plugins/collab-buckets/backend.php b/public/plugins/collab-buckets/backend.php
new file mode 100644
index 0000000..b7160d4
--- /dev/null
+++ b/public/plugins/collab-buckets/backend.php
@@ -0,0 +1,876 @@
+ create on relay + mirror local
+ * PATCH /api/collab-buckets/{uid} {name?, color?, members?} -> push to relay
+ * DELETE /api/collab-buckets/{uid} owner: delete; member: leave
+ * POST /api/collab-buckets/poll full re-sync from relay
+ * POST /api/collab-buckets/mark-seen {uid} -> reset dot/pulse state
+ * GET /api/collab-buckets/relay-tenants proxy to relay /api/tenants for the member-picker
+ *
+ * Phase 1+2: bucket metadata + membership only. Card sync lands in Phase 3.
+ */
+
+(function () {
+ $db = Database::connect();
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS collab_buckets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ uid TEXT UNIQUE NOT NULL,
+ name TEXT NOT NULL,
+ color TEXT NOT NULL DEFAULT '#38bdf8',
+ owner_username TEXT NOT NULL,
+ members_json TEXT NOT NULL DEFAULT '[]',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_seen_at TEXT
+ )
+ ");
+ $db->exec("CREATE INDEX IF NOT EXISTS idx_collab_buckets_uid ON collab_buckets(uid)");
+
+ // ── Phase 3 schema: card sync tracking ───────────────────────
+ // Per-card state: which relay uid and bucket uid the card maps to, plus
+ // the last time we pushed it and the last remote updated_at we applied.
+ // `last_push_at` is compared against `cards.updated_at` to find dirty
+ // cards that need a push. On apply, `last_push_at` is bumped to the
+ // remote's updated_at so we don't re-push what we just received.
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS collab_card_state (
+ card_id INTEGER PRIMARY KEY,
+ relay_uid TEXT NOT NULL,
+ bucket_uid TEXT NOT NULL,
+ last_push_at TEXT,
+ last_remote_at TEXT,
+ FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
+ )
+ ");
+ $db->exec("CREATE INDEX IF NOT EXISTS idx_collab_card_state_uid ON collab_card_state(relay_uid)");
+
+ // Tombstone outbox: populated by a BEFORE DELETE trigger on the cards
+ // table. The poll's push phase drains it and sends each entry as a
+ // DELETE to the relay.
+ //
+ // The trigger fires unconditionally on any collab-card delete, including
+ // deletions caused by *applying a remote tombstone*. That would loop the
+ // tombstone back to the relay pointlessly. To break the loop without
+ // needing a temp-table suppress flag (SQLite forbids triggers on
+ // permanent tables from referencing `temp.*` objects), the apply phase
+ // snapshots MAX(id) on `collab_tombstones` before it starts and deletes
+ // every row with id > that marker after it finishes — i.e. everything
+ // that the apply phase itself caused.
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS collab_tombstones (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ relay_uid TEXT NOT NULL,
+ bucket_uid TEXT NOT NULL,
+ queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ ");
+ // Phase 5: create-outbox for buckets. When the user creates (or
+ // upgrades) a collab bucket but the relay is unreachable, the local
+ // row is created anyway and an entry is queued here. The poll drains
+ // it on every tick by retrying the upgrade.
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS collab_bucket_outbox (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ bucket_id INTEGER NOT NULL,
+ members_json TEXT NOT NULL DEFAULT '[]',
+ queued_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ ");
+ $db->exec("
+ CREATE TRIGGER IF NOT EXISTS collab_card_delete_tombstone
+ BEFORE DELETE ON cards
+ WHEN OLD.bucket_id IS NOT NULL
+ AND OLD.relay_uid IS NOT NULL
+ BEGIN
+ INSERT INTO collab_tombstones (relay_uid, bucket_uid)
+ SELECT OLD.relay_uid, b.collab_uid
+ FROM buckets b
+ WHERE b.id = OLD.bucket_id AND b.collab_uid IS NOT NULL;
+ END
+ ");
+})();
+
+/** Encrypt a JSON payload with the local relay_key using the same envelope
+ * format the rest of hyprcrd uses (HYPRCRD-RELAY-V1). */
+function collabEncryptPayload(array $fields): ?string {
+ $key = collabRelaySettings()['key'] ?? '';
+ if ($key === '') return null;
+ $encKey = substr(hash('sha256', $key), 0, 32);
+ $plain = json_encode($fields);
+ $iv = random_bytes(16);
+ $cipher = openssl_encrypt($plain, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
+ if ($cipher === false) return null;
+ return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
+}
+
+/** Decrypt the inverse. Returns an associative array, or null on failure. */
+function collabDecryptPayload(string $envelope): ?array {
+ if (!str_starts_with($envelope, 'HYPRCRD-RELAY-V1')) return null;
+ $b64 = trim(substr($envelope, strlen('HYPRCRD-RELAY-V1') + 1));
+ $raw = base64_decode($b64);
+ if (!$raw || strlen($raw) < 17) return null;
+ $key = collabRelaySettings()['key'] ?? '';
+ if ($key === '') return null;
+ $encKey = substr(hash('sha256', $key), 0, 32);
+ $iv = substr($raw, 0, 16);
+ $cipher = substr($raw, 16);
+ $plain = openssl_decrypt($cipher, 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, $iv);
+ if ($plain === false) return null;
+ $data = json_decode($plain, true);
+ return is_array($data) ? $data : null;
+}
+
+function collabJson($data, int $code = 200): void {
+ http_response_code($code);
+ header('Content-Type: application/json');
+ echo json_encode($data);
+ exit;
+}
+
+function collabRelaySettings(): array {
+ $db = Database::connect();
+ return [
+ 'url' => $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '',
+ 'user' => $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '',
+ 'key' => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '',
+ ];
+}
+
+function collabRelayCall(string $path, string $method = 'GET', $body = null): array {
+ $r = collabRelaySettings();
+ if (!$r['url'] || !$r['user'] || !$r['key']) {
+ return ['ok' => false, 'error' => 'relay not configured (set URL, username, and secret in settings)'];
+ }
+ $url = rtrim($r['url'], '/') . $path;
+ $ch = curl_init($url);
+ $opts = [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CUSTOMREQUEST => $method,
+ CURLOPT_HTTPHEADER => [
+ 'Content-Type: application/json',
+ 'X-Relay-User: ' . $r['user'],
+ 'X-Relay-Key: ' . $r['key'],
+ ],
+ CURLOPT_TIMEOUT => 10,
+ ];
+ if ($body !== null) $opts[CURLOPT_POSTFIELDS] = json_encode($body);
+ curl_setopt_array($ch, $opts);
+ $response = curl_exec($ch);
+ $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($status !== 200) {
+ $decoded = json_decode($response ?: '', true);
+ return [
+ 'ok' => false,
+ 'status' => $status,
+ 'error' => is_array($decoded) && !empty($decoded['error']) ? $decoded['error'] : "HTTP $status",
+ 'url' => $url,
+ ];
+ }
+ $decoded = json_decode($response ?: '', true);
+ return is_array($decoded) ? $decoded : ['ok' => false, 'error' => 'invalid relay response'];
+}
+
+/** Serialize a local bucket row for the frontend. */
+function collabShapeLocalBucket(array $r): array {
+ $members = json_decode($r['members_json'] ?? '[]', true) ?: [];
+ return [
+ 'uid' => $r['uid'],
+ 'name' => $r['name'],
+ 'color' => $r['color'],
+ 'owner_username' => $r['owner_username'],
+ 'members' => $members,
+ 'created_at' => $r['created_at'],
+ 'updated_at' => $r['updated_at'],
+ 'last_seen_at' => $r['last_seen_at'],
+ // A bucket has unread state if it was never opened OR if remote
+ // updated it after the last time we opened it.
+ 'has_unread' => $r['last_seen_at'] === null || $r['updated_at'] > $r['last_seen_at'],
+ ];
+}
+
+/** Upsert the corresponding row in the main `buckets` table so the collab
+ * bucket shows up in the standard bucket bar. Returns the main bucket id. */
+function collabUpsertMainBucket(string $uid, string $name, string $color): int {
+ $db = Database::connect();
+ $stmt = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u');
+ $stmt->execute(['u' => $uid]);
+ $existing = $stmt->fetchColumn();
+ if ($existing) {
+ $db->prepare('UPDATE buckets SET name = :n, color = :c WHERE id = :id')
+ ->execute(['n' => $name, 'c' => $color, 'id' => $existing]);
+ return (int)$existing;
+ }
+ // Put new collab buckets at the end of the bar
+ $maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn();
+ $db->prepare('INSERT INTO buckets (name, color, collab_uid, position) VALUES (:n, :c, :u, :p)')
+ ->execute(['n' => $name, 'c' => $color, 'u' => $uid, 'p' => $maxPos + 1]);
+ return (int)$db->lastInsertId();
+}
+
+/** Delete the main `buckets` row for a collab bucket (if any) and unlink its
+ * cards. Uses the same ON DELETE SET NULL semantics as the regular delete path. */
+function collabDeleteMainBucket(string $uid): void {
+ $db = Database::connect();
+ $stmt = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u');
+ $stmt->execute(['u' => $uid]);
+ $bid = $stmt->fetchColumn();
+ if (!$bid) return;
+ $db->prepare('UPDATE cards SET bucket_id = NULL WHERE bucket_id = :id')->execute(['id' => $bid]);
+ $db->prepare('DELETE FROM buckets WHERE id = :id')->execute(['id' => $bid]);
+}
+
+/**
+ * Shared upgrade path: given a local buckets row, generate a fresh uid,
+ * push the create to the relay, then (on success) rewrite the local
+ * collab_uid and install the plugin mirror row. Used by both the
+ * "upgrade existing bucket" route AND the new outbox drain that retries
+ * failed creates.
+ *
+ * Returns an array with:
+ * ok=true + uid on success
+ * ok=false + error on relay failure — the caller decides
+ * whether to queue a retry in the outbox
+ * ok=true + already=true if the bucket already has a collab_uid
+ */
+function collabAttemptUpgrade(int $bucketId, array $members): array {
+ $db = Database::connect();
+ $stmt = $db->prepare('SELECT id, name, color, collab_uid FROM buckets WHERE id = :id');
+ $stmt->execute(['id' => $bucketId]);
+ $bucket = $stmt->fetch();
+ if (!$bucket) return ['ok' => false, 'error' => 'bucket not found'];
+ if (!empty($bucket['collab_uid'])) {
+ return ['ok' => true, 'already' => true, 'uid' => $bucket['collab_uid']];
+ }
+
+ $uid = bin2hex(random_bytes(8));
+ $res = collabRelayCall('/api/collab-buckets', 'POST', [
+ 'uid' => $uid,
+ 'name' => $bucket['name'],
+ 'color' => $bucket['color'],
+ 'members' => $members,
+ ]);
+ if (!($res['ok'] ?? false)) {
+ return ['ok' => false, 'error' => $res['error'] ?? 'relay create failed'];
+ }
+
+ $db->prepare('UPDATE buckets SET collab_uid = :u WHERE id = :id')
+ ->execute(['u' => $uid, 'id' => $bucketId]);
+
+ $now = date('Y-m-d H:i:s');
+ $self = collabRelaySettings()['user'];
+ $resolvedMembers = $res['members'] ?? $members;
+ $db->prepare('
+ INSERT INTO collab_buckets
+ (uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at)
+ VALUES
+ (:uid, :n, :c, :ou, :m, :ca, :ua, :ls)
+ ON CONFLICT(uid) DO UPDATE SET
+ name = excluded.name, color = excluded.color,
+ owner_username = excluded.owner_username,
+ members_json = excluded.members_json,
+ updated_at = excluded.updated_at, last_seen_at = excluded.last_seen_at
+ ')->execute([
+ 'uid' => $uid, 'n' => $bucket['name'], 'c' => $bucket['color'],
+ 'ou' => $self,
+ 'm' => json_encode($resolvedMembers),
+ 'ca' => $now, 'ua' => $now,
+ 'ls' => $now,
+ ]);
+ return ['ok' => true, 'uid' => $uid];
+}
+
+/** Find or create a local-only "Conflicts" bucket where LWW losers get
+ * stashed. Never gets a collab_uid so it doesn't sync anywhere. */
+function collabFindOrCreateConflictsBucket(): int {
+ $db = Database::connect();
+ $stmt = $db->prepare("SELECT id FROM buckets WHERE name = 'Conflicts' AND collab_uid IS NULL");
+ $stmt->execute();
+ $id = $stmt->fetchColumn();
+ if ($id) return (int)$id;
+ $maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn();
+ $db->prepare("INSERT INTO buckets (name, color, position) VALUES ('Conflicts', '#fb7185', :p)")
+ ->execute(['p' => $maxPos + 1]);
+ return (int)$db->lastInsertId();
+}
+
+/** Duplicate a card into the Conflicts bucket with a title marker, so the
+ * local user can see what they lost before the remote overwrite applied.
+ * Called from the apply phase when LWW is about to clobber unsynced local
+ * edits (local.updated_at > last_push_at && remote.updated_at > local). */
+function collabStashConflict(PDO $db, array $local, string $remoteAuthor): void {
+ $bid = collabFindOrCreateConflictsBucket();
+ $origTitle = (string)($local['title'] ?? '');
+ $newTitle = '(conflict' . ($remoteAuthor !== '' ? ' from ' . $remoteAuthor : '') . ') ' . $origTitle;
+ $db->prepare("
+ INSERT INTO cards (title, body, color, tags, position, due_date, bucket_id, created_at, updated_at)
+ VALUES (:t, :b, :c, :tg, :p, :d, :bid, datetime('now'), datetime('now'))
+ ")->execute([
+ 't' => substr($newTitle, 0, 400),
+ 'b' => $local['body'] ?? '',
+ 'c' => $local['color'] ?? '#fb7185',
+ 'tg' => $local['tags'] ?? '',
+ 'p' => (int)($local['position'] ?? 0),
+ 'd' => $local['due_date'] ?? null,
+ 'bid' => $bid,
+ ]);
+}
+
+/** Build the card payload we send to the relay. Fields that don't matter
+ * cross-user (parent_id for groups, caldav_*, the autoinc id) are omitted. */
+function collabBuildCardPayload(array $card): array {
+ return [
+ 'title' => $card['title'] ?? '',
+ 'body' => $card['body'] ?? '',
+ 'color' => $card['color'] ?? '#38bdf8',
+ 'tags' => $card['tags'] ?? '',
+ 'position' => (int)($card['position'] ?? 0),
+ 'due_date' => $card['due_date'] ?? null,
+ 'updated_at' => $card['updated_at'] ?? null,
+ ];
+}
+
+/**
+ * Phase 3 sync: push local changes + pull remote deltas for all collab
+ * buckets. Returns counters for the response body.
+ *
+ * Order matters:
+ * 1. Drain tombstones — pushes delete-your-card notifications before we
+ * pull, otherwise we might immediately re-apply a remote version of
+ * the just-deleted card.
+ * 2. Push dirty cards — `cards.updated_at > collab_card_state.last_push_at`
+ * means the user edited since we last pushed. Encrypt, POST, stamp
+ * last_push_at so the next poll skips.
+ * 3. Pull deltas — since the highest `last_remote_at` we have, apply LWW
+ * to local rows. The suppress temp-table flag keeps the BEFORE DELETE
+ * trigger from re-queueing remotely-triggered deletes.
+ */
+function collabSyncCards(PDO $db): array {
+ $relay = collabRelaySettings();
+ $stats = ['pushed' => 0, 'pulled' => 0, 'deleted_local' => 0, 'tombstones_sent' => 0, 'conflicts' => 0, 'moved' => 0];
+ if (!$relay['url'] || !$relay['user'] || !$relay['key']) return $stats;
+
+ // ── 1. Drain tombstones ────────────────────────────────
+ $tombRows = $db->query('SELECT id, relay_uid, bucket_uid FROM collab_tombstones ORDER BY id ASC')->fetchAll();
+ foreach ($tombRows as $t) {
+ $res = collabRelayCall(
+ '/api/collab-cards/' . urlencode($t['bucket_uid']) . '/' . urlencode($t['relay_uid']),
+ 'DELETE'
+ );
+ if (($res['ok'] ?? false)) {
+ $db->prepare('DELETE FROM collab_tombstones WHERE id = :id')->execute(['id' => $t['id']]);
+ $stats['tombstones_sent']++;
+ }
+ // If a tombstone push fails it stays queued and gets retried next poll.
+ }
+
+ // ── 1b. Detect moved cards ─────────────────────────────
+ // A card previously tracked in collab_card_state under bucket_uid X can
+ // now be in (a) no bucket, (b) a local bucket without collab_uid, or
+ // (c) a different collab bucket. In all three cases we need to push a
+ // tombstone to the original bucket so other members stop seeing the
+ // stale copy. If the card is still a collab card in a new bucket, the
+ // normal push phase below picks it up and pushes under the new uid.
+ $movedRows = $db->query("
+ SELECT cs.card_id, cs.relay_uid, cs.bucket_uid AS old_bucket_uid,
+ c.id IS NULL AS card_missing,
+ b.collab_uid AS new_bucket_uid
+ FROM collab_card_state cs
+ LEFT JOIN cards c ON c.id = cs.card_id
+ LEFT JOIN buckets b ON b.id = c.bucket_id
+ WHERE c.id IS NULL
+ OR b.collab_uid IS NULL
+ OR b.collab_uid != cs.bucket_uid
+ ")->fetchAll();
+ foreach ($movedRows as $mv) {
+ // Tombstone on the OLD bucket — members of that bucket should stop
+ // seeing this card regardless of where it went.
+ $res = collabRelayCall(
+ '/api/collab-cards/' . urlencode($mv['old_bucket_uid']) . '/' . urlencode($mv['relay_uid']),
+ 'DELETE'
+ );
+ if (!($res['ok'] ?? false)) continue; // retry next poll
+ $stats['moved']++;
+
+ if ($mv['card_missing'] || empty($mv['new_bucket_uid'])) {
+ // Card is gone or moved out of collab entirely — forget about it
+ $db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')
+ ->execute(['id' => $mv['card_id']]);
+ } else {
+ // Card moved to a different collab bucket. Rewrite state so the
+ // push phase below picks it up under the new bucket_uid and also
+ // resets last_push_at so the push actually fires.
+ $db->prepare("
+ UPDATE collab_card_state
+ SET bucket_uid = :b, last_push_at = NULL
+ WHERE card_id = :id
+ ")->execute(['b' => $mv['new_bucket_uid'], 'id' => $mv['card_id']]);
+ }
+ }
+
+ // ── 2. Push dirty cards ────────────────────────────────
+ // Find every card in a collab bucket whose updated_at is newer than the
+ // last time we pushed it (or was never pushed).
+ $dirty = $db->query("
+ SELECT c.id, c.title, c.body, c.color, c.tags, c.position, c.due_date,
+ c.updated_at, c.relay_uid, b.collab_uid AS bucket_uid,
+ cs.last_push_at
+ FROM cards c
+ INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL
+ LEFT JOIN collab_card_state cs ON cs.card_id = c.id
+ WHERE cs.last_push_at IS NULL OR c.updated_at > cs.last_push_at
+ ")->fetchAll();
+
+ $assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id');
+ $stateUpsert = $db->prepare("
+ INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
+ VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL))
+ ON CONFLICT(card_id) DO UPDATE SET
+ relay_uid = excluded.relay_uid,
+ bucket_uid = excluded.bucket_uid,
+ last_push_at = excluded.last_push_at
+ ");
+
+ foreach ($dirty as $card) {
+ // Ensure the card has a relay_uid (cross-tenant identity)
+ $relayUid = $card['relay_uid'];
+ if (!$relayUid) {
+ $relayUid = bin2hex(random_bytes(8));
+ $assignUid->execute(['u' => $relayUid, 'id' => $card['id']]);
+ }
+ $payload = collabEncryptPayload(collabBuildCardPayload($card));
+ if ($payload === null) continue; // no relay_key or openssl failure
+
+ $res = collabRelayCall('/api/collab-cards', 'POST', [
+ 'bucket_uid' => $card['bucket_uid'],
+ 'card_uid' => $relayUid,
+ 'payload' => $payload,
+ ]);
+ if (!($res['ok'] ?? false)) continue; // retry next poll
+
+ $stateUpsert->execute([
+ 'cid' => $card['id'],
+ 'cid2' => $card['id'],
+ 'ru' => $relayUid,
+ 'bu' => $card['bucket_uid'],
+ 'push' => $res['updated_at'] ?? $card['updated_at'],
+ ]);
+ $stats['pushed']++;
+ }
+
+ // ── 3. Pull deltas ─────────────────────────────────────
+ $since = $db->query("SELECT value FROM settings WHERE key = 'collab_cards_last_remote_at'")->fetchColumn();
+ if (!$since) $since = '';
+ $qs = $since !== '' ? ('?since=' . urlencode($since)) : '';
+ $res = collabRelayCall('/api/collab-cards' . $qs);
+ if (!($res['ok'] ?? false)) return $stats;
+
+ $incoming = $res['cards'] ?? [];
+ $maxTs = $res['max_updated_at'] ?? $since;
+ if (!$incoming) {
+ if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs);
+ return $stats;
+ }
+
+ // The delete trigger fires unconditionally on collab-card deletes,
+ // including the ones the apply phase itself causes. To avoid looping
+ // the delete back to the relay we snapshot the highest tombstone id
+ // before apply and purge everything > that marker afterwards.
+ $tombstoneMark = (int)$db->query('SELECT COALESCE(MAX(id), 0) FROM collab_tombstones')->fetchColumn();
+ try {
+ foreach ($incoming as $c) {
+ // Resolve local bucket id
+ $bid = $db->prepare('SELECT id FROM buckets WHERE collab_uid = :u');
+ $bid->execute(['u' => $c['bucket_uid']]);
+ $localBucketId = $bid->fetchColumn();
+ if (!$localBucketId) continue; // bucket mirror missing — shouldn't happen
+
+ // Find local card by relay_uid — grab all fields so we can
+ // stash a conflict copy if needed before overwriting
+ $find = $db->prepare('SELECT id, title, body, color, tags, position, due_date, updated_at FROM cards WHERE relay_uid = :u');
+ $find->execute(['u' => $c['card_uid']]);
+ $local = $find->fetch();
+
+ if (!empty($c['deleted'])) {
+ if ($local) {
+ $db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $local['id']]);
+ $db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')->execute(['id' => $local['id']]);
+ $stats['deleted_local']++;
+ }
+ continue;
+ }
+
+ // Decrypt payload
+ $fields = collabDecryptPayload($c['payload'] ?? '');
+ if ($fields === null) continue;
+
+ if ($local) {
+ // LWW: only accept remote if it's strictly newer than local
+ if (($local['updated_at'] ?? '') >= ($c['updated_at'] ?? '')) continue;
+
+ // Conflict detection: if the local card has local edits that
+ // were never pushed (local.updated_at > last_push_at), we're
+ // about to clobber them. Stash a copy into the local-only
+ // Conflicts bucket before overwriting.
+ $cs = $db->prepare('SELECT last_push_at FROM collab_card_state WHERE card_id = :id');
+ $cs->execute(['id' => $local['id']]);
+ $lastPushAt = $cs->fetchColumn();
+ if ($lastPushAt === false) $lastPushAt = null;
+ $hasUnpushed = $lastPushAt === null
+ ? false // first apply of a remote card — no "local edits" yet
+ : ($local['updated_at'] ?? '') > $lastPushAt;
+ if ($hasUnpushed) {
+ collabStashConflict($db, $local, (string)($c['author_username'] ?? ''));
+ $stats['conflicts']++;
+ }
+
+ $db->prepare("
+ UPDATE cards SET
+ title = :t, body = :b, color = :c, tags = :tg,
+ position = :p, due_date = :d,
+ bucket_id = :bid,
+ updated_at = :ua
+ WHERE id = :id
+ ")->execute([
+ 't' => $fields['title'] ?? '',
+ 'b' => $fields['body'] ?? '',
+ 'c' => $fields['color'] ?? '#38bdf8',
+ 'tg' => $fields['tags'] ?? '',
+ 'p' => (int)($fields['position'] ?? 0),
+ 'd' => $fields['due_date'] ?? null,
+ 'bid' => $localBucketId,
+ 'ua' => $c['updated_at'],
+ 'id' => $local['id'],
+ ]);
+ $localId = (int)$local['id'];
+ } else {
+ // New card
+ $db->prepare("
+ INSERT INTO cards (title, body, color, tags, position, due_date, bucket_id, relay_uid, created_at, updated_at)
+ VALUES (:t, :b, :c, :tg, :p, :d, :bid, :u, :ca, :ua)
+ ")->execute([
+ 't' => $fields['title'] ?? '',
+ 'b' => $fields['body'] ?? '',
+ 'c' => $fields['color'] ?? '#38bdf8',
+ 'tg' => $fields['tags'] ?? '',
+ 'p' => (int)($fields['position'] ?? 0),
+ 'd' => $fields['due_date'] ?? null,
+ 'bid' => $localBucketId,
+ 'u' => $c['card_uid'],
+ 'ca' => $c['updated_at'],
+ 'ua' => $c['updated_at'],
+ ]);
+ $localId = (int)$db->lastInsertId();
+ }
+
+ // Record the remote state so we don't push it back on the next poll
+ $db->prepare("
+ INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
+ VALUES (:cid, :ru, :bu, :p, :r)
+ ON CONFLICT(card_id) DO UPDATE SET
+ relay_uid = excluded.relay_uid,
+ bucket_uid = excluded.bucket_uid,
+ last_push_at = excluded.last_push_at,
+ last_remote_at = excluded.last_remote_at
+ ")->execute([
+ 'cid' => $localId,
+ 'ru' => $c['card_uid'],
+ 'bu' => $c['bucket_uid'],
+ 'p' => $c['updated_at'],
+ 'r' => $c['updated_at'],
+ ]);
+ $stats['pulled']++;
+ }
+ } finally {
+ // Purge any tombstones the apply phase itself created. Tombstones
+ // queued before apply (by actual user deletes) survive and get
+ // drained on the next poll iteration.
+ $db->prepare('DELETE FROM collab_tombstones WHERE id > :m')->execute(['m' => $tombstoneMark]);
+ }
+
+ if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs);
+ return $stats;
+}
+
+/** Tiny key/value on the core `settings` table. */
+function collabSaveSetting(string $key, string $value): void {
+ Database::connect()
+ ->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)")
+ ->execute(['k' => $key, 'v' => $value]);
+}
+
+PluginManager::instance()->onApiRoute(function (string $route, string $method) {
+
+ // ── List local mirror ──────────────────────────────────────
+ if ($route === '/api/collab-buckets' && $method === 'GET') {
+ $db = Database::connect();
+ $rows = $db->query('
+ SELECT uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at
+ FROM collab_buckets
+ ORDER BY updated_at DESC
+ ')->fetchAll();
+ $out = array_map('collabShapeLocalBucket', $rows);
+ collabJson(['ok' => true, 'buckets' => $out]);
+ }
+
+ // ── Create on relay, mirror local ──────────────────────────
+ // Resilient path: always create the local bucket first so the user
+ // can keep working even when the relay is down. Then attempt to
+ // upgrade it via the shared helper. On relay failure we queue the
+ // upgrade in collab_bucket_outbox and the next poll retries.
+ if ($route === '/api/collab-buckets' && $method === 'POST') {
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $name = trim((string)($input['name'] ?? ''));
+ $color = (string)($input['color'] ?? '#38bdf8');
+ $members = is_array($input['members'] ?? null) ? $input['members'] : [];
+ if ($name === '') collabJson(['ok' => false, 'error' => 'name required'], 400);
+
+ $db = Database::connect();
+ $maxPos = (int)$db->query('SELECT COALESCE(MAX(position), 0) FROM buckets')->fetchColumn();
+ $db->prepare('INSERT INTO buckets (name, color, position) VALUES (:n, :c, :p)')
+ ->execute(['n' => $name, 'c' => $color, 'p' => $maxPos + 1]);
+ $bucketId = (int)$db->lastInsertId();
+
+ $up = collabAttemptUpgrade($bucketId, $members);
+ if ($up['ok'] ?? false) {
+ collabJson(['ok' => true, 'uid' => $up['uid'], 'bucket_id' => $bucketId]);
+ }
+ // Relay unreachable: queue the upgrade for retry. The bucket
+ // already exists locally so the user sees it immediately.
+ $db->prepare('INSERT INTO collab_bucket_outbox (bucket_id, members_json) VALUES (:b, :m)')
+ ->execute(['b' => $bucketId, 'm' => json_encode($members)]);
+ collabJson([
+ 'ok' => true,
+ 'queued' => true,
+ 'bucket_id' => $bucketId,
+ 'error' => $up['error'] ?? 'relay unreachable — will retry on next poll',
+ ]);
+ }
+
+ // ── Update on relay (owner-only) ───────────────────────────
+ if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') {
+ $uid = $m[1];
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $res = collabRelayCall('/api/collab-buckets/' . urlencode($uid), 'PATCH', $input);
+ if (!($res['ok'] ?? false)) {
+ collabJson(['ok' => false, 'error' => $res['error'] ?? 'patch failed'], 500);
+ }
+ // Don't mirror the patch locally — the next poll will pick up the new
+ // state from the relay (keeps the relay as source of truth).
+ collabJson(['ok' => true]);
+ }
+
+ // ── Delete / leave ─────────────────────────────────────────
+ if (preg_match('#^/api/collab-buckets/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
+ $uid = $m[1];
+ $res = collabRelayCall('/api/collab-buckets/' . urlencode($uid), 'DELETE');
+ if (!($res['ok'] ?? false)) {
+ collabJson(['ok' => false, 'error' => $res['error'] ?? 'delete failed'], 500);
+ }
+ Database::connect()->prepare('DELETE FROM collab_buckets WHERE uid = :u')->execute(['u' => $uid]);
+ collabDeleteMainBucket($uid);
+ collabJson(['ok' => true]);
+ }
+
+ // ── Poll: full resync ──────────────────────────────────────
+ if ($route === '/api/collab-buckets/poll' && $method === 'POST') {
+ $db = Database::connect();
+
+ // Phase 5: drain the create/upgrade outbox first. Each queued row
+ // is a "tried to make this local bucket collab but the relay was
+ // down" intent. Retry the upgrade helper; on success drop the
+ // outbox row; on continued failure leave it queued for next poll.
+ $outbox = $db->query('SELECT id, bucket_id, members_json FROM collab_bucket_outbox ORDER BY id ASC')->fetchAll();
+ $outboxProcessed = 0;
+ foreach ($outbox as $o) {
+ $members = json_decode($o['members_json'] ?? '[]', true) ?: [];
+ $up = collabAttemptUpgrade((int)$o['bucket_id'], $members);
+ if ($up['ok'] ?? false) {
+ $db->prepare('DELETE FROM collab_bucket_outbox WHERE id = :id')->execute(['id' => $o['id']]);
+ $outboxProcessed++;
+ } elseif (strpos((string)($up['error'] ?? ''), 'bucket not found') !== false) {
+ // Bucket was deleted locally before the upgrade could fly;
+ // drop the stale outbox entry instead of retrying forever.
+ $db->prepare('DELETE FROM collab_bucket_outbox WHERE id = :id')->execute(['id' => $o['id']]);
+ }
+ // All other failures: leave queued, retry next poll.
+ }
+
+ $res = collabRelayCall('/api/collab-buckets');
+ if (!($res['ok'] ?? false)) {
+ collabJson(['ok' => false, 'error' => $res['error'] ?? 'poll failed']);
+ }
+ $remote = $res['buckets'] ?? [];
+ $remoteUids = array_column($remote, 'uid');
+
+ // Any local bucket that is no longer on the relay for us = we were
+ // removed (or the bucket was deleted). Drop both the plugin mirror
+ // and the corresponding row in the main buckets table.
+ $staleUids = [];
+ if ($remoteUids) {
+ $ph = implode(',', array_fill(0, count($remoteUids), '?'));
+ $sel = $db->prepare("SELECT uid FROM collab_buckets WHERE uid NOT IN ($ph)");
+ $sel->execute($remoteUids);
+ $staleUids = array_column($sel->fetchAll(), 'uid');
+ if ($staleUids) {
+ $stmt = $db->prepare("DELETE FROM collab_buckets WHERE uid NOT IN ($ph)");
+ $stmt->execute($remoteUids);
+ }
+ } else {
+ $staleUids = array_column($db->query('SELECT uid FROM collab_buckets')->fetchAll(), 'uid');
+ $db->exec('DELETE FROM collab_buckets');
+ }
+ foreach ($staleUids as $uid) {
+ collabDeleteMainBucket($uid);
+ }
+
+ // Upsert each remote bucket. Preserve local last_seen_at so the dot
+ // state survives across polls.
+ $changed = 0;
+ $upsert = $db->prepare("
+ INSERT INTO collab_buckets
+ (uid, name, color, owner_username, members_json, created_at, updated_at, last_seen_at)
+ VALUES
+ (:uid, :n, :c, :ou, :m, :ca, :ua,
+ COALESCE((SELECT last_seen_at FROM collab_buckets WHERE uid = :uid2), NULL))
+ ON CONFLICT(uid) DO UPDATE SET
+ name = excluded.name,
+ color = excluded.color,
+ owner_username = excluded.owner_username,
+ members_json = excluded.members_json,
+ created_at = excluded.created_at,
+ updated_at = excluded.updated_at
+ ");
+ $check = $db->prepare('SELECT updated_at FROM collab_buckets WHERE uid = :u');
+ foreach ($remote as $b) {
+ // Always mirror into main buckets table (idempotent upsert)
+ collabUpsertMainBucket($b['uid'], $b['name'], $b['color']);
+
+ $check->execute(['u' => $b['uid']]);
+ $existing = $check->fetchColumn();
+ if ($existing === ($b['updated_at'] ?? '')) continue; // unchanged, skip plugin-table write
+ $upsert->execute([
+ 'uid' => $b['uid'],
+ 'n' => $b['name'],
+ 'c' => $b['color'],
+ 'ou' => $b['owner_username'],
+ 'm' => json_encode($b['members'] ?? []),
+ 'ca' => $b['created_at'],
+ 'ua' => $b['updated_at'],
+ 'uid2' => $b['uid'],
+ ]);
+ $changed++;
+ }
+
+ // ── Phase 3: card sync ─────────────────────────────────
+ $cardStats = collabSyncCards($db);
+ // If anything changed locally we hint to the frontend to reload
+ $cardsChanged = (
+ $cardStats['pushed']
+ + $cardStats['pulled']
+ + $cardStats['deleted_local']
+ + $cardStats['tombstones_sent']
+ + $cardStats['conflicts']
+ + $cardStats['moved']
+ ) > 0;
+
+ // ── Phase 4: fold the current lock list into the poll response
+ // so the frontend can decorate cards without a second round-trip.
+ $locks = [];
+ $lockRes = collabRelayCall('/api/collab-locks');
+ if (($lockRes['ok'] ?? false) && is_array($lockRes['locks'] ?? null)) {
+ $locks = $lockRes['locks'];
+ }
+
+ // Any pending outbox entries still waiting for a reachable relay
+ $outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn();
+
+ collabJson([
+ 'ok' => true,
+ 'changed' => $changed,
+ 'total' => count($remote),
+ 'cards_changed' => $cardsChanged,
+ 'card_stats' => $cardStats,
+ 'locks' => $locks,
+ 'outbox_processed' => $outboxProcessed,
+ 'outbox_pending' => $outboxPending,
+ ]);
+ }
+
+ // ── Mark a bucket as seen (clears dot + stops pulse next render) ──
+ if ($route === '/api/collab-buckets/mark-seen' && $method === 'POST') {
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $uid = trim((string)($input['uid'] ?? ''));
+ if ($uid === '') collabJson(['ok' => false, 'error' => 'uid required'], 400);
+ Database::connect()
+ ->prepare("UPDATE collab_buckets SET last_seen_at = datetime('now') WHERE uid = :u")
+ ->execute(['u' => $uid]);
+ collabJson(['ok' => true]);
+ }
+
+ // ── Proxy: relay tenants (for the create-bucket member picker) ──
+ // The relay's /api/tenants returns {tenants, self} without an `ok` field,
+ // so we wrap it here to match the shape the frontend expects.
+ if ($route === '/api/collab-buckets/relay-tenants' && $method === 'GET') {
+ $res = collabRelayCall('/api/tenants');
+ if (isset($res['ok']) && $res['ok'] === false) {
+ collabJson($res);
+ }
+ collabJson([
+ 'ok' => true,
+ 'tenants' => $res['tenants'] ?? [],
+ 'self' => $res['self'] ?? collabRelaySettings()['user'],
+ ]);
+ }
+
+ // ── Phase 5: upgrade an existing local bucket to collab ───
+ // Keeps the same local buckets.id (so cards stay attached), writes a
+ // new collab_uid onto the row, and creates the mirror on the relay.
+ // On the very next poll the push phase sees every card in that bucket
+ // as dirty (no collab_card_state rows) and uploads them. If the relay
+ // is unreachable, the upgrade is queued in the outbox and retried on
+ // the next poll.
+ if ($route === '/api/collab-buckets/upgrade' && $method === 'POST') {
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $bucketId = (int)($input['bucket_id'] ?? 0);
+ $members = is_array($input['members'] ?? null) ? $input['members'] : [];
+ if ($bucketId <= 0) collabJson(['ok' => false, 'error' => 'bucket_id required'], 400);
+
+ $db = Database::connect();
+ $up = collabAttemptUpgrade($bucketId, $members);
+ if ($up['ok'] ?? false) {
+ if (!empty($up['already'])) collabJson(['ok' => false, 'error' => 'bucket is already collaborative'], 400);
+ collabJson(['ok' => true, 'uid' => $up['uid']]);
+ }
+ // Relay unreachable — queue retry
+ $db->prepare('INSERT INTO collab_bucket_outbox (bucket_id, members_json) VALUES (:b, :m)')
+ ->execute(['b' => $bucketId, 'm' => json_encode($members)]);
+ collabJson([
+ 'ok' => true,
+ 'queued' => true,
+ 'error' => $up['error'] ?? 'relay unreachable — will retry on next poll',
+ ]);
+ }
+
+ // ── Phase 4: edit-lock passthrough ─────────────────────────
+ if ($route === '/api/collab-locks' && $method === 'POST') {
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $res = collabRelayCall('/api/collab-locks', 'POST', $input);
+ collabJson($res);
+ }
+ if (preg_match('#^/api/collab-locks/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') {
+ $res = collabRelayCall('/api/collab-locks/' . urlencode($m[1]) . '/' . urlencode($m[2]), 'DELETE');
+ collabJson($res);
+ }
+});
diff --git a/public/plugins/collab-buckets/collab-buckets.css b/public/plugins/collab-buckets/collab-buckets.css
new file mode 100644
index 0000000..19d897e
--- /dev/null
+++ b/public/plugins/collab-buckets/collab-buckets.css
@@ -0,0 +1,304 @@
+/* ── Collab buckets plugin ── */
+
+/* Decoration for bucket-bar tabs that represent a collab bucket. The core
+ still renders the tab via its normal `.bucket-tab` rule; the plugin only
+ layers a few extras on top: a small 👥 icon swap (done in JS), an unread
+ dot pinned top-right, and a 3-cycle pulse animation when a fresh update
+ arrives. */
+
+.bucket-tab.bucket-tab-collab {
+ position: relative;
+ /* Subtle left emphasis so collab tabs read differently at a glance */
+ box-shadow: inset 2px 0 0 0 color-mix(in srgb, var(--t-accent2) 70%, transparent);
+}
+
+.bucket-tab.bucket-tab-collab .collab-tab-dot {
+ position: absolute;
+ top: -3px;
+ right: -3px;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--t-danger);
+ border: 1px solid var(--t-bg);
+ box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 60%, transparent);
+ pointer-events: none;
+}
+
+.bucket-tab.bucket-tab-collab.pulsing {
+ animation: collab-bucket-pulse 1.2s ease-in-out 3;
+}
+/* Keep the 3-cycle cadence regardless of global animation-speed override */
+body.anim-fast .bucket-tab.bucket-tab-collab.pulsing,
+body.anim-slow .bucket-tab.bucket-tab-collab.pulsing {
+ animation: collab-bucket-pulse 1.2s ease-in-out 3 !important;
+ animation-duration: 1.2s !important;
+}
+@keyframes collab-bucket-pulse {
+ 0%, 100% {
+ box-shadow: inset 2px 0 0 0 color-mix(in srgb, var(--t-accent2) 70%, transparent),
+ 0 0 0 0 color-mix(in srgb, var(--t-danger) 60%, transparent);
+ }
+ 50% {
+ box-shadow: inset 2px 0 0 0 color-mix(in srgb, var(--t-accent2) 70%, transparent),
+ 0 0 0 4px color-mix(in srgb, var(--t-danger) 0%, transparent);
+ }
+}
+
+/* ── Create / edit modal (used by the + Bucket button when the plugin
+ is loaded) ── */
+.collab-modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 240;
+ background: var(--t-overlay, rgba(0,0,0,0.6));
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ backdrop-filter: blur(var(--ui-blur-overlay, 0px));
+ -webkit-backdrop-filter: blur(var(--ui-blur-overlay, 0px));
+}
+.collab-modal {
+ width: 100%;
+ max-width: 480px;
+ max-height: 85vh;
+ background: var(--t-surface);
+ border: 1px solid var(--t-border);
+ border-radius: var(--ui-radius-modal);
+ box-shadow: var(--ui-shadow-modal);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.collab-modal-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--t-border);
+ flex-shrink: 0;
+}
+.collab-modal-close {
+ background: transparent;
+ border: 1px solid var(--t-border);
+ border-radius: var(--ui-radius-button);
+ color: var(--t-muted);
+ cursor: pointer;
+ padding: 4px 8px;
+ display: flex;
+ align-items: center;
+ transition: color 0.12s, border-color 0.12s;
+}
+.collab-modal-close:hover { color: var(--t-danger); border-color: var(--t-danger); }
+.collab-modal-body {
+ padding: 16px;
+ overflow-y: auto;
+ flex: 1;
+ min-height: 0;
+}
+.collab-field { display: block; margin-bottom: 14px; }
+.collab-field:last-child { margin-bottom: 0; }
+.collab-field.hidden { display: none; }
+.collab-label {
+ display: block;
+ font-family: var(--t-font-pixel, 'Silkscreen', cursive);
+ font-size: 9px;
+ color: var(--t-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 5px;
+}
+.collab-input {
+ width: 100%;
+ background: var(--t-bg);
+ border: 1px solid var(--t-border);
+ border-radius: var(--ui-radius-input);
+ color: var(--t-bright);
+ padding: 7px 10px;
+ font-size: 12px;
+ font-family: var(--t-font-content, 'JetBrains Mono', monospace);
+ outline: none;
+ transition: border-color 0.12s;
+}
+.collab-input:focus { border-color: var(--t-accent); }
+.collab-color {
+ width: 52px;
+ height: 32px;
+ border: 1px solid var(--t-border);
+ border-radius: var(--ui-radius-input);
+ background: transparent;
+ cursor: pointer;
+ padding: 2px;
+}
+
+.collab-toggle-row {
+ display: flex !important;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ background: var(--t-bg);
+ border: 1px solid var(--t-border);
+ border-radius: var(--ui-radius-input);
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--t-text);
+ user-select: none;
+}
+.collab-toggle-row input { accent-color: var(--t-accent); cursor: pointer; }
+.collab-toggle-row:hover { border-color: var(--t-accent); color: var(--t-bright); }
+
+.collab-members-wrap { margin-top: 10px; }
+.collab-members {
+ border: 1px solid var(--t-border);
+ border-radius: var(--ui-radius-input);
+ background: var(--t-bg);
+ max-height: 200px;
+ overflow-y: auto;
+ padding: 4px;
+}
+.collab-members-empty {
+ font-size: 11px;
+ color: var(--t-dim);
+ font-style: italic;
+ padding: 8px 10px;
+}
+.collab-member-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 8px;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--t-text);
+ border-radius: 2px;
+ user-select: none;
+}
+.collab-member-row:hover {
+ background: var(--t-raised);
+ color: var(--t-bright);
+}
+.collab-member-row input { cursor: pointer; accent-color: var(--t-accent); }
+
+.collab-modal-footer {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ border-top: 1px solid var(--t-border);
+ flex-shrink: 0;
+}
+.collab-modal-status {
+ font-family: var(--t-font-pixel, 'Silkscreen', cursive);
+ font-size: 9px;
+ color: var(--t-dim);
+ min-height: 14px;
+}
+.collab-btn-cancel,
+.collab-btn-submit {
+ padding: 6px 14px;
+ font-family: var(--t-font-pixel, 'Silkscreen', cursive);
+ font-size: 10px;
+ background: transparent;
+ cursor: pointer;
+ border: 1px solid var(--t-border);
+ border-radius: var(--ui-radius-button);
+ color: var(--t-muted);
+ transition: color 0.12s, border-color 0.12s, background 0.12s;
+}
+.collab-btn-cancel:hover {
+ color: var(--t-danger);
+ border-color: var(--t-danger);
+}
+.collab-btn-submit {
+ color: var(--t-accent);
+ border-color: var(--t-accent);
+}
+.collab-btn-submit:hover {
+ background: color-mix(in srgb, var(--t-accent) 12%, transparent);
+}
+
+.collab-readonly {
+ display: block;
+ padding: 7px 10px;
+ background: var(--t-bg);
+ border: 1px solid var(--t-border);
+ border-radius: var(--ui-radius-input);
+ color: var(--t-text);
+ font-family: var(--t-font-content, 'JetBrains Mono', monospace);
+ font-size: 12px;
+}
+.collab-hint {
+ margin-top: 4px;
+ font-size: 10px;
+ color: var(--t-dim);
+ font-style: italic;
+}
+
+/* ── Phase 4: edit-lock indicator on board cards ── */
+.board-card.collab-locked {
+ box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--t-accent2) 55%, transparent);
+}
+.collab-lock-badge {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--t-accent2);
+ background: color-mix(in srgb, var(--t-surface) 92%, transparent);
+ border: 1px solid color-mix(in srgb, var(--t-accent2) 60%, var(--t-border));
+ border-radius: 50%;
+ z-index: 6;
+ pointer-events: none;
+ font-size: 11px;
+}
+
+/* ── Conflict toast (LWW losers stashed into the Conflicts bucket) ── */
+.collab-conflict-toast {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 220;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px 10px 12px;
+ max-width: 480px;
+ font-family: var(--t-font-pixel, 'Silkscreen', cursive);
+ font-size: 10px;
+ color: var(--t-bright);
+ background: var(--t-surface);
+ border: 1px solid var(--t-danger);
+ border-radius: var(--ui-radius-modal);
+ box-shadow: var(--ui-shadow-modal);
+ transition: opacity 0.4s;
+}
+.collab-conflict-toast i.ph-warning { color: var(--t-danger); flex-shrink: 0; }
+.collab-conflict-toast b { color: var(--t-danger); font-weight: 700; }
+.collab-conflict-toast.fade-out { opacity: 0; }
+/* Queued variant reuses the layout but switches the danger accents for
+ a neutral info color — used for "relay was down, bucket is queued" and
+ "queued bucket finally synced" messages. */
+.collab-conflict-toast.collab-queued-toast {
+ border-color: var(--t-accent2);
+}
+.collab-conflict-toast.collab-queued-toast i.ph-cloud-arrow-up {
+ color: var(--t-accent2);
+ flex-shrink: 0;
+}
+.collab-conflict-close {
+ background: transparent;
+ border: none;
+ color: var(--t-dim);
+ cursor: pointer;
+ padding: 2px 4px;
+ margin-left: 6px;
+ display: flex;
+ align-items: center;
+}
+.collab-conflict-close:hover { color: var(--t-danger); }
diff --git a/public/plugins/collab-buckets/collab-buckets.js b/public/plugins/collab-buckets/collab-buckets.js
new file mode 100644
index 0000000..b8ec215
--- /dev/null
+++ b/public/plugins/collab-buckets/collab-buckets.js
@@ -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, ''');
+}
+
+// ── 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 = `
+