feat(rnotes): add collab status bar, sidebar indicator, mobile UX polish

Adds a visible collab status bar between toolbar and editor showing
connection state (live editing with peer count, sync enabled, or offline).
Sidebar footer now shows a live collab indicator dot. Mobile sidebar
auto-closes when selecting a note. Mobile FAB button now shows "Docs"
label. Bumps cache version to v=5.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 16:11:49 -07:00
parent a7eda3c53f
commit 32524fdf00
3 changed files with 105 additions and 5 deletions

View File

@ -287,7 +287,7 @@ class FolkNotesApp extends HTMLElement {
// Mobile sidebar toggle
const mobileToggle = document.createElement('button');
mobileToggle.className = 'mobile-sidebar-toggle';
mobileToggle.innerHTML = '\u2630';
mobileToggle.innerHTML = '<span class="mobile-toggle-icon">\u{1F4C4}</span><span class="mobile-toggle-label">Docs</span>';
mobileToggle.addEventListener('click', () => {
this.sidebarOpen = !this.sidebarOpen;
this.navZone.querySelector('.notes-sidebar')?.classList.toggle('open', this.sidebarOpen);
@ -1049,6 +1049,13 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
private async openNote(noteId: string, notebookId: string) {
const isDemo = this.space === "demo";
// Auto-close sidebar on mobile
if (window.innerWidth <= 768) {
this.sidebarOpen = false;
this.navZone.querySelector('.notes-sidebar')?.classList.remove('open');
this.shadow.querySelector('.sidebar-overlay')?.classList.remove('open');
}
// Expand notebook if not expanded
if (!this.expandedNotebooks.has(notebookId)) {
this.expandedNotebooks.add(notebookId);
@ -1210,6 +1217,10 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
<div class="editor-wrapper">
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title...">
${isEditable ? this.renderToolbar() : ''}
<div class="collab-status-bar" id="collab-status-bar" style="display:none">
<span class="collab-status-dot"></span>
<span class="collab-status-text"></span>
</div>
<div class="tiptap-container" id="tiptap-container"></div>
</div>
`;
@ -1333,8 +1344,16 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
name: session.username || 'Anonymous',
color: this.userColor(session.userId || 'anon'),
});
// Update collab status bar when peers change
this.yjsProvider.awareness.on('update', () => {
this.updatePeersIndicator();
});
}
// Initial collab status bar update
this.updatePeersIndicator();
// Periodic plaintext sync to Automerge (for search indexing)
this.yjsPlainTextTimer = setInterval(() => {
if (!this.editor || !this.editorNoteId) return;
@ -2382,13 +2401,19 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
private updatePeersIndicator() {
const peersEl = this.shadow.getElementById('collab-peers');
const statusBar = this.shadow.getElementById('collab-status-bar');
if (!peersEl || !this.yjsProvider) {
if (peersEl) peersEl.style.display = 'none';
if (statusBar) statusBar.style.display = 'none';
return;
}
const connected = this.yjsProvider.isConnected;
const states = this.yjsProvider.awareness.getStates();
const peerCount = states.size - 1; // Exclude self
// Toolbar peer dots
if (peerCount > 0) {
peersEl.style.display = 'inline-flex';
peersEl.innerHTML = '';
@ -2413,6 +2438,38 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
} else {
peersEl.style.display = 'none';
}
// Collab status bar
if (statusBar) {
statusBar.style.display = 'flex';
const dot = statusBar.querySelector('.collab-status-dot') as HTMLElement;
const text = statusBar.querySelector('.collab-status-text') as HTMLElement;
if (!dot || !text) return;
if (!connected) {
dot.className = 'collab-status-dot offline';
text.textContent = 'Offline \u2014 changes will sync when reconnected';
} else if (peerCount > 0) {
dot.className = 'collab-status-dot live';
const names: string[] = [];
for (const [clientId, state] of states) {
if (clientId === this.ydoc?.clientID) continue;
names.push(state.user?.name || 'Anonymous');
}
text.textContent = `Live editing \u00B7 ${peerCount} collaborator${peerCount > 1 ? 's' : ''}`;
text.title = names.join(', ');
} else {
dot.className = 'collab-status-dot synced';
text.textContent = 'Live sync enabled';
text.title = '';
}
}
// Sidebar collab dot
const sidebarDot = this.navZone.querySelector('.sidebar-collab-dot') as HTMLElement;
if (sidebarDot) {
sidebarDot.className = `sidebar-collab-dot ${connected ? 'connected' : 'disconnected'}`;
}
}
// ── Helpers ──
@ -2517,6 +2574,10 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
<div class="sidebar-tree">
${treeHtml}
</div>
<div class="sidebar-collab-info" title="All editors in this space see changes in real-time. Share the space link to collaborate.">
<span class="sidebar-collab-dot ${this.yjsProvider?.isConnected ? 'connected' : 'disconnected'}"></span>
<span>Live Collab</span>
</div>
<div class="sidebar-footer">
<button class="sidebar-footer-btn" id="btn-import-export">Import / Export</button>
<button class="sidebar-footer-btn" id="btn-tour">Tour</button>
@ -2877,6 +2938,19 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
.sidebar-footer-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
/* Sidebar collab info */
.sidebar-collab-info {
display: flex; align-items: center; gap: 6px;
padding: 6px 12px; font-size: 11px; color: var(--rs-text-muted);
border-top: 1px solid var(--rs-border-subtle); cursor: default;
}
.sidebar-collab-dot {
width: 7px; height: 7px; border-radius: 50%;
flex-shrink: 0; background: #9ca3af;
}
.sidebar-collab-dot.connected { background: #22c55e; }
.sidebar-collab-dot.disconnected { background: #9ca3af; }
/* Sidebar search results */
.sidebar-search-results { padding: 4px 0; }
.sidebar-search-result {
@ -2906,11 +2980,14 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
/* Mobile sidebar */
.mobile-sidebar-toggle {
display: none; position: fixed; bottom: 20px; left: 20px; z-index: 198;
width: 44px; height: 44px; border-radius: 50%; border: none;
background: var(--rs-primary); color: #fff; font-size: 20px;
min-width: 44px; height: 44px; border-radius: 22px; border: none;
padding: 0 14px; gap: 4px;
background: var(--rs-primary); color: #fff; font-size: 13px;
cursor: pointer; box-shadow: var(--rs-shadow-md);
align-items: center; justify-content: center;
}
.mobile-toggle-icon { font-size: 18px; }
.mobile-toggle-label { font-weight: 600; font-family: inherit; }
.sidebar-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.4); z-index: 199;
@ -3331,6 +3408,25 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
height: 1.2em;
}
/* ── Collaboration: Status Bar ── */
.collab-status-bar {
display: none; align-items: center; gap: 8px;
padding: 4px 16px; height: 28px;
background: var(--rs-bg-hover, rgba(0,0,0,0.03));
border-top: 1px solid var(--rs-border-subtle);
border-bottom: 1px solid var(--rs-border-subtle);
font-size: 12px; color: var(--rs-text-muted);
}
.collab-status-dot {
width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0;
background: #9ca3af;
}
.collab-status-dot.live { background: #22c55e; }
.collab-status-dot.synced { background: #3b82f6; }
.collab-status-dot.offline { background: #9ca3af; }
.collab-status-text { white-space: nowrap; }
/* ── Collaboration: Peers Indicator ── */
.collab-tools { display: flex; align-items: center; gap: 4px; }
.collab-peers {

View File

@ -1615,8 +1615,8 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=4"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=4">`,
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=5"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`,
}));
});

View File

@ -187,6 +187,10 @@ export class RSpaceYjsProvider {
return this.synced;
}
get isConnected(): boolean {
return this.connected;
}
destroy(): void {
// Remove local awareness state
removeAwarenessStates(