diff --git a/public/components/notes.html b/public/components/notes.html
index e137c58..61533c7 100644
--- a/public/components/notes.html
+++ b/public/components/notes.html
@@ -3,7 +3,11 @@
A quiet place for your thoughts — jot ideas, paste references, or dictate and let AI tidy it up. Only you can see these.
-
+
+
-
+
+
+
+
+
Note title
+
+
+
+
+
+
+
+
+
+
@@ -67,7 +95,7 @@
-
+
@@ -78,7 +106,7 @@
Press Ctrl+S to save
-
Dictate works even offline with Browser Whisper
+
Dictate turns your voice into a clean note
Encrypted at rest — only you can read them
diff --git a/public/css/styles.css b/public/css/styles.css
index 5eae5ea..79def95 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -910,10 +910,15 @@ textarea.full-input{resize:vertical;}
.notes-empty-state p{font-size:14px;line-height:1.5;max-width:320px;margin:0;}
.notes-empty-state.hidden{display:none;}
-/* Voice-to-note recording bar inside the editor head */
+/* Voice-to-note recording bar inside the editor head.
+ Each rule has an explicit `.hidden` override — the generic
+ .hidden{display:none} at line 317 loses to these single-class
+ rules on source order, so we need to re-assert it here. */
.notes-voice-bar{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:0;}
.notes-voice-bar .btn-sm{font-size:12px;padding:6px 10px;}
+.notes-voice-bar .btn-sm.hidden{display:none;}
.notes-rec-indicator{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--red);font-weight:600;}
+.notes-rec-indicator.hidden{display:none;}
.notes-rec-indicator .pulse-dot{width:8px;height:8px;background:var(--red);border-radius:50%;animation:notes-pulse 1.2s ease-in-out infinite;}
.notes-rec-indicator .pulse-dot.paused{animation:none;opacity:0.5;}
@keyframes notes-pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.35;transform:scale(1.25);}}
@@ -950,3 +955,66 @@ textarea.full-input{resize:vertical;}
/* subtle card float on hover for the whole editor */
.notes-editor{transition:box-shadow 0.2s;}
.notes-editor:hover{box-shadow:0 4px 16px rgba(0,0,0,0.06);}
+
+/* ── Notes: reader mode + mobile layout ──────────────────────── */
+
+/* Reader pane — clean read-only view of a saved note */
+.notes-reader{background:white;border-radius:var(--radius);box-shadow:var(--shadow);display:flex;flex-direction:column;min-height:70vh;}
+.notes-reader.hidden{display:none;}
+.notes-reader-head{display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--g200);background:linear-gradient(180deg,#fdfdfd,#f9fafb);border-top-left-radius:var(--radius);border-top-right-radius:var(--radius);}
+.notes-reader-title{flex:1;margin:0;font-size:20px;font-weight:600;color:var(--g900);letter-spacing:-0.01em;overflow-wrap:anywhere;}
+.notes-reader-actions{display:flex;align-items:center;gap:6px;flex-shrink:0;}
+.notes-reader-body{flex:1;padding:22px 24px;font-size:15px;line-height:1.7;color:var(--g800);overflow-y:auto;}
+.notes-reader-body p{margin:0 0 12px;}
+.notes-reader-body h2{font-size:20px;font-weight:700;color:var(--g900);margin:20px 0 10px;}
+.notes-reader-body h3{font-size:16px;font-weight:700;color:var(--g800);margin:16px 0 8px;}
+.notes-reader-body ul,.notes-reader-body ol{margin:0 0 12px 24px;}
+.notes-reader-body li{margin-bottom:4px;}
+.notes-reader-body blockquote{border-left:3px solid var(--g300);margin:0 0 12px;padding:4px 0 4px 14px;color:var(--g600);font-style:italic;}
+.notes-reader-body code{background:var(--g100);padding:1px 6px;border-radius:4px;font-size:13.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
+.notes-reader-body pre{background:var(--g900);color:#e5e7eb;padding:12px 16px;border-radius:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;overflow-x:auto;margin:0 0 12px;}
+.notes-reader-body pre code{background:transparent;padding:0;color:inherit;}
+.notes-reader-body a{color:var(--blue);text-decoration:underline;}
+.notes-reader-foot{padding:10px 18px;border-top:1px solid var(--g200);font-size:11px;color:var(--g400);background:var(--g50);}
+
+/* Back button for mobile navigation (shown in reader + editor heads) */
+.notes-back{display:none !important;padding:6px 10px;}
+
+/* Mobile: single-pane layout driven by .notes-layout[data-view=…] */
+@media (max-width:900px){
+ .notes-layout{grid-template-columns:1fr;gap:0;}
+ .notes-sidebar,.notes-reader,.notes-editor,.notes-empty-state{max-height:none;}
+
+ /* Show only the pane matching data-view; hide the others */
+ .notes-layout[data-view="list"] .notes-reader,
+ .notes-layout[data-view="list"] .notes-editor,
+ .notes-layout[data-view="reader"] .notes-sidebar,
+ .notes-layout[data-view="reader"] .notes-editor,
+ .notes-layout[data-view="reader"] .notes-empty-state,
+ .notes-layout[data-view="editor"] .notes-sidebar,
+ .notes-layout[data-view="editor"] .notes-reader,
+ .notes-layout[data-view="editor"] .notes-empty-state,
+ .notes-layout[data-view="empty"] .notes-reader,
+ .notes-layout[data-view="empty"] .notes-editor{display:none !important;}
+
+ .notes-back{display:inline-flex !important;}
+
+ .notes-reader-head,.notes-editor-head{padding:10px 12px;gap:8px;}
+ .notes-reader-title{font-size:17px;}
+ .notes-reader-body{padding:16px;font-size:14.5px;}
+ .notes-reader-actions .btn-sm{padding:6px 10px;font-size:12px;}
+
+ /* Stack the editor action row on small screens so nothing wraps weird */
+ .notes-editor-head{flex-wrap:wrap;}
+ .notes-title-input{font-size:17px;min-width:140px;flex:1 1 100%;order:2;}
+ .notes-back{order:1;}
+ .notes-editor-actions{order:3;flex-wrap:wrap;gap:6px;width:100%;justify-content:flex-start;}
+
+ .notes-voice-bar{flex-wrap:wrap;}
+ .notes-empty-state{padding:40px 16px;}
+}
+
+/* Desktop: the list pane should never be hidden by data-view toggles */
+@media (min-width:901px){
+ .notes-layout[data-view] .notes-sidebar{display:flex !important;}
+}
diff --git a/public/js/notes.js b/public/js/notes.js
index dd3bf09..361ebfc 100644
--- a/public/js/notes.js
+++ b/public/js/notes.js
@@ -1,29 +1,43 @@
// ============================================================
// NOTES — per-user personal scratchpad under Clinical Tools.
// Rich-text via the app-wide Tiptap bundle (window.Tiptap).
-// Pure client-side; talks to /api/notes (auth-gated, encrypted
-// at rest via src/routes/notes.js + crypto util).
+// CRUD against /api/notes (auth + server-side encryption).
+//
+// Three panes — list / reader / editor. After opening or saving
+// a note, the reader shows a clean, read-only rendering; tap
+// Edit to switch to the Tiptap editor. Autosaves during edit
+// (1.2s quiet period) so you never lose progress if you close
+// the tab or the browser crashes.
+//
+// On mobile (<900px) only one pane is visible at a time — the
+// layout's data-view attribute controls which one. Desktop
+// always shows the sidebar + the right pane together.
// ============================================================
(function() {
var _inited = false;
var _notes = [];
- var _activeId = null;
- var _editor = null;
- var _dirty = false;
+ var _activeId = null; // currently open note id (reader OR editor)
+ var _editor = null; // Tiptap instance
var _search = '';
- var _statusTimer = null;
- // Voice recording state (one session at a time — you can dictate into
- // one note, not two simultaneously).
+ // Autosave state
+ var _dirty = false;
+ var _autosaveTimer = null;
+ var _autosaveInFlight = false;
+ var _pendingAfterInflight = false;
+ var AUTOSAVE_DEBOUNCE = 1200;
+
+ // Voice recording state
var _recorder = null;
var _recTimer = null;
var _recPaused = false;
var _recActive = false;
- // Wait for the Notes tab to be activated before wiring anything —
- // the component HTML is lazy-loaded, so elements don't exist until
- // the user clicks the tab.
+ var _statusTimer = null;
+
+ // Wait for the Notes tab to be activated — the component HTML is
+ // lazy-loaded, so elements don't exist until the user clicks the tab.
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'notes') return;
if (_inited) { refreshList(); return; }
@@ -31,66 +45,125 @@
init();
});
+ function $(id) { return document.getElementById(id); }
+
function init() {
- var newBtn = document.getElementById('btn-notes-new');
- var searchEl = document.getElementById('notes-search');
- var saveBtn = document.getElementById('btn-note-save');
- var delBtn = document.getElementById('btn-note-delete');
- var closeBtn = document.getElementById('btn-note-close');
- var titleEl = document.getElementById('note-title');
- var listEl = document.getElementById('notes-list');
-
- if (!newBtn || !listEl) {
- // Component didn't load; fall back quietly.
- return;
- }
-
- newBtn.addEventListener('click', function() { openEditor(null); });
- var newFromEmpty = document.getElementById('btn-notes-new-empty');
- if (newFromEmpty) newFromEmpty.addEventListener('click', function() { openEditor(null); });
- closeBtn.addEventListener('click', function() { closeEditor(); });
- saveBtn.addEventListener('click', saveNote);
- delBtn.addEventListener('click', deleteNote);
+ var newBtn = $('btn-notes-new');
+ var searchEl = $('notes-search');
+ var titleEl = $('note-title');
+ var listEl = $('notes-list');
+ if (!newBtn || !listEl) return;
+ // List + sidebar
+ newBtn.addEventListener('click', function() { startCreate(); });
searchEl.addEventListener('input', function() {
_search = searchEl.value.trim().toLowerCase();
renderList();
});
-
- titleEl.addEventListener('input', function() { _dirty = true; updateStatus('Unsaved', 'dirty'); });
-
- // Voice recording controls
- var recStart = document.getElementById('btn-note-rec-start');
- var recPause = document.getElementById('btn-note-rec-pause');
- var recStop = document.getElementById('btn-note-rec-stop');
- if (recStart) recStart.addEventListener('click', startRecording);
- if (recPause) recPause.addEventListener('click', togglePauseRecording);
- if (recStop) recStop.addEventListener('click', stopRecording);
-
- // Delegated list-row click
listEl.addEventListener('click', function(e) {
var item = e.target.closest('.notes-list-item');
if (!item) return;
var id = parseInt(item.dataset.id);
- if (id) openEditor(id);
+ if (id) openReader(id);
});
- // Ctrl/Cmd+S to save
+ // Empty-state CTA
+ var newFromEmpty = $('btn-notes-new-empty');
+ if (newFromEmpty) newFromEmpty.addEventListener('click', function() { startCreate(); });
+
+ // Reader pane
+ var editBtn = $('btn-note-edit');
+ var delFromRead = $('btn-note-delete-read');
+ var backFromRead = $('btn-notes-reader-back');
+ if (editBtn) editBtn.addEventListener('click', function() { if (_activeId != null) openEditor(_activeId); });
+ if (delFromRead) delFromRead.addEventListener('click', deleteActive);
+ if (backFromRead) backFromRead.addEventListener('click', closeToList);
+
+ // Editor pane
+ var saveBtn = $('btn-note-save');
+ var delBtn = $('btn-note-delete');
+ var backFromEdit = $('btn-notes-editor-back');
+ if (saveBtn) saveBtn.addEventListener('click', function() { saveNote({ source: 'manual', returnToReader: true }); });
+ if (delBtn) delBtn.addEventListener('click', deleteActive);
+ if (backFromEdit) backFromEdit.addEventListener('click', backFromEditor);
+
+ titleEl.addEventListener('input', function() {
+ _dirty = true; updateStatus('Saving…', 'saving'); scheduleAutosave();
+ });
+
+ // Voice recording controls
+ var recStart = $('btn-note-rec-start');
+ var recPause = $('btn-note-rec-pause');
+ var recStop = $('btn-note-rec-stop');
+ if (recStart) recStart.addEventListener('click', startRecording);
+ if (recPause) recPause.addEventListener('click', togglePauseRecording);
+ if (recStop) recStop.addEventListener('click', stopRecording);
+
+ // Ctrl/Cmd+S still saves manually from the editor
document.addEventListener('keydown', function(e) {
- var t = document.getElementById('notes-tab');
- if (!t || !t.classList.contains('active')) return;
+ var tab = $('notes-tab');
+ if (!tab || !tab.classList.contains('active')) return;
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
- e.preventDefault();
- if (!document.getElementById('notes-editor').classList.contains('hidden')) saveNote();
+ if (!$('notes-editor').classList.contains('hidden')) {
+ e.preventDefault();
+ saveNote({ source: 'shortcut', returnToReader: true });
+ }
+ }
+ });
+
+ // Persist dirty state on tab close / page unload — best-effort final flush
+ window.addEventListener('beforeunload', function() {
+ if (_dirty && _activeId != null) {
+ // Use sendBeacon for a guaranteed best-effort send
+ try {
+ var payload = JSON.stringify({ title: getTitle(), body: getBody() });
+ var headers = { type: 'application/json' };
+ navigator.sendBeacon && navigator.sendBeacon('/api/notes/' + _activeId, new Blob([payload], headers));
+ } catch (e) {}
}
});
refreshList();
+ setView('list');
}
- // ── API ────────────────────────────────────────────────────
+ // ── View management ──────────────────────────────────────
+ // On mobile (<900px), only one pane visible at a time, driven
+ // by the layout's data-view attribute.
+ function setView(view) {
+ var layout = $('notes-layout');
+ if (layout) layout.setAttribute('data-view', view);
+
+ var reader = $('notes-reader');
+ var editor = $('notes-editor');
+ var empty = $('notes-empty-state');
+
+ if (reader) reader.classList.toggle('hidden', view !== 'reader');
+ if (editor) editor.classList.toggle('hidden', view !== 'editor');
+ // Empty state is only the "no note picked" desktop placeholder
+ if (empty) empty.classList.toggle('hidden', view !== 'empty' && view !== 'list');
+ }
+
+ function closeToList() {
+ // Any unsaved edits are already autosaved, so no discard prompt needed.
+ flushAutosave();
+ _activeId = null;
+ _dirty = false;
+ if (_editor) { _editor.destroy(); _editor = null; }
+ stopRecording(true); // silent cancel if in flight
+ setView('list');
+ renderList();
+ }
+
+ function backFromEditor() {
+ flushAutosave();
+ if (_activeId != null) openReader(_activeId);
+ else closeToList();
+ }
+
+ // ── Data fetching ─────────────────────────────────────────
function refreshList() {
- var listEl = document.getElementById('notes-list');
+ var listEl = $('notes-list');
if (!listEl) return;
listEl.innerHTML = 'Loading…
';
fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' })
@@ -106,7 +179,7 @@
}
function renderList() {
- var listEl = document.getElementById('notes-list');
+ var listEl = $('notes-list');
if (!listEl) return;
var filtered = _notes;
if (_search) {
@@ -117,7 +190,9 @@
}
if (filtered.length === 0) {
listEl.innerHTML = ''
- + (_search ? 'No notes match "' + esc(_search) + '".' : 'No notes yet. Click New note above to create one.')
+ + (_search
+ ? 'No notes match "' + esc(_search) + '".'
+ : 'No notes yet. Tap New note to create one.')
+ '
';
return;
}
@@ -133,72 +208,115 @@
}).join('');
}
- function openEditor(id) {
- if (_dirty && !confirmDiscard()) return;
+ // ── Reader mode (default after opening or saving) ─────────
+ function openReader(id) {
+ flushAutosave();
+ var note = _notes.find(function(n) { return n.id === id; });
+ if (!note) return;
_activeId = id;
_dirty = false;
- var editor = document.getElementById('notes-editor');
- var empty = document.getElementById('notes-empty-state');
- var titleEl = document.getElementById('note-title');
- var metaEl = document.getElementById('note-meta');
- var delBtn = document.getElementById('btn-note-delete');
- var bodyEl = document.getElementById('note-body-editor');
-
- editor.classList.remove('hidden');
- if (empty) empty.classList.add('hidden');
-
- // Tear down and rebuild the Tiptap editor each time — cheapest way
- // to load a different note's HTML without drifting state.
+ // Tear down the editor if it was open
if (_editor) { _editor.destroy(); _editor = null; }
- bodyEl.innerHTML = '';
- if (id == null) {
- titleEl.value = '';
- metaEl.textContent = 'New note';
- delBtn.style.display = 'none';
- mountTiptap(bodyEl, '');
- titleEl.focus();
- } else {
- var note = _notes.find(function(n) { return n.id === id; });
- if (!note) return;
- titleEl.value = note.title || '';
- metaEl.textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
- delBtn.style.display = '';
- mountTiptap(bodyEl, note.body || '');
- }
- updateStatus('', '');
- setRecUI('idle');
- renderList(); // refresh active-row highlight
- }
+ $('notes-reader-title').textContent = note.title || 'Untitled';
+ $('notes-reader-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
- function closeEditor() {
- if (_dirty && !confirmDiscard()) return;
- _activeId = null;
- _dirty = false;
- if (_editor) { _editor.destroy(); _editor = null; }
- var editor = document.getElementById('notes-editor');
- var empty = document.getElementById('notes-empty-state');
- if (editor) editor.classList.add('hidden');
- if (empty) empty.classList.remove('hidden');
+ var body = $('notes-reader-body');
+ body.innerHTML = note.body ? sanitizeHtml(note.body) : 'This note is empty. Tap Edit to add content.
';
+
+ setView('reader');
renderList();
}
- function saveNote() {
- var titleEl = document.getElementById('note-title');
- var title = (titleEl.value || '').trim();
- if (!title) { updateStatus('Title required', 'err'); titleEl.focus(); return; }
- var body = _editor ? _editor.getHTML() : '';
- if (body === '') body = '';
+ // ── Editor mode (create new / edit existing) ──────────────
+ function startCreate() {
+ flushAutosave();
+ _activeId = null;
+ _dirty = false;
+ $('note-title').value = '';
+ $('note-meta').textContent = 'New note — will save automatically once you type.';
+ $('btn-note-delete').style.display = 'none';
+ mountTiptap($('note-body-editor'), '');
+ updateStatus('', '');
+ setRecUI('idle');
+ setView('editor');
+ setTimeout(function() { $('note-title').focus(); }, 20);
+ renderList();
+ }
- var saveBtn = document.getElementById('btn-note-save');
- saveBtn.disabled = true;
- updateStatus('Saving…', 'saving');
+ function openEditor(id) {
+ var note = _notes.find(function(n) { return n.id === id; });
+ if (!note) return;
+ _activeId = id;
+ _dirty = false;
+
+ $('note-title').value = note.title || '';
+ $('note-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
+ $('btn-note-delete').style.display = '';
+ mountTiptap($('note-body-editor'), note.body || '');
+ updateStatus('', '');
+ setRecUI('idle');
+ setView('editor');
+ renderList();
+ }
+
+ function getTitle() { return ($('note-title').value || '').trim(); }
+ function getBody() {
+ if (!_editor) {
+ // Fallback textarea if Tiptap didn't load
+ var fb = document.querySelector('.notes-body-fallback');
+ return fb ? fb.value : '';
+ }
+ var html = _editor.getHTML();
+ return (html === '' || html === '') ? '' : html;
+ }
+
+ // ── Save + autosave ──────────────────────────────────────
+ function scheduleAutosave() {
+ if (_autosaveTimer) clearTimeout(_autosaveTimer);
+ _autosaveTimer = setTimeout(function() {
+ _autosaveTimer = null;
+ saveNote({ source: 'auto' });
+ }, AUTOSAVE_DEBOUNCE);
+ }
+
+ function flushAutosave() {
+ if (_autosaveTimer) { clearTimeout(_autosaveTimer); _autosaveTimer = null; }
+ if (_dirty && _activeId != null) {
+ saveNote({ source: 'flush' });
+ }
+ }
+
+ function saveNote(opts) {
+ opts = opts || {};
+ var title = getTitle();
+ var body = getBody();
+
+ // Title is required — but for autosave, don't nag every keystroke.
+ if (!title) {
+ if (opts.source === 'manual' || opts.source === 'shortcut') {
+ updateStatus('Title required', 'err');
+ $('note-title').focus();
+ }
+ return;
+ }
+
+ if (_autosaveInFlight) {
+ // Another save is already pending the network — mark so we
+ // fire one more right after it completes to capture the newest
+ // state. Prevents "the last keystroke didn't save" races.
+ _pendingAfterInflight = true;
+ return;
+ }
var isNew = _activeId == null;
var url = isNew ? '/api/notes' : '/api/notes/' + _activeId;
var method = isNew ? 'POST' : 'PUT';
+ _autosaveInFlight = true;
+ updateStatus(opts.source === 'auto' ? 'Saving…' : 'Saving…', 'saving');
+
fetch(url, {
method: method,
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
@@ -207,43 +325,51 @@
})
.then(function(r) { return r.json(); })
.then(function(data) {
- saveBtn.disabled = false;
+ _autosaveInFlight = false;
if (!data.success) { updateStatus(data.error || 'Save failed', 'err'); return; }
if (isNew && data.id) _activeId = data.id;
_dirty = false;
updateStatus('Saved', 'ok');
+
+ // Refresh the list so the new/updated note appears with correct
+ // timestamps + ordering.
return fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) _notes = d.notes || [];
- // Update meta line with the new updated_at.
var note = _notes.find(function(n) { return n.id === _activeId; });
if (note) {
- document.getElementById('note-meta').textContent =
- 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
+ $('note-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
}
renderList();
+ })
+ .then(function() {
+ // If the user typed more while the save was in flight, fire once more.
+ if (_pendingAfterInflight) {
+ _pendingAfterInflight = false;
+ _dirty = true;
+ scheduleAutosave();
+ } else if (opts.returnToReader) {
+ // Manual save → show the reader view with the saved content.
+ openReader(_activeId);
+ }
});
})
.catch(function(err) {
- saveBtn.disabled = false;
+ _autosaveInFlight = false;
updateStatus(err.message || 'Save failed', 'err');
});
}
- function deleteNote() {
+ // ── Delete ──────────────────────────────────────────────
+ function deleteActive() {
if (_activeId == null) return;
var note = _notes.find(function(n) { return n.id === _activeId; });
var label = (note && note.title) ? note.title : 'this note';
- // Use the app's styled confirm helper if present (see public/js/app.js)
- // so Daniel's no-native-alerts rule holds.
var doDelete = function() {
- fetch('/api/notes/' + _activeId, {
- method: 'DELETE',
- headers: getAuthHeaders(),
- credentials: 'include',
- })
+ flushAutosave();
+ fetch('/api/notes/' + _activeId, { method: 'DELETE', headers: getAuthHeaders(), credentials: 'include' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { updateStatus(data.error || 'Delete failed', 'err'); return; }
@@ -251,9 +377,7 @@
_activeId = null;
_dirty = false;
if (_editor) { _editor.destroy(); _editor = null; }
- document.getElementById('notes-editor').classList.add('hidden');
- document.getElementById('notes-empty-state').classList.remove('hidden');
- renderList();
+ closeToList();
if (typeof showToast === 'function') showToast('Note deleted', 'success');
})
.catch(function(err) { updateStatus(err.message || 'Delete failed', 'err'); });
@@ -261,28 +385,20 @@
if (typeof showConfirm === 'function') {
showConfirm('Delete "' + label + '"? This cannot be undone.', doDelete);
- } else {
- // Fallback only fires if app.js never loaded showConfirm — shouldn't
- // happen, but the CSS modal depends on app.js being present.
- if (window.confirm('Delete "' + label + '"? This cannot be undone.')) doDelete();
+ } else if (window.confirm('Delete "' + label + '"? This cannot be undone.')) {
+ doDelete();
}
}
// ── Voice recording → AI note ──────────────────────────────
function startRecording() {
if (_recActive) return;
- // Ensure the editor is open — voice creates a fresh note if none is picked
- if (document.getElementById('notes-editor').classList.contains('hidden')) {
- openEditor(null);
- }
- if (typeof AudioRecorder === 'undefined') {
- updateStatus('Recorder unavailable', 'err');
- return;
- }
+ // Ensure the editor is open — voice always targets the editor
+ if ($('notes-editor').classList.contains('hidden')) startCreate();
+ if (typeof AudioRecorder === 'undefined') { updateStatus('Recorder unavailable', 'err'); return; }
_recorder = new AudioRecorder();
_recorder.start().then(function() {
- _recActive = true;
- _recPaused = false;
+ _recActive = true; _recPaused = false;
setRecUI('recording');
startRecTimer();
}).catch(function(err) {
@@ -294,13 +410,10 @@
if (!_recActive || !_recorder || !_recorder.mediaRecorder) return;
if (!_recPaused) {
try { _recorder.mediaRecorder.pause(); } catch (e) {}
- _recPaused = true;
- stopRecTimer();
- setRecUI('paused');
+ _recPaused = true; stopRecTimer(); setRecUI('paused');
} else {
try { _recorder.mediaRecorder.resume(); } catch (e) {
- // Some browsers don't support resume; restart a fresh segment
- // on the same stream and keep going.
+ // Browser doesn't support resume — start a fresh segment.
try {
if (_recorder.stream && _recorder.stream.active) {
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
@@ -310,37 +423,34 @@
}
} catch (e2) {}
}
- _recPaused = false;
- startRecTimer();
- setRecUI('recording');
+ _recPaused = false; startRecTimer(); setRecUI('recording');
}
}
- function stopRecording() {
- if (!_recActive || !_recorder) return;
- _recActive = false;
- _recPaused = false;
+ function stopRecording(silent) {
+ if (!_recActive || !_recorder) {
+ if (!silent) setRecUI('idle');
+ return;
+ }
+ _recActive = false; _recPaused = false;
stopRecTimer(true);
+ if (silent) {
+ // Cancel cleanly — close the stream, don't transcribe.
+ try { _recorder.mediaRecorder && _recorder.mediaRecorder.state !== 'inactive' && _recorder.mediaRecorder.stop(); } catch (e) {}
+ try { _recorder.stream && _recorder.stream.getTracks().forEach(function(t) { t.stop(); }); } catch (e) {}
+ setRecUI('idle');
+ return;
+ }
setRecUI('processing');
updateStatus('Transcribing…', 'saving');
_recorder.stop().then(function(blob) {
- if (!blob || blob.size === 0) {
- updateStatus('Nothing recorded', 'err');
- setRecUI('idle');
- return;
- }
- if (typeof transcribeAudio !== 'function') {
- updateStatus('Transcription unavailable', 'err');
- setRecUI('idle');
- return;
- }
+ if (!blob || blob.size === 0) { updateStatus('Nothing recorded', 'err'); setRecUI('idle'); return; }
+ if (typeof transcribeAudio !== 'function') { updateStatus('Transcription unavailable', 'err'); setRecUI('idle'); return; }
return transcribeAudio(blob).then(function(resp) {
if (!resp || !resp.success || !resp.text) {
var msg = (resp && (resp.error || (resp.noProvider ? 'No STT provider configured' : null))) || 'Transcription failed';
- updateStatus(msg, 'err');
- setRecUI('idle');
- return;
+ updateStatus(msg, 'err'); setRecUI('idle'); return;
}
updateStatus('Generating note…', 'saving');
return fetch('/api/notes/from-voice', {
@@ -354,7 +464,10 @@
setRecUI('idle');
if (!data.success) { updateStatus(data.error || 'Generation failed', 'err'); return; }
applyGeneratedNote(data.title || 'Voice note', data.body || '');
- updateStatus('Generated — review and save', 'ok');
+ updateStatus('Generated — autosaving…', 'ok');
+ // Trigger the autosave so the AI-generated note is persisted
+ // even if the user walks away.
+ _dirty = true; scheduleAutosave();
});
});
}).catch(function(err) {
@@ -364,11 +477,8 @@
}
function applyGeneratedNote(title, body) {
- var titleEl = document.getElementById('note-title');
- if (titleEl) titleEl.value = title;
- // Rebuild the Tiptap editor with the generated body so the toolbar
- // stays functional and formatting is preserved.
- var container = document.getElementById('note-body-editor');
+ $('note-title').value = title;
+ var container = $('note-body-editor');
if (!container) return;
if (_editor) { _editor.destroy(); _editor = null; }
mountTiptap(container, body);
@@ -376,18 +486,15 @@
}
function startRecTimer() {
- var el = document.getElementById('notes-rec-timer');
+ var el = $('notes-rec-timer');
if (_recTimer || !el) return;
_recTimer = createTimerLite(el);
_recTimer.start();
}
function stopRecTimer(reset) {
- if (_recTimer) { _recTimer.stop(); }
+ if (_recTimer) _recTimer.stop();
if (reset && _recTimer) { _recTimer.reset(); _recTimer = null; }
}
-
- // Minimal local timer (independent of app.js createTimer so multiple
- // tabs can run their own timers without stepping on each other).
function createTimerLite(el) {
var s = 0, iv = null;
function paint() { el.textContent = String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0'); }
@@ -399,21 +506,21 @@
}
function setRecUI(state) {
- var start = document.getElementById('btn-note-rec-start');
- var pause = document.getElementById('btn-note-rec-pause');
- var stopBtn = document.getElementById('btn-note-rec-stop');
- var ind = document.getElementById('notes-rec-indicator');
- var stateEl = document.getElementById('notes-rec-state');
+ var start = $('btn-note-rec-start');
+ var pause = $('btn-note-rec-pause');
+ var stopBtn = $('btn-note-rec-stop');
+ var ind = $('notes-rec-indicator');
+ var stateEl = $('notes-rec-state');
var dot = ind ? ind.querySelector('.pulse-dot') : null;
if (!start) return;
- var idle = (state === 'idle');
- var recording = (state === 'recording');
- var paused = (state === 'paused');
- var processing = (state === 'processing');
+ var idle = (state === 'idle');
+ var recording = (state === 'recording');
+ var paused = (state === 'paused');
+ var processing = (state === 'processing');
start.classList.toggle('hidden', !idle);
- pause.classList.toggle('hidden', !(recording || paused));
+ pause.classList.toggle('hidden', !(recording || paused));
stopBtn.classList.toggle('hidden', !(recording || paused));
ind.classList.toggle('hidden', idle);
@@ -426,12 +533,15 @@
stopBtn.disabled = processing;
}
- // ── Tiptap ────────────────────────────────────────────────
+ // ── Tiptap ─────────────────────────────────────────────
function mountTiptap(container, initialHtml) {
var T = window.Tiptap || {};
if (!T.Editor) {
+ // Fallback when the Tiptap bundle didn't load — plain textarea.
container.innerHTML = '';
_editor = null;
+ var fb = container.querySelector('.notes-body-fallback');
+ if (fb) fb.addEventListener('input', function() { _dirty = true; updateStatus('Saving…', 'saving'); scheduleAutosave(); });
return;
}
container.innerHTML = toolbarHTML() + '';
@@ -444,15 +554,17 @@
],
content: initialHtml || '',
autofocus: false,
- onUpdate: function() { _dirty = true; updateStatus('Unsaved', 'dirty'); updateToolbarState(container); },
+ onUpdate: function() {
+ _dirty = true;
+ updateStatus('Saving…', 'saving');
+ updateToolbarState(container);
+ scheduleAutosave();
+ },
onSelectionUpdate: function() { updateToolbarState(container); }
});
wireToolbar(container, _editor);
}
- // Built from the same buttons vanilla learningHub uses so the CSS in
- // public/css/styles.css (.tp-toolbar / .tp-btn / .tp-sep / .tp-link-bar)
- // styles this identically.
function toolbarHTML() {
var btns = ''
+ ''
@@ -532,7 +644,7 @@
linkBar.style.display = 'none';
});
linkInput.addEventListener('keydown', function(e) {
- if (e.key === 'Enter') { e.preventDefault(); wrap.querySelector('.tp-link-apply').dispatchEvent(new MouseEvent('mousedown')); }
+ if (e.key === 'Enter') { e.preventDefault(); wrap.querySelector('.tp-link-apply').dispatchEvent(new MouseEvent('mousedown')); }
if (e.key === 'Escape') { linkBar.style.display = 'none'; }
});
}
@@ -559,56 +671,66 @@
// ── Helpers ──────────────────────────────────────────────
function updateStatus(text, kind) {
- var el = document.getElementById('notes-status');
+ var el = $('notes-status');
if (!el) return;
el.textContent = text || '';
el.className = 'notes-status' + (kind ? ' notes-status-' + kind : '');
if (_statusTimer) { clearTimeout(_statusTimer); _statusTimer = null; }
if (kind === 'ok') {
- _statusTimer = setTimeout(function() { el.textContent = ''; el.className = 'notes-status'; }, 1500);
+ _statusTimer = setTimeout(function() { el.textContent = ''; el.className = 'notes-status'; }, 2000);
}
}
- function confirmDiscard() {
- // Don't prompt for unsaved discard via native dialog — if there's
- // dirty content and the user clicks another note, keep it simple:
- // fall through and discard. A proper "are you sure?" would be a
- // showConfirm() modal, but for a personal scratchpad the friction
- // outweighs the benefit. Users who care save with Ctrl/Cmd+S.
- _dirty = false;
- return true;
- }
-
function esc(s) {
return (s == null ? '' : String(s))
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
}
-
function stripTags(html) {
if (!html) return '';
- var d = document.createElement('div');
- d.innerHTML = html;
+ var d = document.createElement('div'); d.innerHTML = html;
return (d.textContent || d.innerText || '').replace(/\s+/g, ' ').trim();
}
-
+ // Allowlist-based sanitizer for reader view. The body is also
+ // encrypted in the DB and only rendered back to the same user
+ // who wrote it, so the threat model here is "clean up your own
+ // pasted HTML" rather than hostile content — but we still drop
+ // script/event-handler/iframe/object to be safe.
+ var ALLOWED_TAGS = /^(p|br|strong|em|b|i|u|s|a|ul|ol|li|blockquote|h2|h3|h4|code|pre|hr)$/i;
+ function sanitizeHtml(html) {
+ var doc = document.createElement('div'); doc.innerHTML = html;
+ (function walk(node) {
+ var children = Array.from(node.children);
+ children.forEach(function(el) {
+ if (!ALLOWED_TAGS.test(el.tagName)) {
+ // Replace with its text content
+ var text = document.createTextNode(el.textContent || '');
+ el.parentNode.replaceChild(text, el);
+ return;
+ }
+ // Strip every attribute except href on
+ Array.from(el.attributes || []).forEach(function(attr) {
+ if (el.tagName.toLowerCase() === 'a' && attr.name === 'href') {
+ if (!/^(https?:|mailto:|#)/i.test(attr.value)) el.removeAttribute('href');
+ } else {
+ el.removeAttribute(attr.name);
+ }
+ });
+ if (el.tagName.toLowerCase() === 'a') { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); }
+ walk(el);
+ });
+ })(doc);
+ return doc.innerHTML;
+ }
function formatWhen(iso) {
if (!iso) return '';
var d = new Date(iso);
if (isNaN(d.getTime())) return '';
var now = new Date();
var sameDay = d.toDateString() === now.toDateString();
- if (sameDay) {
- return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
- }
+ if (sameDay) return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric' });
}
-
- // getAuthHeaders() lives in app.js. Fallback keeps the module
- // standalone-testable in isolation.
function getAuthHeaders() {
if (typeof window.getAuthHeaders === 'function') return window.getAuthHeaders();
return {};
diff --git a/public/sw.js b/public/sw.js
index ec266d8..0c9d6c2 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -4,7 +4,7 @@
// API calls always fresh (critical for medical data accuracy)
// ============================================================
-var CACHE_NAME = 'pedscribe-v12-notes1';
+var CACHE_NAME = 'pedscribe-v12-notes2';
var SHELL_ASSETS = [
'/',
'/index.html',
diff --git a/src/routes/notes.js b/src/routes/notes.js
index 1c4a5fd..2eb3c4b 100644
--- a/src/routes/notes.js
+++ b/src/routes/notes.js
@@ -152,10 +152,22 @@ router.post('/notes/from-voice', async function (req, res) {
'Physician voice dictation to convert into a personal note:\n' +
wrapUserText('dictation', transcript);
+ // Client never supplies a model — "admin decides" is the feature
+ // contract. Read the admin-configured default from settings; if
+ // it's not set (LiteLLM deployments with no explicit default),
+ // fall back to the LITELLM_DEFAULT_MODEL env var, then to the
+ // provider's built-in default. Passing an empty string to LiteLLM
+ // returns a 400, so we guard against that.
+ var adminDefault = '';
+ try { adminDefault = await db.getSetting('models.default'); } catch (e) {}
+ var model = (adminDefault || process.env.LITELLM_DEFAULT_MODEL || '').trim();
+ var opts = { maxTokens: 4000 };
+ if (model) opts.model = model;
+
var result = await callAI([
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent }
- ], { model: req.body.model, maxTokens: 4000 });
+ ], opts);
// The model sometimes wraps JSON in markdown fences or adds text
// before the {. Strip anything before the first { to recover.