diff --git a/shared/components/rstack-comment-bell.ts b/shared/components/rstack-comment-bell.ts index b700929..455c099 100644 --- a/shared/components/rstack-comment-bell.ts +++ b/shared/components/rstack-comment-bell.ts @@ -1,10 +1,10 @@ /** - * — Comment button with unresolved-count badge. + * — Comment button with dropdown panel. * * Shows a chat-bubble icon in the header bar. Badge displays the count - * of unresolved comment pins on the current canvas. Clicking dispatches - * a `comment-pin-activate` event on `window` so canvas.html can enter - * pin-placement mode. + * of unresolved comment pins on the current canvas. Clicking toggles a + * dropdown panel showing all comment threads, sorted by most recent + * message. Includes a "New Comment" button to enter pin-placement mode. * * Data source: `window.__communitySync?.doc?.commentPins` * Listens for `comment-pins-changed` on `window` (re-dispatched by canvas). @@ -13,9 +13,26 @@ const POLL_INTERVAL = 5_000; +interface CommentMessage { + text: string; + createdBy: string; + createdByName?: string; + createdAt: number; +} + +interface CommentPinData { + messages: CommentMessage[]; + anchor?: { x: number; y: number }; + resolved?: boolean; + createdBy?: string; + createdByName?: string; + createdAt?: number; +} + export class RStackCommentBell extends HTMLElement { #shadow: ShadowRoot; #count = 0; + #open = false; #pollTimer: ReturnType | null = null; #syncRef: any = null; @@ -26,12 +43,10 @@ export class RStackCommentBell extends HTMLElement { connectedCallback() { this.#render(); - // Try to pick up existing CommunitySync this.#syncRef = (window as any).__communitySync || null; this.#refreshCount(); this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL); window.addEventListener("comment-pins-changed", this.#onPinsChanged); - // Listen for CommunitySync becoming available (may connect after mount) document.addEventListener("community-sync-ready", this.#onSyncReady); } @@ -51,10 +66,10 @@ export class RStackCommentBell extends HTMLElement { #onPinsChanged = () => { this.#refreshCount(); + if (this.#open) this.#render(); }; #refreshCount() { - // Prefer cached ref, fall back to global const sync = this.#syncRef || (window as any).__communitySync; if (sync && !this.#syncRef) this.#syncRef = sync; const pins = sync?.doc?.commentPins; @@ -74,35 +89,177 @@ export class RStackCommentBell extends HTMLElement { } } - /** Full render — only called once in connectedCallback. */ - #render() { - this.#shadow.innerHTML = ` - - - `; - - this.#shadow - .querySelector(".comment-btn") - ?.addEventListener("click", (e) => { - e.stopPropagation(); - window.dispatchEvent(new CustomEvent("comment-pin-activate")); - }); + #togglePanel() { + this.#open = !this.#open; + this.#render(); } - /** Lightweight badge update — no innerHTML churn. */ + #getPins(): [string, CommentPinData][] { + const sync = this.#syncRef || (window as any).__communitySync; + const pins = sync?.doc?.commentPins; + if (!pins) return []; + + const entries = Object.entries(pins) as [string, CommentPinData][]; + // Sort by most recent message timestamp, descending + entries.sort((a, b) => { + const aTime = this.#latestMessageTime(a[1]); + const bTime = this.#latestMessageTime(b[1]); + return bTime - aTime; + }); + return entries.slice(0, 50); + } + + #latestMessageTime(pin: CommentPinData): number { + if (!pin.messages || pin.messages.length === 0) return pin.createdAt || 0; + return Math.max(...pin.messages.map(m => m.createdAt || 0)); + } + + #timeAgo(ts: number): string { + if (!ts) return ""; + const diff = Date.now() - ts; + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; + } + + #esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + #truncate(s: string, max = 80): string { + if (s.length <= max) return s; + return s.slice(0, max) + "\u2026"; + } + + #render() { + const badge = this.#count > 0 + ? `${this.#count > 99 ? "99+" : this.#count}` + : ""; + + let panelHTML = ""; + if (this.#open) { + const pins = this.#getPins(); + const header = ` +
+ Comments + +
+ `; + + let body: string; + if (pins.length === 0) { + body = `
No comments yet
`; + } else { + body = pins.map(([pinId, pin]) => { + const lastMsg = pin.messages?.[pin.messages.length - 1]; + const authorName = lastMsg?.createdByName || pin.createdByName || "Unknown"; + const initial = authorName.charAt(0).toUpperCase(); + const text = lastMsg?.text || "(no message)"; + const time = this.#timeAgo(this.#latestMessageTime(pin)); + const threadCount = (pin.messages?.length || 0); + const resolvedBadge = pin.resolved + ? `Resolved` + : `Open`; + + return ` +
+
${initial}
+
+
+ ${this.#esc(authorName)} + ${resolvedBadge} +
+
${this.#esc(this.#truncate(text))}
+
+ ${threadCount > 1 ? `${threadCount} messages` : ""} + ${time} +
+
+
`; + }).join(""); + } + + panelHTML = `
${header}
${body}
`; + } + + this.#shadow.innerHTML = ` + +
+ + ${panelHTML} +
+ `; + + // Toggle button + this.#shadow.getElementById("comment-toggle")?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#togglePanel(); + }); + + // Close on outside click + if (this.#open) { + const closeHandler = () => { + if (this.#open) { + this.#open = false; + this.#render(); + } + }; + document.addEventListener("click", closeHandler, { once: true }); + + // Stop propagation from panel clicks + this.#shadow.querySelector(".panel")?.addEventListener("click", (e) => e.stopPropagation()); + } + + // New Comment button + this.#shadow.querySelector('[data-action="new-comment"]')?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#open = false; + this.#render(); + window.dispatchEvent(new CustomEvent("comment-pin-activate")); + }); + + // Comment item clicks — focus the pin on canvas + this.#shadow.querySelectorAll(".comment-item").forEach((el) => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + const pinId = (el as HTMLElement).dataset.pinId; + if (pinId) { + this.#open = false; + this.#render(); + window.dispatchEvent(new CustomEvent("comment-pin-focus", { detail: { pinId } })); + } + }); + }); + } + + /** Lightweight badge update when panel is closed. */ #updateBadge() { + if (this.#open) { + this.#render(); + return; + } const badge = this.#shadow.querySelector(".badge") as HTMLElement | null; - if (!badge) return; - if (this.#count > 0) { - badge.textContent = this.#count > 99 ? "99+" : String(this.#count); - badge.style.display = ""; - } else { - badge.style.display = "none"; + if (!badge && this.#count > 0) { + // Need full re-render to add badge + this.#render(); + return; + } + if (badge) { + if (this.#count > 0) { + badge.textContent = this.#count > 99 ? "99+" : String(this.#count); + badge.style.display = ""; + } else { + badge.style.display = "none"; + } } } @@ -117,6 +274,10 @@ const STYLES = ` align-items: center; } +.bell-wrapper { + position: relative; +} + .comment-btn { position: relative; background: none; @@ -154,7 +315,170 @@ const STYLES = ` pointer-events: none; } +.panel { + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + width: 380px; + max-height: 480px; + display: flex; + flex-direction: column; + border-radius: 10px; + background: var(--rs-bg-surface, #1e293b); + border: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + box-shadow: var(--rs-shadow-lg, 0 8px 30px rgba(0,0,0,0.3)); + z-index: 200; + animation: dropDown 0.15s ease; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +@keyframes dropDown { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.1)); + flex-shrink: 0; +} + +.panel-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--rs-text-primary, #e2e8f0); +} + +.new-comment-btn { + background: linear-gradient(135deg, #14b8a6, #0d9488); + border: none; + color: white; + font-size: 0.75rem; + font-weight: 600; + padding: 5px 12px; + border-radius: 6px; + cursor: pointer; + transition: opacity 0.15s; +} +.new-comment-btn:hover { + opacity: 0.85; +} + +.panel-body { + flex: 1; + overflow-y: auto; +} + +.panel-empty { + padding: 32px 16px; + text-align: center; + color: var(--rs-text-muted, #94a3b8); + font-size: 0.8rem; +} + +.comment-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 16px; + cursor: pointer; + transition: background 0.15s; + border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06)); +} +.comment-item:hover { + background: var(--rs-bg-hover, rgba(255,255,255,0.05)); +} +.comment-item:last-child { + border-bottom: none; +} +.comment-item.resolved { + opacity: 0.6; +} + +.comment-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(20,184,166,0.15); + color: #14b8a6; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 0.75rem; + flex-shrink: 0; + margin-top: 2px; +} + +.comment-content { + flex: 1; + min-width: 0; +} + +.comment-top { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 2px; +} + +.comment-author { + font-size: 0.8rem; + font-weight: 600; + color: var(--rs-text-primary, #e2e8f0); +} + +.resolved-badge, .open-badge { + font-size: 0.6rem; + font-weight: 700; + padding: 1px 5px; + border-radius: 9999px; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.resolved-badge { + background: rgba(148,163,184,0.15); + color: #94a3b8; +} +.open-badge { + background: rgba(20,184,166,0.15); + color: #14b8a6; +} + +.comment-text { + font-size: 0.78rem; + color: var(--rs-text-secondary, #cbd5e1); + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comment-meta { + display: flex; + gap: 8px; + margin-top: 3px; + font-size: 0.68rem; + color: var(--rs-text-muted, #64748b); +} + +.thread-count { + font-weight: 500; +} + @media (max-width: 640px) { .comment-btn { display: none; } + .panel { + position: fixed; + top: 60px; + right: 8px; + left: 8px; + width: auto; + max-height: calc(100vh - 80px); + } } `; diff --git a/shared/components/rstack-space-settings.ts b/shared/components/rstack-space-settings.ts index 67c4fe2..c60788a 100644 --- a/shared/components/rstack-space-settings.ts +++ b/shared/components/rstack-space-settings.ts @@ -413,9 +413,7 @@ export class RStackSpaceSettings extends HTMLElement { `; } - const panelTitle = this._moduleConfig - ? `${this._moduleConfig.icon} ${this._esc(this._moduleConfig.name)}` - : "Space Settings"; + const panelTitle = "Space Settings"; this.shadowRoot.innerHTML = ` @@ -426,24 +424,6 @@ export class RStackSpaceSettings extends HTMLElement {
- ${moduleSettingsHTML} - - ${this._isAdmin ? ` -
-

Space Settings

- - - - - - ${this._spaceSaveMsg ? `${this._esc(this._spaceSaveMsg)}` : ""} -
- ` : ""} -

Members ${this._members.length}

${membersHTML}
@@ -507,6 +487,24 @@ export class RStackSpaceSettings extends HTMLElement {
${invitesHTML}
` : ""} + + ${moduleSettingsHTML} + + ${this._isAdmin ? ` +
+

Space Settings

+ + + + + + ${this._spaceSaveMsg ? `${this._esc(this._spaceSaveMsg)}` : ""} +
+ ` : ""}
`;