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.

-
+ +
- + + + +
@@ -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.