feat(notes): personal notes with rich-text editor + voice dictation
New "Notes" tab under Clinical Tools — a per-user scratchpad that's
explicitly NOT fed into AI prompts (distinct from user_memories).
Two-pane layout: searchable list on the left, rich-text editor with
title + save/edit/delete on the right.
Backend:
migrations/…_add-personal-notes.js — personal_notes table
(id, user_id → users ON DELETE CASCADE, title, body, created_at,
updated_at) with indexes on user_id + (user_id, updated_at).
src/routes/notes.js — CRUD + one AI endpoint:
GET /api/notes list, newest-updated first
GET /api/notes/:id fetch one
POST /api/notes create (title + body required)
PUT /api/notes/:id update
DELETE /api/notes/:id remove
POST /api/notes/from-voice transcript → { title, body }
via callAI (admin-controlled
provider — never selectable by
the clinician).
Body + title encrypted at rest via the same cryptoUtil used for
user_memories; 500-note per-user cap; 200-char title / 50 KB
body limits.
Frontend:
public/components/notes.html — empty-state card ("Hello 👋"),
sidebar list with search, editor head with voice-bar (Dictate /
Pause / Resume / Stop + live timer + pulse indicator), Tiptap
body, metadata footer. Uses existing .tp-* toolbar classes.
public/js/notes.js — lazy-init on first tab activation; Tiptap
editor built from window.Tiptap (same bundle the Content
Manager uses); delegated list clicks; Ctrl/Cmd+S to save;
uses app.js's AudioRecorder + transcribeAudio so the STT
pipeline is shared. On stop → transcribe → /api/notes/from-
voice → drop the AI-structured title + body into the editor;
clinician reviews then saves.
public/css/styles.css — 70 lines of .notes-* styles (card
layout, warm empty-state with gradient icon + tips, pulse
animation for the recording indicator, focus states, hover
nudges).
public/sw.js — bump cache from pedscribe-v12 → pedscribe-v12-
notes1 so clients pick up the new module/component.
Admin-controlled STT provider: the recorder posts its audio blob
to the existing /api/transcribe (Google / LiteLLM / ElevenLabs /
Browser Whisper — whatever admin wired up in Settings). Users
cannot pick the provider from this UI.
This commit is contained in:
parent
bc2580b148
commit
ac39554c3a
8 changed files with 1024 additions and 1 deletions
25
migrations/1777003849000_add-personal-notes.js
Normal file
25
migrations/1777003849000_add-personal-notes.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Personal Notes — lightweight per-user scratchpad living under
|
||||
* Clinical Tools. Distinct from user_memories (which feed AI
|
||||
* prompts as style hints / templates): personal_notes are pure
|
||||
* clinician notes, never injected into an AI call. Title + rich-
|
||||
* text body, encrypted at rest like memories so row dumps are
|
||||
* useless without the app crypto key.
|
||||
*/
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.createTable('personal_notes', {
|
||||
id: { type: 'serial', primaryKey: true },
|
||||
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' },
|
||||
title: { type: 'text', notNull: true },
|
||||
body: { type: 'text', notNull: true, default: '' },
|
||||
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
|
||||
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
|
||||
});
|
||||
pgm.createIndex('personal_notes', 'user_id');
|
||||
pgm.createIndex('personal_notes', ['user_id', 'updated_at']);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.dropTable('personal_notes');
|
||||
};
|
||||
87
public/components/notes.html
Normal file
87
public/components/notes.html
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-note-sticky" style="color:#f59e0b;"></i> My Notes</h2>
|
||||
<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 class="notes-layout">
|
||||
|
||||
<!-- Left pane: list + new-note button + search -->
|
||||
<aside class="notes-sidebar">
|
||||
<div class="notes-sidebar-head">
|
||||
<button id="btn-notes-new" class="btn-primary notes-new-btn" type="button">
|
||||
<i class="fas fa-plus"></i> New note
|
||||
</button>
|
||||
<div class="notes-search">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="notes-search" placeholder="Search notes…" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div id="notes-list" class="notes-list">
|
||||
<div class="notes-empty">Loading…</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right pane: editor (hidden until a note is picked / created) -->
|
||||
<section id="notes-editor" class="notes-editor hidden">
|
||||
<div class="notes-editor-head">
|
||||
<input type="text" id="note-title"
|
||||
class="notes-title-input"
|
||||
placeholder="Note title…"
|
||||
maxlength="200"
|
||||
autocomplete="off">
|
||||
<div class="notes-editor-actions">
|
||||
<!-- Voice → AI note: transcribes via the admin-configured
|
||||
STT provider, then asks the AI to produce a clean note.
|
||||
Pause/Resume available natively through MediaRecorder. -->
|
||||
<div class="notes-voice-bar" id="notes-voice-bar">
|
||||
<button id="btn-note-rec-start" class="btn-sm btn-ghost" type="button" title="Record voice → AI note">
|
||||
<i class="fas fa-microphone" style="color:var(--red);"></i> Dictate
|
||||
</button>
|
||||
<button id="btn-note-rec-pause" class="btn-sm btn-ghost hidden" type="button">
|
||||
<i class="fas fa-pause"></i> Pause
|
||||
</button>
|
||||
<button id="btn-note-rec-stop" class="btn-sm hidden" type="button" style="background:var(--red);color:white;border:none;">
|
||||
<i class="fas fa-stop"></i> Stop
|
||||
</button>
|
||||
<span id="notes-rec-indicator" class="notes-rec-indicator hidden">
|
||||
<span class="pulse-dot"></span>
|
||||
<span id="notes-rec-state">Recording</span>
|
||||
<span class="notes-rec-timer" id="notes-rec-timer">00:00</span>
|
||||
</span>
|
||||
</div>
|
||||
<span id="notes-status" class="notes-status"></span>
|
||||
<button id="btn-note-save" class="btn-sm btn-primary" type="button">
|
||||
<i class="fas fa-floppy-disk"></i> Save
|
||||
</button>
|
||||
<button id="btn-note-delete" class="btn-sm" type="button" style="background:var(--red-light);color:var(--red);border:1px solid var(--red);">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
<button id="btn-note-close" class="btn-sm btn-ghost" type="button" title="Close editor">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="note-body-editor" class="notes-body-editor"></div>
|
||||
<div class="notes-editor-foot">
|
||||
<span id="note-meta" class="notes-meta"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Empty placeholder — shown when no note is picked -->
|
||||
<section id="notes-empty-state" class="notes-empty-state">
|
||||
<div class="notes-empty-card">
|
||||
<div class="notes-empty-icon"><i class="fas fa-feather"></i></div>
|
||||
<h3>Hello 👋</h3>
|
||||
<p>Pick a note on the left, or start a fresh one. You can type, paste, or tap <strong>Dictate</strong> to let AI clean up your voice into a polished note.</p>
|
||||
<button id="btn-notes-new-empty" type="button" class="btn-primary notes-empty-cta">
|
||||
<i class="fas fa-plus"></i> Create your first note
|
||||
</button>
|
||||
<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-microphone"></i> <span>Dictate works even offline with Browser Whisper</span></div>
|
||||
<div><i class="fas fa-lock"></i> <span>Encrypted at rest — only you can read them</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
|
@ -853,3 +853,100 @@ textarea.full-input{resize:vertical;}
|
|||
.lh-webdav-dir{color:var(--g700);font-weight:500;}
|
||||
.lh-webdav-file{color:var(--g600);}
|
||||
.lh-webdav-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
|
||||
/* ── Notes tab — personal scratchpad (under Clinical Tools) ───── */
|
||||
.notes-layout{display:grid;grid-template-columns:320px 1fr;gap:14px;align-items:flex-start;min-height:70vh;}
|
||||
@media (max-width:900px){.notes-layout{grid-template-columns:1fr;}}
|
||||
|
||||
.notes-sidebar{background:white;border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;display:flex;flex-direction:column;max-height:80vh;}
|
||||
.notes-sidebar-head{padding:10px;border-bottom:1px solid var(--g200);background:var(--g50);display:flex;flex-direction:column;gap:8px;}
|
||||
.notes-new-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:8px 12px;font-size:13px;font-weight:600;}
|
||||
.notes-search{position:relative;}
|
||||
.notes-search i{position:absolute;top:50%;left:10px;transform:translateY(-50%);color:var(--g400);font-size:12px;pointer-events:none;}
|
||||
.notes-search input{width:100%;padding:7px 10px 7px 30px;border:1px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;box-sizing:border-box;background:white;}
|
||||
.notes-search input:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);}
|
||||
|
||||
.notes-list{flex:1;overflow-y:auto;padding:4px;}
|
||||
.notes-empty{padding:24px 14px;text-align:center;color:var(--g400);font-size:13px;line-height:1.5;}
|
||||
.notes-list-item{display:block;width:100%;text-align:left;padding:10px 12px;border:none;background:transparent;border-radius:8px;cursor:pointer;margin-bottom:2px;font-family:inherit;transition:background 0.1s;border-left:3px solid transparent;}
|
||||
.notes-list-item:hover{background:var(--g50);}
|
||||
.notes-list-item.active{background:var(--blue-light);border-left-color:var(--blue);}
|
||||
.notes-list-title{font-size:13.5px;font-weight:600;color:var(--g800);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:2px;}
|
||||
.notes-list-snippet{font-size:12px;color:var(--g500);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.35;}
|
||||
.notes-list-when{font-size:11px;color:var(--g400);margin-top:4px;}
|
||||
|
||||
.notes-editor{background:white;border-radius:var(--radius);box-shadow:var(--shadow);display:flex;flex-direction:column;min-height:70vh;}
|
||||
.notes-editor.hidden{display:none;}
|
||||
.notes-editor-head{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--g200);background:var(--g50);border-top-left-radius:var(--radius);border-top-right-radius:var(--radius);flex-wrap:wrap;}
|
||||
.notes-title-input{flex:1;min-width:180px;padding:8px 10px;border:1px solid transparent;border-radius:6px;font-size:16px;font-weight:600;color:var(--g900);font-family:inherit;background:transparent;}
|
||||
.notes-title-input:focus{outline:none;background:white;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);}
|
||||
.notes-editor-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
|
||||
.notes-status{font-size:12px;color:var(--g500);min-width:60px;text-align:right;}
|
||||
.notes-status-dirty{color:var(--amber);}
|
||||
.notes-status-saving{color:var(--g500);}
|
||||
.notes-status-ok{color:#059669;}
|
||||
.notes-status-err{color:var(--red);}
|
||||
|
||||
.notes-body-editor{flex:1;display:flex;flex-direction:column;min-height:280px;}
|
||||
.notes-body-editor .tp-toolbar{position:sticky;top:0;z-index:5;}
|
||||
.notes-body-editor .tp-content{flex:1;padding:14px 18px;font-size:14px;line-height:1.6;color:var(--g800);overflow-y:auto;min-height:220px;}
|
||||
.notes-body-editor .tp-content:focus-within .ProseMirror{outline:none;}
|
||||
.notes-body-editor .ProseMirror{outline:none;min-height:200px;}
|
||||
.notes-body-editor .ProseMirror p{margin:0 0 8px;}
|
||||
.notes-body-editor .ProseMirror h2{font-size:18px;font-weight:700;color:var(--g900);margin:16px 0 8px;}
|
||||
.notes-body-editor .ProseMirror h3{font-size:15px;font-weight:700;color:var(--g800);margin:12px 0 6px;}
|
||||
.notes-body-editor .ProseMirror ul,.notes-body-editor .ProseMirror ol{margin:0 0 8px 24px;}
|
||||
.notes-body-editor .ProseMirror blockquote{border-left:3px solid var(--g300);margin:0 0 8px;padding:2px 0 2px 12px;color:var(--g600);font-style:italic;}
|
||||
.notes-body-editor .ProseMirror pre{background:var(--g900);color:#e5e7eb;padding:10px 14px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;overflow-x:auto;margin:0 0 8px;}
|
||||
.notes-body-editor .ProseMirror code{background:var(--g100);padding:1px 5px;border-radius:4px;font-size:12.5px;}
|
||||
.notes-body-editor .ProseMirror a{color:var(--blue);text-decoration:underline;}
|
||||
.notes-body-editor .notes-body-fallback{width:100%;height:100%;min-height:260px;padding:14px 18px;border:none;font-size:14px;line-height:1.6;font-family:inherit;color:var(--g800);resize:vertical;box-sizing:border-box;}
|
||||
|
||||
.notes-editor-foot{padding:8px 14px;border-top:1px solid var(--g200);font-size:11px;color:var(--g400);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;}
|
||||
.notes-meta{font-size:11px;color:var(--g400);}
|
||||
|
||||
.notes-empty-state{background:white;border-radius:var(--radius);box-shadow:var(--shadow);padding:60px 24px;text-align:center;color:var(--g400);display:flex;flex-direction:column;align-items:center;gap:14px;min-height:70vh;justify-content:center;}
|
||||
.notes-empty-state i{font-size:64px;color:var(--g300);}
|
||||
.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 */
|
||||
.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-rec-indicator{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--red);font-weight:600;}
|
||||
.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);}}
|
||||
.notes-rec-timer{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;color:var(--g600);}
|
||||
|
||||
/* Notes — friendlier touches */
|
||||
.notes-empty-state .notes-empty-card{max-width:440px;margin:0 auto;display:flex;flex-direction:column;align-items:center;gap:12px;}
|
||||
.notes-empty-icon{width:72px;height:72px;border-radius:50%;background:linear-gradient(135deg,#fef3c7,#fde68a);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(245,158,11,0.25);}
|
||||
.notes-empty-icon i{font-size:30px;color:#b45309;}
|
||||
.notes-empty-state h3{margin:4px 0 0;font-size:20px;font-weight:600;color:var(--g800);}
|
||||
.notes-empty-state p{font-size:14px;line-height:1.55;color:var(--g500);margin:0;}
|
||||
.notes-empty-cta{margin-top:4px;padding:10px 20px;border-radius:10px;font-weight:600;display:inline-flex;align-items:center;gap:8px;box-shadow:0 2px 6px rgba(37,99,235,0.25);}
|
||||
.notes-empty-cta:hover{box-shadow:0 4px 10px rgba(37,99,235,0.35);}
|
||||
.notes-empty-tips{margin-top:16px;display:flex;flex-direction:column;gap:8px;font-size:12.5px;color:var(--g500);text-align:left;align-self:stretch;padding:14px 18px;background:var(--g50);border-radius:10px;}
|
||||
.notes-empty-tips div{display:flex;align-items:center;gap:10px;}
|
||||
.notes-empty-tips i{color:var(--blue);font-size:13px;width:16px;text-align:center;}
|
||||
.notes-empty-tips kbd{background:white;border:1px solid var(--g300);border-radius:4px;padding:1px 6px;font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;box-shadow:0 1px 0 var(--g200);}
|
||||
|
||||
.notes-list-item{position:relative;transition:background 0.15s, transform 0.15s;}
|
||||
.notes-list-item:hover{transform:translateX(1px);}
|
||||
.notes-list-item.active{box-shadow:inset 0 0 0 1px var(--blue-light);}
|
||||
|
||||
.notes-sidebar-head{padding:12px;}
|
||||
.notes-new-btn{border-radius:10px;padding:9px 14px;font-size:13.5px;box-shadow:0 1px 3px rgba(37,99,235,0.2);}
|
||||
.notes-new-btn:hover{box-shadow:0 2px 6px rgba(37,99,235,0.3);}
|
||||
|
||||
.notes-title-input{font-size:18px;letter-spacing:-0.01em;}
|
||||
.notes-title-input::placeholder{color:var(--g400);font-weight:500;}
|
||||
|
||||
.notes-editor-head{gap:12px;padding:12px 16px;}
|
||||
.notes-body-editor{border-bottom-left-radius:var(--radius);border-bottom-right-radius:var(--radius);}
|
||||
.notes-body-editor .tp-toolbar{border-bottom:1px solid var(--g100);background:#fdfdfd;}
|
||||
|
||||
/* 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);}
|
||||
|
|
|
|||
|
|
@ -248,6 +248,10 @@
|
|||
<i class="fas fa-phone-volume"></i>
|
||||
<span>Pagers & Extensions</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="notes">
|
||||
<i class="fas fa-note-sticky"></i>
|
||||
<span>Notes</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="learning">
|
||||
<i class="fas fa-graduation-cap"></i>
|
||||
<span>Learning Hub</span>
|
||||
|
|
@ -301,6 +305,7 @@
|
|||
<section id="bedside-tab" class="tab-content" data-component="bedside"></section>
|
||||
<section id="calculators-tab" class="tab-content" data-component="calculators"></section>
|
||||
<section id="extensions-tab" class="tab-content" data-component="extensions"></section>
|
||||
<section id="notes-tab" class="tab-content" data-component="notes"></section>
|
||||
<section id="learning-tab" class="tab-content" data-component="learning"></section>
|
||||
<section id="cms-tab" class="tab-content" data-component="cms"></section>
|
||||
<section id="admin-tab" class="tab-content" data-component="admin"></section>
|
||||
|
|
@ -455,6 +460,7 @@
|
|||
<script defer src="/js/milestones.js"></script>
|
||||
<script defer src="/js/peGuide.js"></script>
|
||||
<script defer src="/js/extensions.js"></script>
|
||||
<script defer src="/js/notes.js"></script>
|
||||
<script defer src="/js/nextcloud.js"></script>
|
||||
<script defer src="/js/wellVisit.js"></script>
|
||||
<script defer src="/js/shadess.js"></script>
|
||||
|
|
|
|||
616
public/js/notes.js
Normal file
616
public/js/notes.js
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
// ============================================================
|
||||
// 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).
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
var _inited = false;
|
||||
var _notes = [];
|
||||
var _activeId = null;
|
||||
var _editor = null;
|
||||
var _dirty = false;
|
||||
var _search = '';
|
||||
var _statusTimer = null;
|
||||
|
||||
// Voice recording state (one session at a time — you can dictate into
|
||||
// one note, not two simultaneously).
|
||||
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.
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail.tab !== 'notes') return;
|
||||
if (_inited) { refreshList(); return; }
|
||||
_inited = true;
|
||||
init();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// Ctrl/Cmd+S to save
|
||||
document.addEventListener('keydown', function(e) {
|
||||
var t = document.getElementById('notes-tab');
|
||||
if (!t || !t.classList.contains('active')) return;
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (!document.getElementById('notes-editor').classList.contains('hidden')) saveNote();
|
||||
}
|
||||
});
|
||||
|
||||
refreshList();
|
||||
}
|
||||
|
||||
// ── API ────────────────────────────────────────────────────
|
||||
function refreshList() {
|
||||
var listEl = document.getElementById('notes-list');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<div class="notes-empty">Loading…</div>';
|
||||
fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { listEl.innerHTML = '<div class="notes-empty">' + esc(data.error || 'Failed to load') + '</div>'; return; }
|
||||
_notes = data.notes || [];
|
||||
renderList();
|
||||
})
|
||||
.catch(function(err) {
|
||||
listEl.innerHTML = '<div class="notes-empty">' + esc(err.message || 'Load failed') + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
var listEl = document.getElementById('notes-list');
|
||||
if (!listEl) return;
|
||||
var filtered = _notes;
|
||||
if (_search) {
|
||||
filtered = _notes.filter(function(n) {
|
||||
var hay = (n.title + ' ' + stripTags(n.body || '')).toLowerCase();
|
||||
return hay.indexOf(_search) !== -1;
|
||||
});
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML = '<div class="notes-empty">'
|
||||
+ (_search ? 'No notes match "' + esc(_search) + '".' : 'No notes yet. Click <strong>New note</strong> above to create one.')
|
||||
+ '</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = filtered.map(function(n) {
|
||||
var snippet = stripTags(n.body || '').substring(0, 110);
|
||||
var when = formatWhen(n.updated_at);
|
||||
var active = (n.id === _activeId) ? ' active' : '';
|
||||
return '<button type="button" class="notes-list-item' + active + '" data-id="' + n.id + '">'
|
||||
+ '<div class="notes-list-title">' + esc(n.title || 'Untitled') + '</div>'
|
||||
+ '<div class="notes-list-snippet">' + esc(snippet) + '</div>'
|
||||
+ '<div class="notes-list-when">' + esc(when) + '</div>'
|
||||
+ '</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openEditor(id) {
|
||||
if (_dirty && !confirmDiscard()) 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.
|
||||
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
|
||||
}
|
||||
|
||||
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');
|
||||
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 === '<p></p>') body = '';
|
||||
|
||||
var saveBtn = document.getElementById('btn-note-save');
|
||||
saveBtn.disabled = true;
|
||||
updateStatus('Saving…', 'saving');
|
||||
|
||||
var isNew = _activeId == null;
|
||||
var url = isNew ? '/api/notes' : '/api/notes/' + _activeId;
|
||||
var method = isNew ? 'POST' : 'PUT';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ title: title, body: body }),
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
saveBtn.disabled = false;
|
||||
if (!data.success) { updateStatus(data.error || 'Save failed', 'err'); return; }
|
||||
if (isNew && data.id) _activeId = data.id;
|
||||
_dirty = false;
|
||||
updateStatus('Saved', 'ok');
|
||||
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);
|
||||
}
|
||||
renderList();
|
||||
});
|
||||
})
|
||||
.catch(function(err) {
|
||||
saveBtn.disabled = false;
|
||||
updateStatus(err.message || 'Save failed', 'err');
|
||||
});
|
||||
}
|
||||
|
||||
function deleteNote() {
|
||||
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',
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { updateStatus(data.error || 'Delete failed', 'err'); return; }
|
||||
_notes = _notes.filter(function(n) { return n.id !== _activeId; });
|
||||
_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();
|
||||
if (typeof showToast === 'function') showToast('Note deleted', 'success');
|
||||
})
|
||||
.catch(function(err) { updateStatus(err.message || 'Delete failed', 'err'); });
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
_recorder = new AudioRecorder();
|
||||
_recorder.start().then(function() {
|
||||
_recActive = true;
|
||||
_recPaused = false;
|
||||
setRecUI('recording');
|
||||
startRecTimer();
|
||||
}).catch(function(err) {
|
||||
updateStatus('Mic denied: ' + (err && err.message || ''), 'err');
|
||||
});
|
||||
}
|
||||
|
||||
function togglePauseRecording() {
|
||||
if (!_recActive || !_recorder || !_recorder.mediaRecorder) return;
|
||||
if (!_recPaused) {
|
||||
try { _recorder.mediaRecorder.pause(); } catch (e) {}
|
||||
_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.
|
||||
try {
|
||||
if (_recorder.stream && _recorder.stream.active) {
|
||||
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||
_recorder.mediaRecorder = new MediaRecorder(_recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
|
||||
_recorder.mediaRecorder.ondataavailable = function(ev) { if (ev.data.size > 0) _recorder.chunks.push(ev.data); };
|
||||
_recorder.mediaRecorder.start(1000);
|
||||
}
|
||||
} catch (e2) {}
|
||||
}
|
||||
_recPaused = false;
|
||||
startRecTimer();
|
||||
setRecUI('recording');
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (!_recActive || !_recorder) return;
|
||||
_recActive = false;
|
||||
_recPaused = false;
|
||||
stopRecTimer(true);
|
||||
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;
|
||||
}
|
||||
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('Generating note…', 'saving');
|
||||
return fetch('/api/notes/from-voice', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
|
||||
body: JSON.stringify({ transcript: resp.text }),
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
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');
|
||||
});
|
||||
});
|
||||
}).catch(function(err) {
|
||||
setRecUI('idle');
|
||||
updateStatus(err.message || 'Recording failed', 'err');
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
if (!container) return;
|
||||
if (_editor) { _editor.destroy(); _editor = null; }
|
||||
mountTiptap(container, body);
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
function startRecTimer() {
|
||||
var el = document.getElementById('notes-rec-timer');
|
||||
if (_recTimer || !el) return;
|
||||
_recTimer = createTimerLite(el);
|
||||
_recTimer.start();
|
||||
}
|
||||
function stopRecTimer(reset) {
|
||||
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'); }
|
||||
return {
|
||||
start: function() { paint(); if (!iv) iv = setInterval(function() { s++; paint(); }, 1000); },
|
||||
stop: function() { if (iv) { clearInterval(iv); iv = null; } },
|
||||
reset: function() { s = 0; paint(); },
|
||||
};
|
||||
}
|
||||
|
||||
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 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');
|
||||
|
||||
start.classList.toggle('hidden', !idle);
|
||||
pause.classList.toggle('hidden', !(recording || paused));
|
||||
stopBtn.classList.toggle('hidden', !(recording || paused));
|
||||
ind.classList.toggle('hidden', idle);
|
||||
|
||||
if (pause) pause.innerHTML = paused ? '<i class="fas fa-play"></i> Resume' : '<i class="fas fa-pause"></i> Pause';
|
||||
if (stateEl) stateEl.textContent = processing ? 'Processing…' : (paused ? 'Paused' : 'Recording');
|
||||
if (dot) dot.classList.toggle('paused', paused || processing);
|
||||
|
||||
start.disabled = !idle;
|
||||
pause.disabled = processing;
|
||||
stopBtn.disabled = processing;
|
||||
}
|
||||
|
||||
// ── Tiptap ────────────────────────────────────────────────
|
||||
function mountTiptap(container, initialHtml) {
|
||||
var T = window.Tiptap || {};
|
||||
if (!T.Editor) {
|
||||
container.innerHTML = '<textarea class="notes-body-fallback" placeholder="Write your note…">' + esc(stripTags(initialHtml)) + '</textarea>';
|
||||
_editor = null;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = toolbarHTML() + '<div class="tp-content"></div>';
|
||||
_editor = new T.Editor({
|
||||
element: container.querySelector('.tp-content'),
|
||||
extensions: [
|
||||
T.StarterKit,
|
||||
T.Link.configure({ openOnClick: false, autolink: true }),
|
||||
T.Underline
|
||||
],
|
||||
content: initialHtml || '',
|
||||
autofocus: false,
|
||||
onUpdate: function() { _dirty = true; updateStatus('Unsaved', 'dirty'); updateToolbarState(container); },
|
||||
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 = ''
|
||||
+ '<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="italic" title="Italic"><i class="fas fa-italic"></i></button>'
|
||||
+ '<button type="button" class="tp-btn" data-cmd="underline" title="Underline"><i class="fas fa-underline"></i></button>'
|
||||
+ '<button type="button" class="tp-btn" data-cmd="strike" title="Strike"><i class="fas fa-strikethrough"></i></button>'
|
||||
+ '<span class="tp-sep"></span>'
|
||||
+ '<button type="button" class="tp-btn" data-cmd="h2" title="Heading 2">H2</button>'
|
||||
+ '<button type="button" class="tp-btn" data-cmd="h3" title="Heading 3">H3</button>'
|
||||
+ '<span class="tp-sep"></span>'
|
||||
+ '<button type="button" class="tp-btn" data-cmd="bulletList" title="Bullet list"><i class="fas fa-list-ul"></i></button>'
|
||||
+ '<button type="button" class="tp-btn" data-cmd="orderedList" title="Numbered list"><i class="fas fa-list-ol"></i></button>'
|
||||
+ '<button type="button" class="tp-btn" data-cmd="blockquote" title="Quote"><i class="fas fa-quote-left"></i></button>'
|
||||
+ '<button type="button" class="tp-btn" data-cmd="codeBlock" title="Code"><i class="fas fa-code"></i></button>'
|
||||
+ '<span class="tp-sep"></span>'
|
||||
+ '<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>'
|
||||
+ '<span class="tp-sep"></span>'
|
||||
+ '<button type="button" class="tp-btn" data-cmd="clear" title="Clear formatting"><i class="fas fa-remove-format"></i></button>';
|
||||
return ''
|
||||
+ '<div class="tp-toolbar">' + btns + '</div>'
|
||||
+ '<div class="tp-link-bar" style="display:none;">'
|
||||
+ '<input type="url" class="tp-link-input" placeholder="https://">'
|
||||
+ '<button type="button" class="tp-link-apply">Apply</button>'
|
||||
+ '<button type="button" class="tp-link-remove">Remove</button>'
|
||||
+ '<button type="button" class="tp-link-cancel">✕</button>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function wireToolbar(wrap, ed) {
|
||||
var toolbar = wrap.querySelector('.tp-toolbar');
|
||||
var linkBar = wrap.querySelector('.tp-link-bar');
|
||||
var linkInput = wrap.querySelector('.tp-link-input');
|
||||
|
||||
toolbar.addEventListener('mousedown', function(e) {
|
||||
var btn = e.target.closest('.tp-btn[data-cmd]');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
var cmd = btn.dataset.cmd;
|
||||
switch (cmd) {
|
||||
case 'bold': ed.chain().focus().toggleBold().run(); break;
|
||||
case 'italic': ed.chain().focus().toggleItalic().run(); break;
|
||||
case 'underline': ed.chain().focus().toggleUnderline().run(); break;
|
||||
case 'strike': ed.chain().focus().toggleStrike().run(); break;
|
||||
case 'h2': ed.chain().focus().toggleHeading({ level: 2 }).run(); break;
|
||||
case 'h3': ed.chain().focus().toggleHeading({ level: 3 }).run(); break;
|
||||
case 'bulletList': ed.chain().focus().toggleBulletList().run(); break;
|
||||
case 'orderedList': ed.chain().focus().toggleOrderedList().run(); break;
|
||||
case 'blockquote': ed.chain().focus().toggleBlockquote().run(); break;
|
||||
case 'codeBlock': ed.chain().focus().toggleCodeBlock().run(); break;
|
||||
case 'clear': ed.chain().focus().unsetAllMarks().clearNodes().run(); break;
|
||||
case 'link':
|
||||
if (linkBar.style.display === 'none') {
|
||||
linkInput.value = ed.getAttributes('link').href || '';
|
||||
linkBar.style.display = 'flex';
|
||||
setTimeout(function() { linkInput.focus(); }, 0);
|
||||
} else {
|
||||
linkBar.style.display = 'none';
|
||||
}
|
||||
break;
|
||||
}
|
||||
updateToolbarState(wrap);
|
||||
});
|
||||
|
||||
wrap.querySelector('.tp-link-apply').addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
var url = linkInput.value.trim();
|
||||
if (url) ed.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
linkBar.style.display = 'none';
|
||||
});
|
||||
wrap.querySelector('.tp-link-remove').addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
ed.chain().focus().unsetLink().run();
|
||||
linkBar.style.display = 'none';
|
||||
});
|
||||
wrap.querySelector('.tp-link-cancel').addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
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 === 'Escape') { linkBar.style.display = 'none'; }
|
||||
});
|
||||
}
|
||||
|
||||
function updateToolbarState(wrap) {
|
||||
if (!_editor) return;
|
||||
wrap.querySelectorAll('.tp-btn[data-cmd]').forEach(function(btn) {
|
||||
var cmd = btn.dataset.cmd;
|
||||
var active = false;
|
||||
if (cmd === 'bold') active = _editor.isActive('bold');
|
||||
else if (cmd === 'italic') active = _editor.isActive('italic');
|
||||
else if (cmd === 'underline') active = _editor.isActive('underline');
|
||||
else if (cmd === 'strike') active = _editor.isActive('strike');
|
||||
else if (cmd === 'h2') active = _editor.isActive('heading', { level: 2 });
|
||||
else if (cmd === 'h3') active = _editor.isActive('heading', { level: 3 });
|
||||
else if (cmd === 'bulletList') active = _editor.isActive('bulletList');
|
||||
else if (cmd === 'orderedList') active = _editor.isActive('orderedList');
|
||||
else if (cmd === 'blockquote') active = _editor.isActive('blockquote');
|
||||
else if (cmd === 'codeBlock') active = _editor.isActive('codeBlock');
|
||||
else if (cmd === 'link') active = _editor.isActive('link');
|
||||
btn.classList.toggle('active', active);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────
|
||||
function updateStatus(text, kind) {
|
||||
var el = document.getElementById('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);
|
||||
}
|
||||
}
|
||||
|
||||
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, ''');
|
||||
}
|
||||
|
||||
function stripTags(html) {
|
||||
if (!html) return '';
|
||||
var d = document.createElement('div');
|
||||
d.innerHTML = html;
|
||||
return (d.textContent || d.innerText || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
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 {};
|
||||
}
|
||||
})();
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
// API calls always fresh (critical for medical data accuracy)
|
||||
// ============================================================
|
||||
|
||||
var CACHE_NAME = 'pedscribe-v12';
|
||||
var CACHE_NAME = 'pedscribe-v12-notes1';
|
||||
var SHELL_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ app.use('/api', require('./src/routes/refine'));
|
|||
app.use('/api', require('./src/routes/logs'));
|
||||
app.use('/api', require('./src/routes/encounters'));
|
||||
app.use('/api', require('./src/routes/memories'));
|
||||
app.use('/api', require('./src/routes/notes'));
|
||||
app.use('/api', require('./src/routes/documents'));
|
||||
app.use('/api', require('./src/routes/audioBackups'));
|
||||
app.use('/api', require('./src/routes/billing'));
|
||||
|
|
|
|||
191
src/routes/notes.js
Normal file
191
src/routes/notes.js
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// ============================================================
|
||||
// PERSONAL NOTES ROUTES — per-user scratchpad, rich-text body.
|
||||
// Pure CRUD, auth-gated. Body + title encrypted at rest (same
|
||||
// crypto helper as user_memories so a row dump stays useless
|
||||
// without the app key).
|
||||
// ============================================================
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var db = require('../db/database');
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
var logger = require('../utils/logger');
|
||||
var cryptoUtil = require('../utils/crypto');
|
||||
var { callAI } = require('../utils/ai');
|
||||
var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
var MAX_TITLE = 200;
|
||||
var MAX_BODY = 50000; // 50 KB of rich-text HTML is plenty for a clinical note
|
||||
var MAX_NOTES_PER_USER = 500;
|
||||
|
||||
function decryptRow(row) {
|
||||
if (!row) return row;
|
||||
try { row.title = cryptoUtil.decryptString(row.title); } catch (e) {}
|
||||
try { row.body = cryptoUtil.decryptString(row.body); } catch (e) {}
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── GET list ────────────────────────────────────────────────
|
||||
// Returns all notes for the current user, newest-updated first.
|
||||
// Title + body are decrypted on the way out; caller renders body
|
||||
// HTML through the existing sanitize-html pipeline on display.
|
||||
router.get('/notes', async function (req, res) {
|
||||
try {
|
||||
var rows = await db.all(
|
||||
'SELECT id, title, body, created_at, updated_at FROM personal_notes WHERE user_id = $1 ORDER BY updated_at DESC',
|
||||
[req.user.id]
|
||||
);
|
||||
rows.forEach(decryptRow);
|
||||
res.json({ success: true, notes: rows });
|
||||
} catch (e) {
|
||||
logger.error('GET /notes', e.message);
|
||||
res.status(500).json({ error: 'Request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET one ─────────────────────────────────────────────────
|
||||
router.get('/notes/:id', async function (req, res) {
|
||||
try {
|
||||
var row = await db.get(
|
||||
'SELECT id, title, body, created_at, updated_at FROM personal_notes WHERE id = $1 AND user_id = $2',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: 'Note not found' });
|
||||
res.json({ success: true, note: decryptRow(row) });
|
||||
} catch (e) {
|
||||
logger.error('GET /notes/:id', e.message);
|
||||
res.status(500).json({ error: 'Request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST create ─────────────────────────────────────────────
|
||||
router.post('/notes', async function (req, res) {
|
||||
try {
|
||||
var title = (req.body.title || '').trim();
|
||||
var body = (req.body.body || '').trim();
|
||||
if (!title) return res.status(400).json({ error: 'Title required' });
|
||||
|
||||
var count = await db.get('SELECT COUNT(*) as cnt FROM personal_notes WHERE user_id = $1', [req.user.id]);
|
||||
if (count && parseInt(count.cnt) >= MAX_NOTES_PER_USER) {
|
||||
return res.status(400).json({ error: 'Maximum ' + MAX_NOTES_PER_USER + ' notes per user' });
|
||||
}
|
||||
|
||||
var result = await db.run(
|
||||
'INSERT INTO personal_notes (user_id, title, body) VALUES ($1, $2, $3) RETURNING id',
|
||||
[
|
||||
req.user.id,
|
||||
cryptoUtil.encryptString(title.substring(0, MAX_TITLE)),
|
||||
cryptoUtil.encryptString(body.substring(0, MAX_BODY)),
|
||||
]
|
||||
);
|
||||
res.json({ success: true, id: result.lastInsertRowid });
|
||||
logger.audit(req.user.id, 'create_note', 'Created personal note', req, { category: 'notes' });
|
||||
} catch (e) {
|
||||
logger.error('POST /notes', e.message);
|
||||
res.status(500).json({ error: 'Request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── PUT update ──────────────────────────────────────────────
|
||||
router.put('/notes/:id', async function (req, res) {
|
||||
try {
|
||||
var title = (req.body.title || '').trim();
|
||||
var body = (req.body.body || '').trim();
|
||||
if (!title) return res.status(400).json({ error: 'Title required' });
|
||||
|
||||
var existing = await db.get('SELECT id FROM personal_notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'Note not found' });
|
||||
|
||||
await db.run(
|
||||
'UPDATE personal_notes SET title = $1, body = $2, updated_at = NOW() WHERE id = $3 AND user_id = $4',
|
||||
[
|
||||
cryptoUtil.encryptString(title.substring(0, MAX_TITLE)),
|
||||
cryptoUtil.encryptString(body.substring(0, MAX_BODY)),
|
||||
req.params.id,
|
||||
req.user.id,
|
||||
]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
logger.error('PUT /notes/:id', e.message);
|
||||
res.status(500).json({ error: 'Request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── DELETE ──────────────────────────────────────────────────
|
||||
router.delete('/notes/:id', async function (req, res) {
|
||||
try {
|
||||
await db.run('DELETE FROM personal_notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
|
||||
res.json({ success: true });
|
||||
logger.audit(req.user.id, 'delete_note', 'Deleted personal note', req, { category: 'notes' });
|
||||
} catch (e) {
|
||||
logger.error('DELETE /notes/:id', e.message);
|
||||
res.status(500).json({ error: 'Request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/notes/from-voice ──────────────────────────────
|
||||
// Takes a raw voice transcript (produced by the shared
|
||||
// /api/transcribe endpoint — whichever STT provider the admin
|
||||
// configured, not user-selectable) and asks the AI to produce
|
||||
// a clean, well-structured personal note: one short title
|
||||
// followed by rich-text body HTML. Returned as-is to the client
|
||||
// which drops it straight into the editor. The client stays
|
||||
// in control of Save — this endpoint never touches the DB.
|
||||
router.post('/notes/from-voice', async function (req, res) {
|
||||
try {
|
||||
var transcript = (req.body.transcript || '').trim();
|
||||
if (!transcript) return res.status(400).json({ error: 'No transcript provided' });
|
||||
|
||||
var systemPrompt =
|
||||
'You are a medical scribe turning a physician\'s dictated notes into a clean personal note.\n' +
|
||||
'Output STRICT JSON only — no preamble, no code fences, no commentary.\n' +
|
||||
'Shape: {"title": "<short descriptive title, max 80 chars>", "body": "<HTML body>"}.\n' +
|
||||
'The body must be HTML using only these tags: <p>, <h2>, <h3>, <strong>, <em>, <u>, <ul>, <ol>, <li>, <blockquote>, <code>, <a href="…">, <br>.\n' +
|
||||
'Tone: concise clinical prose. Preserve all clinical facts the physician dictated — never invent, never drop.\n' +
|
||||
'If the dictation is fragmentary, still produce a title and a clear body. Return pure JSON.' +
|
||||
INJECTION_GUARD;
|
||||
|
||||
var userContent =
|
||||
'Physician voice dictation to convert into a personal note:\n' +
|
||||
wrapUserText('dictation', transcript);
|
||||
|
||||
var result = await callAI([
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userContent }
|
||||
], { model: req.body.model, maxTokens: 4000 });
|
||||
|
||||
// The model sometimes wraps JSON in markdown fences or adds text
|
||||
// before the {. Strip anything before the first { to recover.
|
||||
var raw = (result.content || '').trim();
|
||||
var jsonStart = raw.indexOf('{');
|
||||
if (jsonStart > 0) raw = raw.substring(jsonStart);
|
||||
var jsonEnd = raw.lastIndexOf('}');
|
||||
if (jsonEnd > -1 && jsonEnd < raw.length - 1) raw = raw.substring(0, jsonEnd + 1);
|
||||
|
||||
var parsed;
|
||||
try { parsed = JSON.parse(raw); }
|
||||
catch (e) {
|
||||
// Fall back to raw transcript as body if the model didn't cooperate.
|
||||
parsed = {
|
||||
title: transcript.split(/[.\n]/)[0].substring(0, 80) || 'Voice note',
|
||||
body: '<p>' + transcript.replace(/</g, '<').replace(/>/g, '>').replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>',
|
||||
};
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
title: (parsed.title || '').substring(0, 200),
|
||||
body: parsed.body || '',
|
||||
model: result.model,
|
||||
});
|
||||
logger.audit(req.user.id, 'note_from_voice', 'AI-generated note from voice', req, { category: 'notes' });
|
||||
} catch (err) {
|
||||
logger.error('POST /notes/from-voice', err.message);
|
||||
res.status(500).json({ error: 'Request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Loading…
Reference in a new issue