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:
parent
a7eda3c53f
commit
32524fdf00
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -187,6 +187,10 @@ export class RSpaceYjsProvider {
|
|||
return this.synced;
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
// Remove local awareness state
|
||||
removeAwarenessStates(
|
||||
|
|
|
|||
Loading…
Reference in New Issue