feat(notes): read-mode default + Edit toggle + autosave + mobile layout
Four changes landing together so the UX flows properly.
1. Fix voice-generation model error.
/api/notes/from-voice was passing `req.body.model` (undefined)
through to callAI, which resolved to LITELLM_DEFAULT_MODEL (empty
for LiteLLM deployments with no explicit default) → `model=""` →
LiteLLM 400 "Invalid model name". Server now reads the admin-
configured `models.default` setting, falls back to the
LITELLM_DEFAULT_MODEL env var, and only passes a model if one
resolved. Empty string never reaches the provider.
2. Read mode as the default post-save.
Opening an existing note or saving a new one now lands on a
clean, read-only rendering of the body (sanitized HTML with an
allowlist — p/h2/h3/strong/em/u/s/a/ul/ol/li/blockquote/code/pre).
Click the "Edit" button to switch to the Tiptap editor. Matches
the mental model of "notes are documents I review, not drafts I'm
always editing."
3. Autosave during edit.
Title-input + Tiptap onUpdate trigger a 1.2-second-debounced save.
In-flight saves coalesce: if the user types more while a save is
in progress, a follow-up fires right after it lands so the last
keystroke never gets stranded. beforeunload uses navigator.
sendBeacon to best-effort flush on tab close. The manual Save
button is kept as a "save + switch to reader" shortcut. Ctrl/
Cmd+S still works in the editor.
4. Mobile layout.
The two-pane layout collapses to a single-pane view below 900px,
driven by a data-view attribute on .notes-layout. Back buttons
appear in the reader + editor heads on mobile to return to the
list. Desktop layout unchanged (sidebar + right pane always
visible).
Also:
• CSS specificity fix — .hidden{display:none} was losing to
.notes-rec-indicator{display:inline-flex} on source order, so
the "Recording" indicator was showing when idle. Added explicit
.notes-rec-indicator.hidden + .notes-voice-bar .btn-sm.hidden
overrides at higher specificity.
• Reader-body sanitizer — allowlist of safe tags + attributes;
<a> links get target=_blank + rel=noopener.
• SW cache bumped to pedscribe-v12-notes2 so clients pick up the
new module / component / CSS.
This commit is contained in:
parent
f57553585d
commit
30917bb30d
5 changed files with 454 additions and 224 deletions
|
|
@ -3,7 +3,11 @@
|
||||||
<p>A quiet place for your thoughts — jot ideas, paste references, or dictate and let AI tidy it up. Only you can see these.</p>
|
<p>A quiet place for your thoughts — jot ideas, paste references, or dictate and let AI tidy it up. Only you can see these.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="notes-layout">
|
<!-- data-view controls which pane is visible on mobile (<900px):
|
||||||
|
"list" — sidebar only, "reader" — read pane only,
|
||||||
|
"editor" — edit pane only. On desktop the sidebar is always
|
||||||
|
visible and the right pane flips between reader/editor. -->
|
||||||
|
<div class="notes-layout" id="notes-layout" data-view="list">
|
||||||
|
|
||||||
<!-- Left pane: list + new-note button + search -->
|
<!-- Left pane: list + new-note button + search -->
|
||||||
<aside class="notes-sidebar">
|
<aside class="notes-sidebar">
|
||||||
|
|
@ -21,9 +25,35 @@
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Right pane: editor (hidden until a note is picked / created) -->
|
<!-- Reader pane — shown by default after opening any saved note -->
|
||||||
|
<section id="notes-reader" class="notes-reader hidden">
|
||||||
|
<div class="notes-reader-head">
|
||||||
|
<button type="button" class="btn-sm btn-ghost notes-back" id="btn-notes-reader-back" title="Back to list">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</button>
|
||||||
|
<h2 id="notes-reader-title" class="notes-reader-title">Note title</h2>
|
||||||
|
<div class="notes-reader-actions">
|
||||||
|
<button id="btn-note-edit" class="btn-sm btn-primary" type="button">
|
||||||
|
<i class="fas fa-pen"></i> Edit
|
||||||
|
</button>
|
||||||
|
<button id="btn-note-delete-read" class="btn-sm" type="button"
|
||||||
|
style="background:var(--red-light);color:var(--red);border:1px solid var(--red);">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="notes-reader-body" class="notes-reader-body"></div>
|
||||||
|
<div class="notes-reader-foot">
|
||||||
|
<span id="notes-reader-meta" class="notes-meta"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Editor pane — shown when creating a new note or editing an existing one -->
|
||||||
<section id="notes-editor" class="notes-editor hidden">
|
<section id="notes-editor" class="notes-editor hidden">
|
||||||
<div class="notes-editor-head">
|
<div class="notes-editor-head">
|
||||||
|
<button type="button" class="btn-sm btn-ghost notes-back" id="btn-notes-editor-back" title="Back">
|
||||||
|
<i class="fas fa-arrow-left"></i>
|
||||||
|
</button>
|
||||||
<input type="text" id="note-title"
|
<input type="text" id="note-title"
|
||||||
class="notes-title-input"
|
class="notes-title-input"
|
||||||
placeholder="Note title…"
|
placeholder="Note title…"
|
||||||
|
|
@ -53,11 +83,9 @@
|
||||||
<button id="btn-note-save" class="btn-sm btn-primary" type="button">
|
<button id="btn-note-save" class="btn-sm btn-primary" type="button">
|
||||||
<i class="fas fa-floppy-disk"></i> Save
|
<i class="fas fa-floppy-disk"></i> Save
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-note-delete" class="btn-sm" type="button" style="background:var(--red-light);color:var(--red);border:1px solid var(--red);">
|
<button id="btn-note-delete" class="btn-sm" type="button"
|
||||||
<i class="fas fa-trash"></i> Delete
|
style="background:var(--red-light);color:var(--red);border:1px solid var(--red);">
|
||||||
</button>
|
<i class="fas fa-trash"></i>
|
||||||
<button id="btn-note-close" class="btn-sm btn-ghost" type="button" title="Close editor">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -67,7 +95,7 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Empty placeholder — shown when no note is picked -->
|
<!-- Empty placeholder — shown on desktop when nothing's picked -->
|
||||||
<section id="notes-empty-state" class="notes-empty-state">
|
<section id="notes-empty-state" class="notes-empty-state">
|
||||||
<div class="notes-empty-card">
|
<div class="notes-empty-card">
|
||||||
<div class="notes-empty-icon"><i class="fas fa-feather"></i></div>
|
<div class="notes-empty-icon"><i class="fas fa-feather"></i></div>
|
||||||
|
|
@ -78,7 +106,7 @@
|
||||||
</button>
|
</button>
|
||||||
<div class="notes-empty-tips">
|
<div class="notes-empty-tips">
|
||||||
<div><i class="fas fa-bolt"></i> <span>Press <kbd>Ctrl</kbd>+<kbd>S</kbd> to save</span></div>
|
<div><i class="fas fa-bolt"></i> <span>Press <kbd>Ctrl</kbd>+<kbd>S</kbd> to save</span></div>
|
||||||
<div><i class="fas fa-microphone"></i> <span>Dictate works even offline with Browser Whisper</span></div>
|
<div><i class="fas fa-microphone"></i> <span>Dictate turns your voice into a clean note</span></div>
|
||||||
<div><i class="fas fa-lock"></i> <span>Encrypted at rest — only you can read them</span></div>
|
<div><i class="fas fa-lock"></i> <span>Encrypted at rest — only you can read them</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 p{font-size:14px;line-height:1.5;max-width:320px;margin:0;}
|
||||||
.notes-empty-state.hidden{display:none;}
|
.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{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{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{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{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;}
|
.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);}}
|
@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 */
|
/* subtle card float on hover for the whole editor */
|
||||||
.notes-editor{transition:box-shadow 0.2s;}
|
.notes-editor{transition:box-shadow 0.2s;}
|
||||||
.notes-editor:hover{box-shadow:0 4px 16px rgba(0,0,0,0.06);}
|
.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;}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,43 @@
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// NOTES — per-user personal scratchpad under Clinical Tools.
|
// NOTES — per-user personal scratchpad under Clinical Tools.
|
||||||
// Rich-text via the app-wide Tiptap bundle (window.Tiptap).
|
// Rich-text via the app-wide Tiptap bundle (window.Tiptap).
|
||||||
// Pure client-side; talks to /api/notes (auth-gated, encrypted
|
// CRUD against /api/notes (auth + server-side encryption).
|
||||||
// at rest via src/routes/notes.js + crypto util).
|
//
|
||||||
|
// 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() {
|
(function() {
|
||||||
var _inited = false;
|
var _inited = false;
|
||||||
var _notes = [];
|
var _notes = [];
|
||||||
var _activeId = null;
|
var _activeId = null; // currently open note id (reader OR editor)
|
||||||
var _editor = null;
|
var _editor = null; // Tiptap instance
|
||||||
var _dirty = false;
|
|
||||||
var _search = '';
|
var _search = '';
|
||||||
var _statusTimer = null;
|
|
||||||
|
|
||||||
// Voice recording state (one session at a time — you can dictate into
|
// Autosave state
|
||||||
// one note, not two simultaneously).
|
var _dirty = false;
|
||||||
|
var _autosaveTimer = null;
|
||||||
|
var _autosaveInFlight = false;
|
||||||
|
var _pendingAfterInflight = false;
|
||||||
|
var AUTOSAVE_DEBOUNCE = 1200;
|
||||||
|
|
||||||
|
// Voice recording state
|
||||||
var _recorder = null;
|
var _recorder = null;
|
||||||
var _recTimer = null;
|
var _recTimer = null;
|
||||||
var _recPaused = false;
|
var _recPaused = false;
|
||||||
var _recActive = false;
|
var _recActive = false;
|
||||||
|
|
||||||
// Wait for the Notes tab to be activated before wiring anything —
|
var _statusTimer = null;
|
||||||
// the component HTML is lazy-loaded, so elements don't exist until
|
|
||||||
// the user clicks the tab.
|
// 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) {
|
document.addEventListener('tabChanged', function(e) {
|
||||||
if (e.detail.tab !== 'notes') return;
|
if (e.detail.tab !== 'notes') return;
|
||||||
if (_inited) { refreshList(); return; }
|
if (_inited) { refreshList(); return; }
|
||||||
|
|
@ -31,66 +45,125 @@
|
||||||
init();
|
init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
var newBtn = document.getElementById('btn-notes-new');
|
var newBtn = $('btn-notes-new');
|
||||||
var searchEl = document.getElementById('notes-search');
|
var searchEl = $('notes-search');
|
||||||
var saveBtn = document.getElementById('btn-note-save');
|
var titleEl = $('note-title');
|
||||||
var delBtn = document.getElementById('btn-note-delete');
|
var listEl = $('notes-list');
|
||||||
var closeBtn = document.getElementById('btn-note-close');
|
if (!newBtn || !listEl) return;
|
||||||
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);
|
|
||||||
|
|
||||||
|
// List + sidebar
|
||||||
|
newBtn.addEventListener('click', function() { startCreate(); });
|
||||||
searchEl.addEventListener('input', function() {
|
searchEl.addEventListener('input', function() {
|
||||||
_search = searchEl.value.trim().toLowerCase();
|
_search = searchEl.value.trim().toLowerCase();
|
||||||
renderList();
|
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) {
|
listEl.addEventListener('click', function(e) {
|
||||||
var item = e.target.closest('.notes-list-item');
|
var item = e.target.closest('.notes-list-item');
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
var id = parseInt(item.dataset.id);
|
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) {
|
document.addEventListener('keydown', function(e) {
|
||||||
var t = document.getElementById('notes-tab');
|
var tab = $('notes-tab');
|
||||||
if (!t || !t.classList.contains('active')) return;
|
if (!tab || !tab.classList.contains('active')) return;
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
e.preventDefault();
|
if (!$('notes-editor').classList.contains('hidden')) {
|
||||||
if (!document.getElementById('notes-editor').classList.contains('hidden')) saveNote();
|
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();
|
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() {
|
function refreshList() {
|
||||||
var listEl = document.getElementById('notes-list');
|
var listEl = $('notes-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
listEl.innerHTML = '<div class="notes-empty">Loading…</div>';
|
listEl.innerHTML = '<div class="notes-empty">Loading…</div>';
|
||||||
fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' })
|
fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' })
|
||||||
|
|
@ -106,7 +179,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
var listEl = document.getElementById('notes-list');
|
var listEl = $('notes-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
var filtered = _notes;
|
var filtered = _notes;
|
||||||
if (_search) {
|
if (_search) {
|
||||||
|
|
@ -117,7 +190,9 @@
|
||||||
}
|
}
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
listEl.innerHTML = '<div class="notes-empty">'
|
listEl.innerHTML = '<div class="notes-empty">'
|
||||||
+ (_search ? 'No notes match "' + esc(_search) + '".' : 'No notes yet. Click <strong>New note</strong> above to create one.')
|
+ (_search
|
||||||
|
? 'No notes match "' + esc(_search) + '".'
|
||||||
|
: 'No notes yet. Tap <strong>New note</strong> to create one.')
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -133,72 +208,115 @@
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditor(id) {
|
// ── Reader mode (default after opening or saving) ─────────
|
||||||
if (_dirty && !confirmDiscard()) return;
|
function openReader(id) {
|
||||||
|
flushAutosave();
|
||||||
|
var note = _notes.find(function(n) { return n.id === id; });
|
||||||
|
if (!note) return;
|
||||||
_activeId = id;
|
_activeId = id;
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
|
|
||||||
var editor = document.getElementById('notes-editor');
|
// Tear down the editor if it was open
|
||||||
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.
|
|
||||||
if (_editor) { _editor.destroy(); _editor = null; }
|
if (_editor) { _editor.destroy(); _editor = null; }
|
||||||
bodyEl.innerHTML = '';
|
|
||||||
|
|
||||||
if (id == null) {
|
$('notes-reader-title').textContent = note.title || 'Untitled';
|
||||||
titleEl.value = '';
|
$('notes-reader-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditor() {
|
var body = $('notes-reader-body');
|
||||||
if (_dirty && !confirmDiscard()) return;
|
body.innerHTML = note.body ? sanitizeHtml(note.body) : '<p style="color:var(--g400);font-style:italic;">This note is empty. Tap Edit to add content.</p>';
|
||||||
_activeId = null;
|
|
||||||
_dirty = false;
|
setView('reader');
|
||||||
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');
|
|
||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveNote() {
|
// ── Editor mode (create new / edit existing) ──────────────
|
||||||
var titleEl = document.getElementById('note-title');
|
function startCreate() {
|
||||||
var title = (titleEl.value || '').trim();
|
flushAutosave();
|
||||||
if (!title) { updateStatus('Title required', 'err'); titleEl.focus(); return; }
|
_activeId = null;
|
||||||
var body = _editor ? _editor.getHTML() : '';
|
_dirty = false;
|
||||||
if (body === '<p></p>') body = '';
|
$('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');
|
function openEditor(id) {
|
||||||
saveBtn.disabled = true;
|
var note = _notes.find(function(n) { return n.id === id; });
|
||||||
updateStatus('Saving…', 'saving');
|
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 === '<p></p>' || 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 isNew = _activeId == null;
|
||||||
var url = isNew ? '/api/notes' : '/api/notes/' + _activeId;
|
var url = isNew ? '/api/notes' : '/api/notes/' + _activeId;
|
||||||
var method = isNew ? 'POST' : 'PUT';
|
var method = isNew ? 'POST' : 'PUT';
|
||||||
|
|
||||||
|
_autosaveInFlight = true;
|
||||||
|
updateStatus(opts.source === 'auto' ? 'Saving…' : 'Saving…', 'saving');
|
||||||
|
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
method: method,
|
method: method,
|
||||||
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
|
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
|
||||||
|
|
@ -207,43 +325,51 @@
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
saveBtn.disabled = false;
|
_autosaveInFlight = false;
|
||||||
if (!data.success) { updateStatus(data.error || 'Save failed', 'err'); return; }
|
if (!data.success) { updateStatus(data.error || 'Save failed', 'err'); return; }
|
||||||
if (isNew && data.id) _activeId = data.id;
|
if (isNew && data.id) _activeId = data.id;
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
updateStatus('Saved', 'ok');
|
updateStatus('Saved', 'ok');
|
||||||
|
|
||||||
|
// Refresh the list so the new/updated note appears with correct
|
||||||
|
// timestamps + ordering.
|
||||||
return fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' })
|
return fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
if (d.success) _notes = d.notes || [];
|
if (d.success) _notes = d.notes || [];
|
||||||
// Update meta line with the new updated_at.
|
|
||||||
var note = _notes.find(function(n) { return n.id === _activeId; });
|
var note = _notes.find(function(n) { return n.id === _activeId; });
|
||||||
if (note) {
|
if (note) {
|
||||||
document.getElementById('note-meta').textContent =
|
$('note-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
|
||||||
'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
|
|
||||||
}
|
}
|
||||||
renderList();
|
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) {
|
.catch(function(err) {
|
||||||
saveBtn.disabled = false;
|
_autosaveInFlight = false;
|
||||||
updateStatus(err.message || 'Save failed', 'err');
|
updateStatus(err.message || 'Save failed', 'err');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteNote() {
|
// ── Delete ──────────────────────────────────────────────
|
||||||
|
function deleteActive() {
|
||||||
if (_activeId == null) return;
|
if (_activeId == null) return;
|
||||||
var note = _notes.find(function(n) { return n.id === _activeId; });
|
var note = _notes.find(function(n) { return n.id === _activeId; });
|
||||||
var label = (note && note.title) ? note.title : 'this note';
|
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() {
|
var doDelete = function() {
|
||||||
fetch('/api/notes/' + _activeId, {
|
flushAutosave();
|
||||||
method: 'DELETE',
|
fetch('/api/notes/' + _activeId, { method: 'DELETE', headers: getAuthHeaders(), credentials: 'include' })
|
||||||
headers: getAuthHeaders(),
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.success) { updateStatus(data.error || 'Delete failed', 'err'); return; }
|
if (!data.success) { updateStatus(data.error || 'Delete failed', 'err'); return; }
|
||||||
|
|
@ -251,9 +377,7 @@
|
||||||
_activeId = null;
|
_activeId = null;
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
if (_editor) { _editor.destroy(); _editor = null; }
|
if (_editor) { _editor.destroy(); _editor = null; }
|
||||||
document.getElementById('notes-editor').classList.add('hidden');
|
closeToList();
|
||||||
document.getElementById('notes-empty-state').classList.remove('hidden');
|
|
||||||
renderList();
|
|
||||||
if (typeof showToast === 'function') showToast('Note deleted', 'success');
|
if (typeof showToast === 'function') showToast('Note deleted', 'success');
|
||||||
})
|
})
|
||||||
.catch(function(err) { updateStatus(err.message || 'Delete failed', 'err'); });
|
.catch(function(err) { updateStatus(err.message || 'Delete failed', 'err'); });
|
||||||
|
|
@ -261,28 +385,20 @@
|
||||||
|
|
||||||
if (typeof showConfirm === 'function') {
|
if (typeof showConfirm === 'function') {
|
||||||
showConfirm('Delete "' + label + '"? This cannot be undone.', doDelete);
|
showConfirm('Delete "' + label + '"? This cannot be undone.', doDelete);
|
||||||
} else {
|
} else if (window.confirm('Delete "' + label + '"? This cannot be undone.')) {
|
||||||
// Fallback only fires if app.js never loaded showConfirm — shouldn't
|
doDelete();
|
||||||
// happen, but the CSS modal depends on app.js being present.
|
|
||||||
if (window.confirm('Delete "' + label + '"? This cannot be undone.')) doDelete();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Voice recording → AI note ──────────────────────────────
|
// ── Voice recording → AI note ──────────────────────────────
|
||||||
function startRecording() {
|
function startRecording() {
|
||||||
if (_recActive) return;
|
if (_recActive) return;
|
||||||
// Ensure the editor is open — voice creates a fresh note if none is picked
|
// Ensure the editor is open — voice always targets the editor
|
||||||
if (document.getElementById('notes-editor').classList.contains('hidden')) {
|
if ($('notes-editor').classList.contains('hidden')) startCreate();
|
||||||
openEditor(null);
|
if (typeof AudioRecorder === 'undefined') { updateStatus('Recorder unavailable', 'err'); return; }
|
||||||
}
|
|
||||||
if (typeof AudioRecorder === 'undefined') {
|
|
||||||
updateStatus('Recorder unavailable', 'err');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_recorder = new AudioRecorder();
|
_recorder = new AudioRecorder();
|
||||||
_recorder.start().then(function() {
|
_recorder.start().then(function() {
|
||||||
_recActive = true;
|
_recActive = true; _recPaused = false;
|
||||||
_recPaused = false;
|
|
||||||
setRecUI('recording');
|
setRecUI('recording');
|
||||||
startRecTimer();
|
startRecTimer();
|
||||||
}).catch(function(err) {
|
}).catch(function(err) {
|
||||||
|
|
@ -294,13 +410,10 @@
|
||||||
if (!_recActive || !_recorder || !_recorder.mediaRecorder) return;
|
if (!_recActive || !_recorder || !_recorder.mediaRecorder) return;
|
||||||
if (!_recPaused) {
|
if (!_recPaused) {
|
||||||
try { _recorder.mediaRecorder.pause(); } catch (e) {}
|
try { _recorder.mediaRecorder.pause(); } catch (e) {}
|
||||||
_recPaused = true;
|
_recPaused = true; stopRecTimer(); setRecUI('paused');
|
||||||
stopRecTimer();
|
|
||||||
setRecUI('paused');
|
|
||||||
} else {
|
} else {
|
||||||
try { _recorder.mediaRecorder.resume(); } catch (e) {
|
try { _recorder.mediaRecorder.resume(); } catch (e) {
|
||||||
// Some browsers don't support resume; restart a fresh segment
|
// Browser doesn't support resume — start a fresh segment.
|
||||||
// on the same stream and keep going.
|
|
||||||
try {
|
try {
|
||||||
if (_recorder.stream && _recorder.stream.active) {
|
if (_recorder.stream && _recorder.stream.active) {
|
||||||
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||||
|
|
@ -310,37 +423,34 @@
|
||||||
}
|
}
|
||||||
} catch (e2) {}
|
} catch (e2) {}
|
||||||
}
|
}
|
||||||
_recPaused = false;
|
_recPaused = false; startRecTimer(); setRecUI('recording');
|
||||||
startRecTimer();
|
|
||||||
setRecUI('recording');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopRecording() {
|
function stopRecording(silent) {
|
||||||
if (!_recActive || !_recorder) return;
|
if (!_recActive || !_recorder) {
|
||||||
_recActive = false;
|
if (!silent) setRecUI('idle');
|
||||||
_recPaused = false;
|
return;
|
||||||
|
}
|
||||||
|
_recActive = false; _recPaused = false;
|
||||||
stopRecTimer(true);
|
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');
|
setRecUI('processing');
|
||||||
updateStatus('Transcribing…', 'saving');
|
updateStatus('Transcribing…', 'saving');
|
||||||
|
|
||||||
_recorder.stop().then(function(blob) {
|
_recorder.stop().then(function(blob) {
|
||||||
if (!blob || blob.size === 0) {
|
if (!blob || blob.size === 0) { updateStatus('Nothing recorded', 'err'); setRecUI('idle'); return; }
|
||||||
updateStatus('Nothing recorded', 'err');
|
if (typeof transcribeAudio !== 'function') { updateStatus('Transcription unavailable', 'err'); setRecUI('idle'); return; }
|
||||||
setRecUI('idle');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof transcribeAudio !== 'function') {
|
|
||||||
updateStatus('Transcription unavailable', 'err');
|
|
||||||
setRecUI('idle');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return transcribeAudio(blob).then(function(resp) {
|
return transcribeAudio(blob).then(function(resp) {
|
||||||
if (!resp || !resp.success || !resp.text) {
|
if (!resp || !resp.success || !resp.text) {
|
||||||
var msg = (resp && (resp.error || (resp.noProvider ? 'No STT provider configured' : null))) || 'Transcription failed';
|
var msg = (resp && (resp.error || (resp.noProvider ? 'No STT provider configured' : null))) || 'Transcription failed';
|
||||||
updateStatus(msg, 'err');
|
updateStatus(msg, 'err'); setRecUI('idle'); return;
|
||||||
setRecUI('idle');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
updateStatus('Generating note…', 'saving');
|
updateStatus('Generating note…', 'saving');
|
||||||
return fetch('/api/notes/from-voice', {
|
return fetch('/api/notes/from-voice', {
|
||||||
|
|
@ -354,7 +464,10 @@
|
||||||
setRecUI('idle');
|
setRecUI('idle');
|
||||||
if (!data.success) { updateStatus(data.error || 'Generation failed', 'err'); return; }
|
if (!data.success) { updateStatus(data.error || 'Generation failed', 'err'); return; }
|
||||||
applyGeneratedNote(data.title || 'Voice note', data.body || '');
|
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) {
|
}).catch(function(err) {
|
||||||
|
|
@ -364,11 +477,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyGeneratedNote(title, body) {
|
function applyGeneratedNote(title, body) {
|
||||||
var titleEl = document.getElementById('note-title');
|
$('note-title').value = title;
|
||||||
if (titleEl) titleEl.value = title;
|
var container = $('note-body-editor');
|
||||||
// Rebuild the Tiptap editor with the generated body so the toolbar
|
|
||||||
// stays functional and formatting is preserved.
|
|
||||||
var container = document.getElementById('note-body-editor');
|
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
if (_editor) { _editor.destroy(); _editor = null; }
|
if (_editor) { _editor.destroy(); _editor = null; }
|
||||||
mountTiptap(container, body);
|
mountTiptap(container, body);
|
||||||
|
|
@ -376,18 +486,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRecTimer() {
|
function startRecTimer() {
|
||||||
var el = document.getElementById('notes-rec-timer');
|
var el = $('notes-rec-timer');
|
||||||
if (_recTimer || !el) return;
|
if (_recTimer || !el) return;
|
||||||
_recTimer = createTimerLite(el);
|
_recTimer = createTimerLite(el);
|
||||||
_recTimer.start();
|
_recTimer.start();
|
||||||
}
|
}
|
||||||
function stopRecTimer(reset) {
|
function stopRecTimer(reset) {
|
||||||
if (_recTimer) { _recTimer.stop(); }
|
if (_recTimer) _recTimer.stop();
|
||||||
if (reset && _recTimer) { _recTimer.reset(); _recTimer = null; }
|
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) {
|
function createTimerLite(el) {
|
||||||
var s = 0, iv = null;
|
var s = 0, iv = null;
|
||||||
function paint() { el.textContent = String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0'); }
|
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) {
|
function setRecUI(state) {
|
||||||
var start = document.getElementById('btn-note-rec-start');
|
var start = $('btn-note-rec-start');
|
||||||
var pause = document.getElementById('btn-note-rec-pause');
|
var pause = $('btn-note-rec-pause');
|
||||||
var stopBtn = document.getElementById('btn-note-rec-stop');
|
var stopBtn = $('btn-note-rec-stop');
|
||||||
var ind = document.getElementById('notes-rec-indicator');
|
var ind = $('notes-rec-indicator');
|
||||||
var stateEl = document.getElementById('notes-rec-state');
|
var stateEl = $('notes-rec-state');
|
||||||
var dot = ind ? ind.querySelector('.pulse-dot') : null;
|
var dot = ind ? ind.querySelector('.pulse-dot') : null;
|
||||||
if (!start) return;
|
if (!start) return;
|
||||||
|
|
||||||
var idle = (state === 'idle');
|
var idle = (state === 'idle');
|
||||||
var recording = (state === 'recording');
|
var recording = (state === 'recording');
|
||||||
var paused = (state === 'paused');
|
var paused = (state === 'paused');
|
||||||
var processing = (state === 'processing');
|
var processing = (state === 'processing');
|
||||||
|
|
||||||
start.classList.toggle('hidden', !idle);
|
start.classList.toggle('hidden', !idle);
|
||||||
pause.classList.toggle('hidden', !(recording || paused));
|
pause.classList.toggle('hidden', !(recording || paused));
|
||||||
stopBtn.classList.toggle('hidden', !(recording || paused));
|
stopBtn.classList.toggle('hidden', !(recording || paused));
|
||||||
ind.classList.toggle('hidden', idle);
|
ind.classList.toggle('hidden', idle);
|
||||||
|
|
||||||
|
|
@ -426,12 +533,15 @@
|
||||||
stopBtn.disabled = processing;
|
stopBtn.disabled = processing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tiptap ────────────────────────────────────────────────
|
// ── Tiptap ─────────────────────────────────────────────
|
||||||
function mountTiptap(container, initialHtml) {
|
function mountTiptap(container, initialHtml) {
|
||||||
var T = window.Tiptap || {};
|
var T = window.Tiptap || {};
|
||||||
if (!T.Editor) {
|
if (!T.Editor) {
|
||||||
|
// Fallback when the Tiptap bundle didn't load — plain textarea.
|
||||||
container.innerHTML = '<textarea class="notes-body-fallback" placeholder="Write your note…">' + esc(stripTags(initialHtml)) + '</textarea>';
|
container.innerHTML = '<textarea class="notes-body-fallback" placeholder="Write your note…">' + esc(stripTags(initialHtml)) + '</textarea>';
|
||||||
_editor = null;
|
_editor = null;
|
||||||
|
var fb = container.querySelector('.notes-body-fallback');
|
||||||
|
if (fb) fb.addEventListener('input', function() { _dirty = true; updateStatus('Saving…', 'saving'); scheduleAutosave(); });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = toolbarHTML() + '<div class="tp-content"></div>';
|
container.innerHTML = toolbarHTML() + '<div class="tp-content"></div>';
|
||||||
|
|
@ -444,15 +554,17 @@
|
||||||
],
|
],
|
||||||
content: initialHtml || '',
|
content: initialHtml || '',
|
||||||
autofocus: false,
|
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); }
|
onSelectionUpdate: function() { updateToolbarState(container); }
|
||||||
});
|
});
|
||||||
wireToolbar(container, _editor);
|
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() {
|
function toolbarHTML() {
|
||||||
var btns = ''
|
var btns = ''
|
||||||
+ '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>'
|
+ '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>'
|
||||||
|
|
@ -532,7 +644,7 @@
|
||||||
linkBar.style.display = 'none';
|
linkBar.style.display = 'none';
|
||||||
});
|
});
|
||||||
linkInput.addEventListener('keydown', function(e) {
|
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'; }
|
if (e.key === 'Escape') { linkBar.style.display = 'none'; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -559,56 +671,66 @@
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────
|
||||||
function updateStatus(text, kind) {
|
function updateStatus(text, kind) {
|
||||||
var el = document.getElementById('notes-status');
|
var el = $('notes-status');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.textContent = text || '';
|
el.textContent = text || '';
|
||||||
el.className = 'notes-status' + (kind ? ' notes-status-' + kind : '');
|
el.className = 'notes-status' + (kind ? ' notes-status-' + kind : '');
|
||||||
if (_statusTimer) { clearTimeout(_statusTimer); _statusTimer = null; }
|
if (_statusTimer) { clearTimeout(_statusTimer); _statusTimer = null; }
|
||||||
if (kind === 'ok') {
|
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) {
|
function esc(s) {
|
||||||
return (s == null ? '' : String(s))
|
return (s == null ? '' : String(s))
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
.replace(/</g, '<')
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripTags(html) {
|
function stripTags(html) {
|
||||||
if (!html) return '';
|
if (!html) return '';
|
||||||
var d = document.createElement('div');
|
var d = document.createElement('div'); d.innerHTML = html;
|
||||||
d.innerHTML = html;
|
|
||||||
return (d.textContent || d.innerText || '').replace(/\s+/g, ' ').trim();
|
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 <a>
|
||||||
|
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) {
|
function formatWhen(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
var d = new Date(iso);
|
var d = new Date(iso);
|
||||||
if (isNaN(d.getTime())) return '';
|
if (isNaN(d.getTime())) return '';
|
||||||
var now = new Date();
|
var now = new Date();
|
||||||
var sameDay = d.toDateString() === now.toDateString();
|
var sameDay = d.toDateString() === now.toDateString();
|
||||||
if (sameDay) {
|
if (sameDay) return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||||
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric' });
|
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() {
|
function getAuthHeaders() {
|
||||||
if (typeof window.getAuthHeaders === 'function') return window.getAuthHeaders();
|
if (typeof window.getAuthHeaders === 'function') return window.getAuthHeaders();
|
||||||
return {};
|
return {};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// API calls always fresh (critical for medical data accuracy)
|
// API calls always fresh (critical for medical data accuracy)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
var CACHE_NAME = 'pedscribe-v12-notes1';
|
var CACHE_NAME = 'pedscribe-v12-notes2';
|
||||||
var SHELL_ASSETS = [
|
var SHELL_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|
|
||||||
|
|
@ -152,10 +152,22 @@ router.post('/notes/from-voice', async function (req, res) {
|
||||||
'Physician voice dictation to convert into a personal note:\n' +
|
'Physician voice dictation to convert into a personal note:\n' +
|
||||||
wrapUserText('dictation', transcript);
|
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([
|
var result = await callAI([
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: userContent }
|
{ role: 'user', content: userContent }
|
||||||
], { model: req.body.model, maxTokens: 4000 });
|
], opts);
|
||||||
|
|
||||||
// The model sometimes wraps JSON in markdown fences or adds text
|
// The model sometimes wraps JSON in markdown fences or adds text
|
||||||
// before the {. Strip anything before the first { to recover.
|
// before the {. Strip anything before the first { to recover.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue