collab per-card unread, detail stale banner, image sync race fix
- per-card unread dot on board (collab-buckets): backend tracks last_seen_at per card, poll exposes unread_cards, frontend decorates matching board cards; cleared on any open path via detail.opened hook - detail.opened + cards.reloaded hooks on the hyprcrd event bus - stale banner in detail view with manual Reload when the currently viewed card got updated by a peer — no content swap under the user - collab image sync: push attachments BEFORE card (race fix) + pull-side self-heal scans every collab card for missing uploads each poll - relay user deletion now purges chat_messages sent by deleted user so ghost threads stop appearing for their peers - calendar header nav buttons styled as square bordered buttons that honor --ui-radius-button; title bar widened to 280px so font variants don't wrap - .toolbar-btn now uses var(--ui-radius-button) instead of hardcoded 3px Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
39e94b8015
commit
26f95ba079
10 files changed
+324
-35
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.6.0');
|
define('APP_VERSION', '1.7.0');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.6.0",
|
"version": "1.7.0",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+31
-1
@@ -932,7 +932,7 @@ body.anim-slow .board-card.card-pulse-local {
|
|||||||
#tag-bar { border-top: 1px dashed color-mix(in srgb, var(--t-accent) 30%, transparent); }
|
#tag-bar { border-top: 1px dashed color-mix(in srgb, var(--t-accent) 30%, transparent); }
|
||||||
|
|
||||||
/* Toolbar */
|
/* Toolbar */
|
||||||
.toolbar-btn { color: var(--t-muted); padding: 4px 6px; border-radius: 3px; transition: color 0.1s, background 0.1s; cursor: pointer; background: none; border: none; font-family: inherit; }
|
.toolbar-btn { color: var(--t-muted); padding: 4px 6px; border-radius: var(--ui-radius-button); transition: color 0.1s, background 0.1s; cursor: pointer; background: none; border: none; font-family: inherit; }
|
||||||
.toolbar-btn:hover { color: var(--t-bright); background: var(--t-raised); }
|
.toolbar-btn:hover { color: var(--t-bright); background: var(--t-raised); }
|
||||||
|
|
||||||
/* Editor */
|
/* Editor */
|
||||||
@@ -1194,3 +1194,33 @@ body.anim-slow .board-card.card-pulse-local {
|
|||||||
#detail-body .detail-reading-resize.resizing .detail-font-dragline::before {
|
#detail-body .detail-reading-resize.resizing .detail-font-dragline::before {
|
||||||
border-color: var(--t-accent);
|
border-color: var(--t-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Remote-edit banner in the detail view.
|
||||||
|
Shown when a foreign sync replaced the card we're currently reading — the
|
||||||
|
user gets a manual Reload button instead of having the content change
|
||||||
|
under them mid-read. */
|
||||||
|
.detail-stale-banner {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: color-mix(in srgb, var(--t-accent2) 10%, transparent);
|
||||||
|
border-bottom: 1px solid var(--t-accent2);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--t-muted);
|
||||||
|
}
|
||||||
|
.detail-stale-banner i.ph-info { color: var(--t-accent2); flex-shrink: 0; font-size: 14px; }
|
||||||
|
.detail-stale-banner span { flex: 1; }
|
||||||
|
.detail-stale-banner button {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-family: var(--font-pixel, 'Silkscreen', cursive);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--t-accent2);
|
||||||
|
color: var(--t-accent2);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--ui-radius-button);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.detail-stale-banner button:hover { background: var(--t-accent2); color: var(--t-bg); }
|
||||||
|
.detail-stale-banner .detail-stale-dismiss { border-color: var(--t-border); color: var(--t-dim); padding: 3px 5px; }
|
||||||
|
.detail-stale-banner .detail-stale-dismiss:hover { border-color: var(--t-danger); color: var(--t-danger); background: transparent; }
|
||||||
+4
-4
@@ -2115,10 +2115,10 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<button id="cal-today" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Today</button>
|
<button id="cal-today" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Today</button>
|
||||||
<button id="cal-sync" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors flex items-center gap-1"><i class="ph ph-cloud-arrow-up text-xs"></i>Sync</button>
|
<button id="cal-sync" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors flex items-center gap-1"><i class="ph ph-cloud-arrow-up text-xs"></i>Sync</button>
|
||||||
<button id="cal-caldav-settings" class="text-t-dim hover:text-t-accent transition-colors"><i class="ph ph-gear text-sm"></i></button>
|
<button id="cal-caldav-settings" class="text-t-dim hover:text-t-accent transition-colors"><i class="ph ph-gear text-sm"></i></button>
|
||||||
<div class="absolute left-1/2 -translate-x-1/2 flex items-center gap-3">
|
<div class="absolute left-1/2 -translate-x-1/2 flex items-center gap-2">
|
||||||
<button id="cal-prev" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
|
<button id="cal-prev" class="flex items-center justify-center w-8 h-8 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="previous"><i class="ph ph-caret-left text-base"></i></button>
|
||||||
<h2 id="cal-title" class="font-pixel text-sm text-t-accent text-center" style="width:220px;flex-shrink:0;"></h2>
|
<h2 id="cal-title" class="font-pixel text-sm text-t-accent text-center" style="width:280px;flex-shrink:0;"></h2>
|
||||||
<button id="cal-next" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-right text-sm"></i></button>
|
<button id="cal-next" class="flex items-center justify-center w-8 h-8 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="next"><i class="ph ph-caret-right text-base"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="cal-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
<button id="cal-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||||
|
|||||||
+10
-1
@@ -41,7 +41,16 @@ window._getSelectedIds = getSelectedIds;
|
|||||||
window._clearSelection = clearSelection;
|
window._clearSelection = clearSelection;
|
||||||
window._refreshTasks = refreshTasksView;
|
window._refreshTasks = refreshTasksView;
|
||||||
window.openFindBar = openFindBar;
|
window.openFindBar = openFindBar;
|
||||||
window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
|
window._reloadAll = async () => {
|
||||||
|
await loadBuckets();
|
||||||
|
state.cards = await api('api/cards') || [];
|
||||||
|
renderBoard();
|
||||||
|
refreshCalendar();
|
||||||
|
// Let plugins / modules react to a full card reload (detail view checks
|
||||||
|
// whether the currently viewed card is now stale; collab-buckets uses it
|
||||||
|
// to refresh its decorators).
|
||||||
|
window.hyprcrd?.hooks?.emit?.('cards.reloaded');
|
||||||
|
};
|
||||||
// Exposed for plugins that need to read the shared state (e.g. collab-buckets
|
// 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).
|
// reads `state.buckets` in its MutationObserver to find collab_uid mapping).
|
||||||
window.state = state;
|
window.state = state;
|
||||||
|
|||||||
@@ -72,9 +72,83 @@ export function openDetail(id, options = {}) {
|
|||||||
|
|
||||||
renderSidebar(card);
|
renderSidebar(card);
|
||||||
showContent(card);
|
showContent(card);
|
||||||
|
dismissStaleBanner();
|
||||||
detail.classList.add('open');
|
detail.classList.add('open');
|
||||||
|
|
||||||
|
// Let plugins (collab-buckets unread-dot, etc.) react to the open.
|
||||||
|
window.hyprcrd?.hooks?.emit?.('detail.opened', { id: card.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Remote-update detection ──
|
||||||
|
//
|
||||||
|
// When a foreign edit arrives for the card the user is currently viewing,
|
||||||
|
// the pull replaces state.cards but the open detail view keeps rendering
|
||||||
|
// the stale object it captured on open. Instead of force-re-rendering
|
||||||
|
// under the user's finger (jarring when they're mid-read), surface a
|
||||||
|
// banner with a manual Reload button.
|
||||||
|
|
||||||
|
let staleBannerEl = null;
|
||||||
|
|
||||||
|
function dismissStaleBanner() {
|
||||||
|
if (staleBannerEl) { staleBannerEl.remove(); staleBannerEl = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStaleBanner(fresh) {
|
||||||
|
dismissStaleBanner();
|
||||||
|
const host = detail.querySelector('#detail-controls')?.parentElement;
|
||||||
|
if (!host) return;
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'detail-stale-banner';
|
||||||
|
bar.innerHTML = `
|
||||||
|
<i class="ph ph-info"></i>
|
||||||
|
<span>This card was updated${fresh?.updated_at ? ' ' + timeAgo(fresh.updated_at) : ''}.</span>
|
||||||
|
<button class="detail-stale-reload">
|
||||||
|
<i class="ph ph-arrows-clockwise text-xs"></i>
|
||||||
|
<span>Reload</span>
|
||||||
|
</button>
|
||||||
|
<button class="detail-stale-dismiss" aria-label="Dismiss"><i class="ph ph-x text-xs"></i></button>
|
||||||
|
`;
|
||||||
|
host.insertBefore(bar, host.querySelector('#detail-controls').nextSibling);
|
||||||
|
staleBannerEl = bar;
|
||||||
|
bar.querySelector('.detail-stale-reload').addEventListener('click', () => {
|
||||||
|
const current = state.cards.find(c => c.id == state.viewingCard?.id);
|
||||||
|
if (current) {
|
||||||
|
state.viewingCard = current;
|
||||||
|
renderSidebar(current);
|
||||||
|
showContent(current);
|
||||||
|
}
|
||||||
|
dismissStaleBanner();
|
||||||
|
});
|
||||||
|
bar.querySelector('.detail-stale-dismiss').addEventListener('click', dismissStaleBanner);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso) {
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (!isFinite(t)) return '';
|
||||||
|
const s = Math.max(0, (Date.now() - t) / 1000);
|
||||||
|
if (s < 60) return `${Math.floor(s)}s ago`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||||
|
return `${Math.floor(s / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After every full card reload, check whether the card we're viewing is
|
||||||
|
// newer in the fresh state. If so, show the banner. Dismiss it if the
|
||||||
|
// detail view closed in the meantime.
|
||||||
|
window.hyprcrd?.hooks?.on?.('cards.reloaded', () => {
|
||||||
|
if (!detail.classList.contains('open') || !state.viewingCard) {
|
||||||
|
dismissStaleBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const viewingId = state.viewingCard.id;
|
||||||
|
const fresh = state.cards.find(c => c.id == viewingId);
|
||||||
|
if (!fresh) return; // card deleted — separate UX, skip banner
|
||||||
|
const old = state.viewingCard;
|
||||||
|
if ((fresh.updated_at || '') > (old.updated_at || '')) {
|
||||||
|
showStaleBanner(fresh);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function clearMorphGhost() {
|
function clearMorphGhost() {
|
||||||
if (morphTimer) {
|
if (morphTimer) {
|
||||||
clearTimeout(morphTimer);
|
clearTimeout(morphTimer);
|
||||||
@@ -370,6 +444,7 @@ export function closeDetail(options = {}) {
|
|||||||
closeImageFullscreen();
|
closeImageFullscreen();
|
||||||
|
|
||||||
detail.classList.remove('open', 'zen');
|
detail.classList.remove('open', 'zen');
|
||||||
|
dismissStaleBanner();
|
||||||
removeZenHandles();
|
removeZenHandles();
|
||||||
document.documentElement.classList.remove('detail-zen-open');
|
document.documentElement.classList.remove('detail-zen-open');
|
||||||
document.body.classList.remove('detail-zen-open');
|
document.body.classList.remove('detail-zen-open');
|
||||||
|
|||||||
@@ -49,10 +49,20 @@
|
|||||||
bucket_uid TEXT NOT NULL,
|
bucket_uid TEXT NOT NULL,
|
||||||
last_push_at TEXT,
|
last_push_at TEXT,
|
||||||
last_remote_at TEXT,
|
last_remote_at TEXT,
|
||||||
|
last_seen_at TEXT,
|
||||||
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
|
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)");
|
$db->exec("CREATE INDEX IF NOT EXISTS idx_collab_card_state_uid ON collab_card_state(relay_uid)");
|
||||||
|
// Per-card unread tracking (added 2026-04-14). Existing deployments need
|
||||||
|
// the column added explicitly.
|
||||||
|
$ccsCols = array_column($db->query('PRAGMA table_info(collab_card_state)')->fetchAll(), 'name');
|
||||||
|
if (!in_array('last_seen_at', $ccsCols)) {
|
||||||
|
$db->exec("ALTER TABLE collab_card_state ADD COLUMN last_seen_at TEXT");
|
||||||
|
// Backfill: everything existing gets seen=now so we don't flood the
|
||||||
|
// user with a board full of red dots after they deploy this.
|
||||||
|
$db->exec("UPDATE collab_card_state SET last_seen_at = datetime('now')");
|
||||||
|
}
|
||||||
|
|
||||||
// Tombstone outbox: populated by a BEFORE DELETE trigger on the cards
|
// 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
|
// table. The poll's push phase drains it and sends each entry as a
|
||||||
@@ -449,12 +459,13 @@ function collabSyncCards(PDO $db): array {
|
|||||||
|
|
||||||
$assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id');
|
$assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id');
|
||||||
$stateUpsert = $db->prepare("
|
$stateUpsert = $db->prepare("
|
||||||
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
|
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at)
|
||||||
VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL))
|
VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL), :push2)
|
||||||
ON CONFLICT(card_id) DO UPDATE SET
|
ON CONFLICT(card_id) DO UPDATE SET
|
||||||
relay_uid = excluded.relay_uid,
|
relay_uid = excluded.relay_uid,
|
||||||
bucket_uid = excluded.bucket_uid,
|
bucket_uid = excluded.bucket_uid,
|
||||||
last_push_at = excluded.last_push_at
|
last_push_at = excluded.last_push_at,
|
||||||
|
last_seen_at = excluded.last_seen_at
|
||||||
");
|
");
|
||||||
|
|
||||||
foreach ($dirty as $card) {
|
foreach ($dirty as $card) {
|
||||||
@@ -467,6 +478,31 @@ function collabSyncCards(PDO $db): array {
|
|||||||
$payload = collabEncryptPayload(collabBuildCardPayload($card));
|
$payload = collabEncryptPayload(collabBuildCardPayload($card));
|
||||||
if ($payload === null) continue; // no relay_key or openssl failure
|
if ($payload === null) continue; // no relay_key or openssl failure
|
||||||
|
|
||||||
|
// Attachments FIRST, card second. If a peer polls between the two
|
||||||
|
// HTTP calls, they'd see the new card body with an `uploads/xyz.png`
|
||||||
|
// reference but the attachment wouldn't be on the relay yet — their
|
||||||
|
// fetch returns empty and the image renders broken permanently
|
||||||
|
// (the pull short-circuits on the card's next poll because
|
||||||
|
// updated_at doesn't change). Pushing attachments first guarantees
|
||||||
|
// the relay's state is consistent: if the card is visible, its
|
||||||
|
// images are fetchable.
|
||||||
|
$body = $card['body'] ?? '';
|
||||||
|
if ($body && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $body, $fm)) {
|
||||||
|
foreach (array_unique($fm[1]) as $fn) {
|
||||||
|
$localPath = UPLOAD_DIR . '/' . $fn;
|
||||||
|
if (!is_file($localPath)) continue;
|
||||||
|
$bytes = file_get_contents($localPath);
|
||||||
|
if ($bytes === false) continue;
|
||||||
|
$encoded = collabEncryptPayload(['raw' => base64_encode($bytes)]);
|
||||||
|
if ($encoded === null) continue;
|
||||||
|
collabRelayCall('/api/collab-attachments', 'POST', [
|
||||||
|
'bucket_uid' => $card['bucket_uid'],
|
||||||
|
'filename' => $fn,
|
||||||
|
'data' => $encoded,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$res = collabRelayCall('/api/collab-cards', 'POST', [
|
$res = collabRelayCall('/api/collab-cards', 'POST', [
|
||||||
'bucket_uid' => $card['bucket_uid'],
|
'bucket_uid' => $card['bucket_uid'],
|
||||||
'card_uid' => $relayUid,
|
'card_uid' => $relayUid,
|
||||||
@@ -491,27 +527,9 @@ function collabSyncCards(PDO $db): array {
|
|||||||
'ru' => $relayUid,
|
'ru' => $relayUid,
|
||||||
'bu' => $card['bucket_uid'],
|
'bu' => $card['bucket_uid'],
|
||||||
'push' => $res['updated_at'] ?? $card['updated_at'],
|
'push' => $res['updated_at'] ?? $card['updated_at'],
|
||||||
|
'push2'=> $res['updated_at'] ?? $card['updated_at'],
|
||||||
]);
|
]);
|
||||||
$stats['pushed']++;
|
$stats['pushed']++;
|
||||||
|
|
||||||
// Push referenced local attachments to the bucket (encrypted,
|
|
||||||
// member-scoped). Extract `uploads/<hex>.<ext>` from the body.
|
|
||||||
$body = $card['body'] ?? '';
|
|
||||||
if ($body && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $body, $fm)) {
|
|
||||||
foreach (array_unique($fm[1]) as $fn) {
|
|
||||||
$localPath = UPLOAD_DIR . '/' . $fn;
|
|
||||||
if (!is_file($localPath)) continue;
|
|
||||||
$bytes = file_get_contents($localPath);
|
|
||||||
if ($bytes === false) continue;
|
|
||||||
$encoded = collabEncryptPayload(['raw' => base64_encode($bytes)]);
|
|
||||||
if ($encoded === null) continue;
|
|
||||||
collabRelayCall('/api/collab-attachments', 'POST', [
|
|
||||||
'bucket_uid' => $card['bucket_uid'],
|
|
||||||
'filename' => $fn,
|
|
||||||
'data' => $encoded,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Pull deltas ─────────────────────────────────────
|
// ── 3. Pull deltas ─────────────────────────────────────
|
||||||
@@ -629,21 +647,30 @@ function collabSyncCards(PDO $db): array {
|
|||||||
$localId = (int)$db->lastInsertId();
|
$localId = (int)$db->lastInsertId();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the remote state so we don't push it back on the next poll
|
// Record the remote state so we don't push it back on the next
|
||||||
|
// poll. last_seen_at semantics: self-authored updates (our own
|
||||||
|
// echo) mark as seen; foreign-authored updates leave last_seen_at
|
||||||
|
// untouched so the red dot fires on the board.
|
||||||
|
$seenAt = $foreignAuthor ? null : $c['updated_at'];
|
||||||
$db->prepare("
|
$db->prepare("
|
||||||
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
|
INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at)
|
||||||
VALUES (:cid, :ru, :bu, :p, :r)
|
VALUES (:cid, :ru, :bu, :p, :r, :s)
|
||||||
ON CONFLICT(card_id) DO UPDATE SET
|
ON CONFLICT(card_id) DO UPDATE SET
|
||||||
relay_uid = excluded.relay_uid,
|
relay_uid = excluded.relay_uid,
|
||||||
bucket_uid = excluded.bucket_uid,
|
bucket_uid = excluded.bucket_uid,
|
||||||
last_push_at = excluded.last_push_at,
|
last_push_at = excluded.last_push_at,
|
||||||
last_remote_at = excluded.last_remote_at
|
last_remote_at = excluded.last_remote_at,
|
||||||
|
last_seen_at = CASE
|
||||||
|
WHEN excluded.last_seen_at IS NOT NULL THEN excluded.last_seen_at
|
||||||
|
ELSE collab_card_state.last_seen_at
|
||||||
|
END
|
||||||
")->execute([
|
")->execute([
|
||||||
'cid' => $localId,
|
'cid' => $localId,
|
||||||
'ru' => $c['card_uid'],
|
'ru' => $c['card_uid'],
|
||||||
'bu' => $c['bucket_uid'],
|
'bu' => $c['bucket_uid'],
|
||||||
'p' => $c['updated_at'],
|
'p' => $c['updated_at'],
|
||||||
'r' => $c['updated_at'],
|
'r' => $c['updated_at'],
|
||||||
|
's' => $seenAt,
|
||||||
]);
|
]);
|
||||||
$stats['pulled']++;
|
$stats['pulled']++;
|
||||||
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
|
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
|
||||||
@@ -701,6 +728,40 @@ function collabSyncCards(PDO $db): array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs);
|
if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs);
|
||||||
|
|
||||||
|
// Self-heal phase: walk every card in a collab bucket and try to fetch
|
||||||
|
// any `uploads/xxx.png` reference whose file isn't on disk. Covers the
|
||||||
|
// historical race where a card body arrived before its attachment, as
|
||||||
|
// well as any one-off fetch failure (network blip, timeout) that left
|
||||||
|
// a card with a broken image reference. Normal-case cost is trivial:
|
||||||
|
// one SQL, one `file_exists()` per reference, no HTTP when everything
|
||||||
|
// is already in place.
|
||||||
|
$missing = $db->query("
|
||||||
|
SELECT c.id, c.body, b.collab_uid AS bucket_uid
|
||||||
|
FROM cards c
|
||||||
|
INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL
|
||||||
|
WHERE c.body LIKE '%uploads/%'
|
||||||
|
")->fetchAll();
|
||||||
|
$uploadDir = UPLOAD_DIR;
|
||||||
|
if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true);
|
||||||
|
foreach ($missing as $mc) {
|
||||||
|
if (!preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $mc['body'] ?? '', $fm)) continue;
|
||||||
|
foreach (array_unique($fm[1]) as $fn) {
|
||||||
|
$dest = $uploadDir . '/' . $fn;
|
||||||
|
if (file_exists($dest)) continue;
|
||||||
|
$attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($mc['bucket_uid']) . '&filename=' . urlencode($fn));
|
||||||
|
if (!($attRes['ok'] ?? false)) continue;
|
||||||
|
foreach (($attRes['attachments'] ?? []) as $a) {
|
||||||
|
if (($a['filename'] ?? '') !== $fn) continue;
|
||||||
|
$decoded = collabDecryptPayload($a['data'] ?? '');
|
||||||
|
if ($decoded && isset($decoded['raw'])) {
|
||||||
|
$bytes = base64_decode($decoded['raw']);
|
||||||
|
if ($bytes !== false) file_put_contents($dest, $bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $stats;
|
return $stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,6 +958,16 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
// Any pending outbox entries still waiting for a reachable relay
|
// Any pending outbox entries still waiting for a reachable relay
|
||||||
$outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn();
|
$outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn();
|
||||||
|
|
||||||
|
// Per-card unread set: cards whose local updated_at is newer than
|
||||||
|
// their last_seen_at (or never seen). Frontend uses this to draw
|
||||||
|
// the little red dot on the board card.
|
||||||
|
$unreadCards = array_map('intval', $db->query("
|
||||||
|
SELECT cs.card_id
|
||||||
|
FROM collab_card_state cs
|
||||||
|
JOIN cards c ON c.id = cs.card_id
|
||||||
|
WHERE cs.last_seen_at IS NULL OR c.updated_at > cs.last_seen_at
|
||||||
|
")->fetchAll(PDO::FETCH_COLUMN));
|
||||||
|
|
||||||
collabJson([
|
collabJson([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'self' => collabRelaySettings()['user'],
|
'self' => collabRelaySettings()['user'],
|
||||||
@@ -907,6 +978,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
'locks' => $locks,
|
'locks' => $locks,
|
||||||
'outbox_processed' => $outboxProcessed,
|
'outbox_processed' => $outboxProcessed,
|
||||||
'outbox_pending' => $outboxPending,
|
'outbox_pending' => $outboxPending,
|
||||||
|
'unread_cards' => $unreadCards,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,6 +993,17 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
|||||||
collabJson(['ok' => true]);
|
collabJson(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mark a single collab card as seen (clears its red dot) ────
|
||||||
|
if ($route === '/api/collab-buckets/mark-card-seen' && $method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$cardId = (int)($input['card_id'] ?? 0);
|
||||||
|
if ($cardId <= 0) collabJson(['ok' => false, 'error' => 'card_id required'], 400);
|
||||||
|
Database::connect()
|
||||||
|
->prepare("UPDATE collab_card_state SET last_seen_at = datetime('now') WHERE card_id = :c")
|
||||||
|
->execute(['c' => $cardId]);
|
||||||
|
collabJson(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Proxy: relay tenants (for the create-bucket member picker) ──
|
// ── Proxy: relay tenants (for the create-bucket member picker) ──
|
||||||
// The relay's /api/tenants returns {tenants, self} without an `ok` field,
|
// The relay's /api/tenants returns {tenants, self} without an `ok` field,
|
||||||
// so we wrap it here to match the shape the frontend expects.
|
// so we wrap it here to match the shape the frontend expects.
|
||||||
|
|||||||
@@ -359,3 +359,21 @@ body.anim-slow .bucket-tab.bucket-tab-collab.pulsing {
|
|||||||
.collab-conflict-toast.collab-lock-toast b {
|
.collab-conflict-toast.collab-lock-toast b {
|
||||||
color: var(--t-accent2);
|
color: var(--t-accent2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Per-card unread indicator ──
|
||||||
|
Mirror of the bucket-tab red dot: a card with a foreign edit we haven't
|
||||||
|
opened yet gets a small glowing dot pinned to the top-right corner. */
|
||||||
|
.board-card.collab-card-unread { position: relative; }
|
||||||
|
.board-card.collab-card-unread .collab-card-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
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;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ const $ = (id) => document.getElementById(id);
|
|||||||
|
|
||||||
// uid -> collab bucket meta (from /api/collab-buckets)
|
// uid -> collab bucket meta (from /api/collab-buckets)
|
||||||
let collabByUid = new Map();
|
let collabByUid = new Map();
|
||||||
|
// card_id (number) -> true for cards with foreign edits we haven't opened yet
|
||||||
|
let unreadCards = new Set();
|
||||||
let pollHandle = null;
|
let pollHandle = null;
|
||||||
const POLL_INTERVAL_MS = 30000;
|
const POLL_INTERVAL_MS = 30000;
|
||||||
|
|
||||||
@@ -92,6 +94,12 @@ async function pollOnce() {
|
|||||||
const n = res.outbox_processed;
|
const n = res.outbox_processed;
|
||||||
showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`);
|
showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`);
|
||||||
}
|
}
|
||||||
|
// Per-card unread list: cards with foreign edits we haven't opened
|
||||||
|
// yet. Decorated on the board via decorateBoardUnread().
|
||||||
|
if (Array.isArray(res.unread_cards)) {
|
||||||
|
unreadCards = new Set(res.unread_cards.map(Number));
|
||||||
|
decorateBoardUnread();
|
||||||
|
}
|
||||||
// Phase 4: edit-lock list comes back with the poll
|
// Phase 4: edit-lock list comes back with the poll
|
||||||
if (Array.isArray(res.locks)) {
|
if (Array.isArray(res.locks)) {
|
||||||
const next = new Map();
|
const next = new Map();
|
||||||
@@ -638,12 +646,71 @@ function decorateBoardLocks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk every rendered board card and add / remove the "red dot" unread
|
||||||
|
* indicator for cards with foreign edits we haven't opened yet.
|
||||||
|
* Idempotent — safe to re-run on every board re-render.
|
||||||
|
*/
|
||||||
|
function decorateBoardUnread() {
|
||||||
|
const board = document.getElementById('board');
|
||||||
|
if (!board) return;
|
||||||
|
for (const el of board.querySelectorAll('.board-card[data-id]')) {
|
||||||
|
const cid = parseInt(el.dataset.id);
|
||||||
|
const isUnread = unreadCards.has(cid);
|
||||||
|
el.classList.toggle('collab-card-unread', isUnread);
|
||||||
|
let dot = el.querySelector('.collab-card-dot');
|
||||||
|
if (isUnread && !dot) {
|
||||||
|
dot = document.createElement('span');
|
||||||
|
dot.className = 'collab-card-dot';
|
||||||
|
el.appendChild(dot);
|
||||||
|
} else if (!isUnread && dot) {
|
||||||
|
dot.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear a card's unread state locally + on the backend. Called from
|
||||||
|
// - board click (piggy-backing on the natural open-card interaction)
|
||||||
|
// - detail.opened hook (when the detail view opens via any path:
|
||||||
|
// keyboard shortcut, related-card link, backlink, search result, etc.)
|
||||||
|
function clearCardUnread(cid) {
|
||||||
|
cid = parseInt(cid);
|
||||||
|
if (!cid || !unreadCards.has(cid)) return;
|
||||||
|
unreadCards.delete(cid);
|
||||||
|
const el = document.querySelector(`#board .board-card[data-id="${cid}"]`);
|
||||||
|
if (el) {
|
||||||
|
el.classList.remove('collab-card-unread');
|
||||||
|
el.querySelector('.collab-card-dot')?.remove();
|
||||||
|
}
|
||||||
|
hyprcrd.api('api/collab-buckets/mark-card-seen', {
|
||||||
|
method: 'POST', body: { card_id: cid },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupCardClickInterceptor() {
|
||||||
|
const board = document.getElementById('board');
|
||||||
|
if (board) {
|
||||||
|
board.addEventListener('click', (e) => {
|
||||||
|
const el = e.target.closest('.board-card[data-id]');
|
||||||
|
if (el) clearCardUnread(el.dataset.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Detail view has its own open path (keyboard shortcuts, backlinks,
|
||||||
|
// related-card navigation) that doesn't route through a board-card
|
||||||
|
// click. Listen for the explicit detail.opened hook so every open
|
||||||
|
// method clears the dot.
|
||||||
|
hyprcrd.hooks?.on?.('detail.opened', (data) => clearCardUnread(data?.id));
|
||||||
|
}
|
||||||
|
|
||||||
// Re-decorate whenever the board re-renders (reuses the existing #board
|
// Re-decorate whenever the board re-renders (reuses the existing #board
|
||||||
// MutationObserver pattern).
|
// MutationObserver pattern).
|
||||||
function setupBoardObserver() {
|
function setupBoardObserver() {
|
||||||
const board = document.getElementById('board');
|
const board = document.getElementById('board');
|
||||||
if (!board) return;
|
if (!board) return;
|
||||||
const obs = new MutationObserver(() => decorateBoardLocks());
|
const obs = new MutationObserver(() => {
|
||||||
|
decorateBoardLocks();
|
||||||
|
decorateBoardUnread();
|
||||||
|
});
|
||||||
obs.observe(board, { childList: true, subtree: false });
|
obs.observe(board, { childList: true, subtree: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,12 +966,14 @@ async function init() {
|
|||||||
}
|
}
|
||||||
setupBarObserver();
|
setupBarObserver();
|
||||||
setupClickInterceptor();
|
setupClickInterceptor();
|
||||||
|
setupCardClickInterceptor();
|
||||||
setupBoardObserver();
|
setupBoardObserver();
|
||||||
await loadCollabMeta();
|
await loadCollabMeta();
|
||||||
await pollOnce();
|
await pollOnce();
|
||||||
await loadCollabMeta();
|
await loadCollabMeta();
|
||||||
decorateBucketBar();
|
decorateBucketBar();
|
||||||
decorateBoardLocks();
|
decorateBoardLocks();
|
||||||
|
decorateBoardUnread();
|
||||||
startPolling();
|
startPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ if (preg_match('#^/api/users/(\d+)$#', $route, $m) && $method === 'DELETE') {
|
|||||||
if ($id === (int)$sessionTenant['id']) {
|
if ($id === (int)$sessionTenant['id']) {
|
||||||
jsonResponse(['error' => 'cannot delete yourself'], 400);
|
jsonResponse(['error' => 'cannot delete yourself'], 400);
|
||||||
}
|
}
|
||||||
|
// chat_messages.recipient_id cascades via FK, but sender_id is ON DELETE
|
||||||
|
// SET NULL — that leaves sender_username populated so the deleted user
|
||||||
|
// keeps showing up as a chat thread for their peers. Purge those rows
|
||||||
|
// explicitly BEFORE the tenant delete, while sender_id is still matchable.
|
||||||
|
$db->prepare('DELETE FROM chat_messages WHERE sender_id = :id')->execute(['id' => $id]);
|
||||||
$db->prepare('DELETE FROM tenants WHERE id = :id')->execute(['id' => $id]);
|
$db->prepare('DELETE FROM tenants WHERE id = :id')->execute(['id' => $id]);
|
||||||
jsonResponse(['ok' => true]);
|
jsonResponse(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user