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 // Mobile sidebar toggle
const mobileToggle = document.createElement('button'); const mobileToggle = document.createElement('button');
mobileToggle.className = 'mobile-sidebar-toggle'; 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', () => { mobileToggle.addEventListener('click', () => {
this.sidebarOpen = !this.sidebarOpen; this.sidebarOpen = !this.sidebarOpen;
this.navZone.querySelector('.notes-sidebar')?.classList.toggle('open', 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) { private async openNote(noteId: string, notebookId: string) {
const isDemo = this.space === "demo"; 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 // Expand notebook if not expanded
if (!this.expandedNotebooks.has(notebookId)) { if (!this.expandedNotebooks.has(notebookId)) {
this.expandedNotebooks.add(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"> <div class="editor-wrapper">
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title..."> <input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title...">
${isEditable ? this.renderToolbar() : ''} ${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 class="tiptap-container" id="tiptap-container"></div>
</div> </div>
`; `;
@ -1333,8 +1344,16 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
name: session.username || 'Anonymous', name: session.username || 'Anonymous',
color: this.userColor(session.userId || 'anon'), 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) // Periodic plaintext sync to Automerge (for search indexing)
this.yjsPlainTextTimer = setInterval(() => { this.yjsPlainTextTimer = setInterval(() => {
if (!this.editor || !this.editorNoteId) return; 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() { private updatePeersIndicator() {
const peersEl = this.shadow.getElementById('collab-peers'); const peersEl = this.shadow.getElementById('collab-peers');
const statusBar = this.shadow.getElementById('collab-status-bar');
if (!peersEl || !this.yjsProvider) { if (!peersEl || !this.yjsProvider) {
if (peersEl) peersEl.style.display = 'none'; if (peersEl) peersEl.style.display = 'none';
if (statusBar) statusBar.style.display = 'none';
return; return;
} }
const connected = this.yjsProvider.isConnected;
const states = this.yjsProvider.awareness.getStates(); const states = this.yjsProvider.awareness.getStates();
const peerCount = states.size - 1; // Exclude self const peerCount = states.size - 1; // Exclude self
// Toolbar peer dots
if (peerCount > 0) { if (peerCount > 0) {
peersEl.style.display = 'inline-flex'; peersEl.style.display = 'inline-flex';
peersEl.innerHTML = ''; peersEl.innerHTML = '';
@ -2413,6 +2438,38 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
} else { } else {
peersEl.style.display = 'none'; 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 ── // ── Helpers ──
@ -2517,6 +2574,10 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
<div class="sidebar-tree"> <div class="sidebar-tree">
${treeHtml} ${treeHtml}
</div> </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"> <div class="sidebar-footer">
<button class="sidebar-footer-btn" id="btn-import-export">Import / Export</button> <button class="sidebar-footer-btn" id="btn-import-export">Import / Export</button>
<button class="sidebar-footer-btn" id="btn-tour">Tour</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-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 */
.sidebar-search-results { padding: 4px 0; } .sidebar-search-results { padding: 4px 0; }
.sidebar-search-result { .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 */
.mobile-sidebar-toggle { .mobile-sidebar-toggle {
display: none; position: fixed; bottom: 20px; left: 20px; z-index: 198; display: none; position: fixed; bottom: 20px; left: 20px; z-index: 198;
width: 44px; height: 44px; border-radius: 50%; border: none; min-width: 44px; height: 44px; border-radius: 22px; border: none;
background: var(--rs-primary); color: #fff; font-size: 20px; padding: 0 14px; gap: 4px;
background: var(--rs-primary); color: #fff; font-size: 13px;
cursor: pointer; box-shadow: var(--rs-shadow-md); cursor: pointer; box-shadow: var(--rs-shadow-md);
align-items: center; justify-content: center; align-items: center; justify-content: center;
} }
.mobile-toggle-icon { font-size: 18px; }
.mobile-toggle-label { font-weight: 600; font-family: inherit; }
.sidebar-overlay { .sidebar-overlay {
display: none; position: fixed; inset: 0; display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.4); z-index: 199; 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; 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 ── */ /* ── Collaboration: Peers Indicator ── */
.collab-tools { display: flex; align-items: center; gap: 4px; } .collab-tools { display: flex; align-items: center; gap: 4px; }
.collab-peers { .collab-peers {

View File

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

View File

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