Add timestamped notes: multi-note support, quick-capture hotkey, live saved indicator

Replace the single free-text note per lesson with a list of timestamped
note entries, migrated transparently from the old format. Adds a
floating notes panel on the lesson page with an always-reachable
quick-capture bar (press N to pause and jump in), inline edit/delete,
click-to-seek timestamps, and a live "Saved Xs ago" indicator. Notes
Hub, the study guide export, and the Outline push all updated to
render multiple timestamped notes per lesson.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 20:17:20 -04:00
co-authored by Claude Sonnet 5
parent c871d6efae
commit 8b365c4463
3 changed files with 569 additions and 98 deletions
+378 -55
View File
@@ -185,21 +185,9 @@
.content {
margin: 20px 0;
}
.notes-section {
.outline-section {
margin: 20px 0;
}
.lesson-note-textarea {
width: 100%;
min-height: 90px;
padding: 12px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
font-family: var(--font-family);
font-size: 0.95em;
resize: vertical;
}
.note-status {
display: block;
font-size: 0.85em;
@@ -207,6 +195,144 @@
margin-top: 6px;
min-height: 1.2em;
}
.page-content .container {
padding-bottom: 90px;
}
.notes-panel {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 9000;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
box-shadow: 0 -4px 14px rgba(0, 0, 0, 0.3);
}
.notes-panel-list {
max-height: 0;
overflow-y: auto;
transition: max-height 0.25s ease;
}
.notes-panel-list.expanded {
max-height: 40vh;
border-bottom: 1px solid var(--border-color);
}
.notes-list {
padding: 10px 16px;
}
.notes-empty-hint {
color: var(--text-muted);
font-size: 0.9em;
margin: 6px 0;
}
.note-entry {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
}
.note-entry:last-child {
border-bottom: none;
}
.note-timestamp-badge {
flex-shrink: 0;
background: var(--bg-tertiary);
color: var(--accent);
border: none;
border-radius: var(--radius);
padding: 2px 8px;
margin: 0;
font-size: 0.8em;
font-family: var(--font-family);
}
.note-timestamp-badge:hover {
background: var(--bg-tertiary-hover);
}
.note-entry-text {
flex: 1;
min-width: 0;
word-break: break-word;
white-space: pre-wrap;
font-size: 0.95em;
padding-top: 3px;
}
.note-edit-textarea {
flex: 1;
min-width: 0;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 6px 8px;
font-family: var(--font-family);
font-size: 0.95em;
resize: vertical;
}
.note-entry-edit,
.note-entry-delete {
flex-shrink: 0;
background: transparent;
color: var(--text-muted);
padding: 2px 6px;
margin: 0;
font-size: 0.9em;
}
.note-entry-edit:hover,
.note-entry-delete:hover {
background: var(--bg-tertiary-hover);
color: var(--text-primary);
}
.notes-panel-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 10px 16px;
}
.notes-panel-toggle {
background: transparent;
color: var(--text-primary);
padding: 6px 10px;
margin: 0;
font-size: 0.9em;
flex-shrink: 0;
}
.notes-panel-toggle:hover {
background: var(--bg-tertiary-hover);
}
.note-quick-timestamp {
flex-shrink: 0;
color: var(--text-muted);
font-size: 0.85em;
font-family: monospace;
min-width: 2.5em;
}
.note-quick-input {
flex: 1;
min-width: 120px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 8px 10px;
font-family: var(--font-family);
font-size: 0.95em;
}
.note-quick-save {
flex-shrink: 0;
padding: 8px 16px;
margin: 0;
font-size: 0.9em;
}
.note-saved-row {
padding: 0 16px calc(6px + env(safe-area-inset-bottom, 0px));
min-height: 1.2em;
}
.note-saved-indicator {
font-size: 0.8em;
color: var(--text-muted);
}
.outline-topic-row {
display: flex;
align-items: center;
@@ -376,14 +502,10 @@
{% endif %}
</div>
<div class="notes-section">
<h3>Notes</h3>
<textarea id="lesson-note" class="lesson-note-textarea"
placeholder="Jot something down about this lesson…">{{ lesson_note }}</textarea>
<span id="note-status" class="note-status"></span>
<div class="outline-section">
<h3>Push to Outline</h3>
<div class="outline-topic-row">
<label for="outline-topic-select">Push to Outline topic</label>
<label for="outline-topic-select">Push notes to Outline topic</label>
<select id="outline-topic-select" onchange="handleOutlineTopicChange()">
<option value="">— Don't push —</option>
<option value="__new__">+ New topic…</option>
@@ -394,6 +516,21 @@
<span id="outline-topic-status" class="note-status"></span>
</div>
<div class="notes-panel" id="notes-panel">
<div class="notes-panel-list" id="notes-panel-list-wrap">
<div class="notes-list" id="notes-list"></div>
</div>
<div class="notes-panel-bar">
<button type="button" class="notes-panel-toggle" id="notes-panel-toggle" onclick="toggleNotesPanel()">
<span id="notes-panel-toggle-arrow"></span> Notes <span id="notes-panel-count"></span>
</button>
<span class="note-quick-timestamp" id="quick-note-timestamp"></span>
<input type="text" id="quick-note-input" class="note-quick-input" placeholder="Add a note… (press N)">
<button type="button" class="note-quick-save" onclick="submitQuickNote()">Save</button>
</div>
<div class="note-saved-row"><span class="note-saved-indicator" id="note-saved-indicator"></span></div>
</div>
<div class="nav-buttons">
{% if prev_lesson %}
<a href="/lesson/{{ prev_lesson }}">
@@ -562,47 +699,228 @@
}).catch(() => {});
}
// Lesson notes: debounced autosave on typing, plus an immediate
// save on blur so navigating away doesn't lose the last edit.
const noteField = document.getElementById('lesson-note');
const noteStatus = document.getElementById('note-status');
let noteSaveTimeout = null;
function saveNote() {
if (!noteField) return;
fetch('/api/lesson-note', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lesson_path: '{{ lesson_path }}', note: noteField.value })
})
.then(() => {
if (noteStatus) {
noteStatus.textContent = 'Saved';
setTimeout(() => { noteStatus.textContent = ''; }, 2000);
}
})
.catch(() => {
if (noteStatus) noteStatus.textContent = 'Could not save note';
});
}
if (noteField) {
noteField.addEventListener('input', function() {
clearTimeout(noteSaveTimeout);
noteSaveTimeout = setTimeout(saveNote, 1000);
});
noteField.addEventListener('blur', function() {
clearTimeout(noteSaveTimeout);
saveNote();
});
}
// ---- Outline topic chooser + push-on-leave ----
const LESSON_PATH = {{ lesson_path|tojson }};
const LESSON_TITLE = {{ lesson.title|tojson }};
const INITIAL_TOPIC_ID = {{ outline_topic_id|tojson }};
const INITIAL_TOPIC_NAME = {{ outline_topic_name|tojson }};
// ---- Timestamped notes: floating panel, quick capture, live "Saved Xs ago" ----
const LESSON_NOTES_INITIAL = {{ lesson_notes|tojson }};
const INITIAL_SEEK_SECONDS = {{ initial_seek_seconds|tojson }};
let lessonNotes = LESSON_NOTES_INITIAL.slice();
let lastSavedAt = null;
let savedTickInterval = null;
const notesList = document.getElementById('notes-list');
const notesPanelCount = document.getElementById('notes-panel-count');
const notesPanelListWrap = document.getElementById('notes-panel-list-wrap');
const notesPanelToggleArrow = document.getElementById('notes-panel-toggle-arrow');
const quickNoteInput = document.getElementById('quick-note-input');
const quickNoteTimestamp = document.getElementById('quick-note-timestamp');
const noteSavedIndicator = document.getElementById('note-saved-indicator');
function formatTimestamp(seconds) {
if (seconds === null || seconds === undefined) return null;
seconds = Math.floor(seconds);
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function renderNotes() {
if (!notesList) return;
if (notesPanelCount) notesPanelCount.textContent = lessonNotes.length ? `(${lessonNotes.length})` : '';
if (!lessonNotes.length) {
notesList.innerHTML = '<p class="notes-empty-hint">No notes yet — capture one below.</p>';
return;
}
const sorted = lessonNotes.slice().sort(function(a, b) {
const at = (a.timestamp_seconds === null || a.timestamp_seconds === undefined) ? Infinity : a.timestamp_seconds;
const bt = (b.timestamp_seconds === null || b.timestamp_seconds === undefined) ? Infinity : b.timestamp_seconds;
return at - bt;
});
notesList.innerHTML = sorted.map(function(n) {
const label = formatTimestamp(n.timestamp_seconds);
const badge = label
? `<button type="button" class="note-timestamp-badge" onclick="seekTo(${n.timestamp_seconds})">${label}</button>`
: '';
return `<div class="note-entry" data-note-id="${n.id}">
${badge}
<span class="note-entry-text">${escapeHtml(n.text)}</span>
<button type="button" class="note-entry-edit" onclick="startEditNote('${n.id}')" title="Edit">✏️</button>
<button type="button" class="note-entry-delete" onclick="deleteNoteEntry('${n.id}')" title="Delete">🗑</button>
</div>`;
}).join('');
}
function seekTo(seconds) {
if (!activeMedia) return;
activeMedia.currentTime = seconds;
activeMedia.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function startEditNote(id) {
const note = lessonNotes.find(function(n) { return n.id === id; });
const entryEl = document.querySelector(`.note-entry[data-note-id="${id}"]`);
if (!note || !entryEl) return;
const textEl = entryEl.querySelector('.note-entry-text');
const textarea = document.createElement('textarea');
textarea.className = 'note-edit-textarea';
textarea.rows = 2;
textarea.value = note.text;
textEl.replaceWith(textarea);
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
function commit() {
const val = textarea.value.trim();
if (val && val !== note.text) {
updateNoteEntry(id, val);
} else {
renderNotes();
}
}
textarea.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { e.preventDefault(); renderNotes(); }
});
textarea.addEventListener('blur', commit);
}
function addNoteEntry(text, timestampSeconds) {
fetch('/api/lesson-note/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lesson_path: LESSON_PATH, text: text, timestamp_seconds: timestampSeconds })
})
.then(r => r.json())
.then(data => {
if (data.success) {
lessonNotes = data.notes;
renderNotes();
markSaved();
} else if (noteSavedIndicator) {
noteSavedIndicator.textContent = 'Could not save note';
}
})
.catch(() => { if (noteSavedIndicator) noteSavedIndicator.textContent = 'Could not save note'; });
}
function updateNoteEntry(id, text) {
fetch('/api/lesson-note/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lesson_path: LESSON_PATH, note_id: id, text: text })
})
.then(r => r.json())
.then(data => {
if (data.success) {
lessonNotes = data.notes;
renderNotes();
markSaved();
}
})
.catch(() => {});
}
function deleteNoteEntry(id) {
fetch('/api/lesson-note/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lesson_path: LESSON_PATH, note_id: id })
})
.then(r => r.json())
.then(data => {
if (data.success) {
lessonNotes = data.notes;
renderNotes();
markSaved();
}
})
.catch(() => {});
}
function markSaved() {
lastSavedAt = Date.now();
updateSavedLabel();
if (savedTickInterval) clearInterval(savedTickInterval);
savedTickInterval = setInterval(updateSavedLabel, 1000);
}
function updateSavedLabel() {
if (!noteSavedIndicator || !lastSavedAt) return;
const secs = Math.floor((Date.now() - lastSavedAt) / 1000);
let text;
if (secs < 2) text = 'Saved just now';
else if (secs < 60) text = `Saved ${secs}s ago`;
else if (secs < 3600) text = `Saved ${Math.floor(secs / 60)}m ago`;
else text = `Saved ${Math.floor(secs / 3600)}h ago`;
noteSavedIndicator.textContent = text;
}
function submitQuickNote() {
if (!quickNoteInput) return;
const text = quickNoteInput.value.trim();
if (!text) return;
const ts = activeMedia ? Math.floor(activeMedia.currentTime) : null;
addNoteEntry(text, ts);
quickNoteInput.value = '';
}
if (quickNoteInput) {
quickNoteInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); submitQuickNote(); }
if (e.key === 'Escape') { e.preventDefault(); quickNoteInput.blur(); }
});
}
function updateQuickCaptureBadge() {
if (!quickNoteTimestamp) return;
if (activeMedia) {
quickNoteTimestamp.textContent = formatTimestamp(activeMedia.currentTime);
quickNoteTimestamp.style.display = '';
} else {
quickNoteTimestamp.style.display = 'none';
}
}
updateQuickCaptureBadge();
if (activeMedia) {
activeMedia.addEventListener('timeupdate', updateQuickCaptureBadge);
}
function toggleNotesPanel() {
if (!notesPanelListWrap) return;
const expanded = notesPanelListWrap.classList.toggle('expanded');
if (notesPanelToggleArrow) notesPanelToggleArrow.textContent = expanded ? '▴' : '▾';
localStorage.setItem('offlineu-notes-panel-expanded', expanded ? '1' : '0');
}
if (notesPanelListWrap && localStorage.getItem('offlineu-notes-panel-expanded') === '1') {
notesPanelListWrap.classList.add('expanded');
if (notesPanelToggleArrow) notesPanelToggleArrow.textContent = '▴';
}
function openQuickCapture() {
if (activeMedia && !activeMedia.paused) activeMedia.pause();
updateQuickCaptureBadge();
if (quickNoteInput) quickNoteInput.focus();
}
renderNotes();
if (activeMedia && INITIAL_SEEK_SECONDS !== null && INITIAL_SEEK_SECONDS !== undefined) {
const doInitialSeek = function() { activeMedia.currentTime = INITIAL_SEEK_SECONDS; };
if (activeMedia.readyState >= 1) {
doInitialSeek();
} else {
activeMedia.addEventListener('loadedmetadata', doInitialSeek, { once: true });
}
}
const topicSelect = document.getElementById('outline-topic-select');
const newTopicInput = document.getElementById('outline-new-topic-input');
const topicStatus = document.getElementById('outline-topic-status');
@@ -818,6 +1136,11 @@
document.addEventListener('keydown', function(e) {
if (isTypingTarget(e.target)) return;
if ((e.key === 'n' || e.key === 'N') && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
openQuickCapture();
return;
}
if (activeMedia && !e.ctrlKey && !e.altKey && !e.metaKey) {
switch(e.key) {
case ' ':