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:
+378
-55
@@ -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 ' ':
|
||||
|
||||
@@ -167,6 +167,16 @@
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.timestamp-badge {
|
||||
font-size: 0.75em;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-muted);
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-color);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.note-card-body {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -213,13 +223,16 @@
|
||||
<span class="note-lesson-title">{{ note.lesson_title }}</span>
|
||||
<span class="note-course-name">{{ note.course_name }}</span>
|
||||
</div>
|
||||
{% if note.timestamp_label %}
|
||||
<span class="timestamp-badge">{{ note.timestamp_label }}</span>
|
||||
{% endif %}
|
||||
{% if note.pushed_to_outline %}
|
||||
<span class="outline-badge">Outline</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="note-card-body">{{ note.note }}</div>
|
||||
<div class="note-card-body">{{ note.text }}</div>
|
||||
<a class="note-card-link"
|
||||
href="/recent/open?course_path={{ note.course_path | urlencode }}&lesson_path={{ note.lesson_path | urlencode }}">
|
||||
href="/recent/open?course_path={{ note.course_path | urlencode }}&lesson_path={{ note.lesson_path | urlencode }}{% if note.timestamp_seconds is not none %}&t={{ note.timestamp_seconds }}{% endif %}">
|
||||
Open lesson →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user