// ============================================================ // 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 = '
Loading…
'; fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) { listEl.innerHTML = '
' + esc(data.error || 'Failed to load') + '
'; return; } _notes = data.notes || []; renderList(); }) .catch(function(err) { listEl.innerHTML = '
' + esc(err.message || 'Load failed') + '
'; }); } 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 = '
' + (_search ? 'No notes match "' + esc(_search) + '".' : 'No notes yet. Click New note above to create one.') + '
'; 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 ''; }).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 === '

') 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 ? ' Resume' : ' 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 = ''; _editor = null; return; } container.innerHTML = toolbarHTML() + '
'; _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 = '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; return '' + '
' + btns + '
' + ''; } 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, '''); } 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 {}; } })();