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
+168 -33
View File
@@ -11,6 +11,7 @@ import re
import sys import sys
import argparse import argparse
import io import io
import uuid
import zipfile import zipfile
import urllib.request import urllib.request
import urllib.error import urllib.error
@@ -1248,9 +1249,14 @@ def push_note_to_outline(course: Course, lesson_path: str, lesson_title: str,
progress = ProgressTracker.load_progress(course) progress = ProgressTracker.load_progress(course)
entry = progress.get(lesson_path, {}) entry = progress.get(lesson_path, {})
note = entry.get('note', '') notes = ProgressTracker._notes_from_entry(entry)
if not note: if not notes:
return {'success': False, 'error': 'No note to push'} return {'success': False, 'error': 'No note to push'}
note = "\n\n".join(
f"**[{format_timestamp(n.get('timestamp_seconds'))}]** {n.get('text', '')}"
if n.get('timestamp_seconds') is not None else n.get('text', '')
for n in notes
)
if not topic_id: if not topic_id:
if not new_topic_name: if not new_topic_name:
@@ -1427,9 +1433,11 @@ class ProgressTracker:
elif existing.get('duration_seconds'): elif existing.get('duration_seconds'):
entry['duration_seconds'] = existing['duration_seconds'] entry['duration_seconds'] = existing['duration_seconds']
# Same for a note - this call has no opinion on it, so don't let a # Same for notes - this call has no opinion on them, so don't let a
# routine playback-progress save wipe one out. # routine playback-progress save wipe them out.
if existing.get('note'): if existing.get('notes'):
entry['notes'] = existing['notes']
elif existing.get('note'):
entry['note'] = existing['note'] entry['note'] = existing['note']
progress[lesson_path] = entry progress[lesson_path] = entry
@@ -1440,15 +1448,73 @@ class ProgressTracker:
ProgressTracker.save_progress(course, progress) ProgressTracker.save_progress(course, progress)
@staticmethod @staticmethod
def update_lesson_note(course: Course, lesson_path: str, note: str): def _notes_from_entry(entry: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Save (or clear) a lesson's note, without touching its playback progress.""" """
This entry's timestamped notes, transparently upgrading a legacy
single-string `note` field into a one-item list so every caller can
treat notes as a list without caring which shape is on disk.
"""
if entry.get('notes'):
return entry['notes']
legacy = entry.get('note')
if legacy:
return [{
'id': 'legacy',
'timestamp_seconds': None,
'text': legacy,
'created_at': entry.get('last_accessed', '')
}]
return []
@staticmethod
def get_lesson_notes(course: Course, lesson_path: str) -> List[Dict[str, Any]]:
"""A lesson's timestamped notes, newest-shape-or-migrated-legacy."""
progress = ProgressTracker.load_progress(course)
return ProgressTracker._notes_from_entry(progress.get(lesson_path, {}))
@staticmethod
def add_lesson_note(course: Course, lesson_path: str, text: str,
timestamp_seconds: Optional[int]) -> List[Dict[str, Any]]:
"""Append a new timestamped note, migrating a legacy single-note entry to the list shape if needed."""
progress = ProgressTracker.load_progress(course) progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {}) entry = progress.setdefault(lesson_path, {})
if note: notes = ProgressTracker._notes_from_entry(entry)
entry['note'] = note notes.append({
else: 'id': uuid.uuid4().hex[:8],
'text': text,
'timestamp_seconds': timestamp_seconds,
'created_at': datetime.now().isoformat()
})
entry['notes'] = notes
entry.pop('note', None) entry.pop('note', None)
ProgressTracker.save_progress(course, progress) ProgressTracker.save_progress(course, progress)
return notes
@staticmethod
def update_lesson_note_text(course: Course, lesson_path: str, note_id: str, text: str) -> List[Dict[str, Any]]:
"""Edit one timestamped note's text in place."""
progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {})
notes = ProgressTracker._notes_from_entry(entry)
for note in notes:
if note.get('id') == note_id:
note['text'] = text
break
entry['notes'] = notes
entry.pop('note', None)
ProgressTracker.save_progress(course, progress)
return notes
@staticmethod
def delete_lesson_note(course: Course, lesson_path: str, note_id: str) -> List[Dict[str, Any]]:
"""Remove one timestamped note."""
progress = ProgressTracker.load_progress(course)
entry = progress.setdefault(lesson_path, {})
notes = [n for n in ProgressTracker._notes_from_entry(entry) if n.get('id') != note_id]
entry['notes'] = notes
entry.pop('note', None)
ProgressTracker.save_progress(course, progress)
return notes
@staticmethod @staticmethod
def set_lesson_outline_topic(course: Course, lesson_path: str, topic_id: str, topic_name: str): def set_lesson_outline_topic(course: Course, lesson_path: str, topic_id: str, topic_name: str):
@@ -1532,16 +1598,25 @@ def course_has_any_notes(course: Course) -> bool:
"""Whether any lesson in the course has a saved note - used to decide whether to offer a study-guide download.""" """Whether any lesson in the course has a saved note - used to decide whether to offer a study-guide download."""
progress = ProgressTracker.load_progress(course) progress = ProgressTracker.load_progress(course)
return any( return any(
isinstance(entry, dict) and entry.get('note') isinstance(entry, dict) and ProgressTracker._notes_from_entry(entry)
for key, entry in progress.items() if key != 'last_accessed_path' for key, entry in progress.items() if key != 'last_accessed_path'
) )
def format_timestamp(seconds: Optional[float]) -> Optional[str]:
"""Render a note's captured playback position as "MM:SS", or None if it wasn't timestamped."""
if seconds is None:
return None
seconds = int(seconds)
return f"{seconds // 60}:{seconds % 60:02d}"
def get_all_notes() -> List[Dict[str, Any]]: def get_all_notes() -> List[Dict[str, Any]]:
""" """
Every lesson note across the whole library, newest first - a note is Every timestamped note across the whole library, newest first - a note
otherwise only visible from its own lesson page (or Outline, once is otherwise only visible from its own lesson page (or Outline, once
pushed), so this is the one place to see everything you've written. pushed), so this is the one place to see everything you've written. A
lesson with several notes contributes one row per note.
""" """
notes = [] notes = []
for course_dir in iter_all_courses(get_library_root()): for course_dir in iter_all_courses(get_library_root()):
@@ -1554,20 +1629,22 @@ def get_all_notes() -> List[Dict[str, Any]]:
for lesson_path, entry in progress.items(): for lesson_path, entry in progress.items():
if lesson_path == 'last_accessed_path' or not isinstance(entry, dict): if lesson_path == 'last_accessed_path' or not isinstance(entry, dict):
continue continue
note = entry.get('note') for note in ProgressTracker._notes_from_entry(entry):
if not note: created_at = note.get('created_at') or entry.get('last_accessed', '')
continue
notes.append({ notes.append({
'course_path': str(course_dir), 'course_path': str(course_dir),
'course_name': course_dir.name, 'course_name': course_dir.name,
'lesson_path': lesson_path, 'lesson_path': lesson_path,
'lesson_title': lesson_path.rsplit('/', 1)[-1].replace('_', ' '), 'lesson_title': lesson_path.rsplit('/', 1)[-1].replace('_', ' '),
'note': note, 'note_id': note.get('id'),
'last_accessed': entry.get('last_accessed', ''), 'text': note.get('text', ''),
'timestamp_seconds': note.get('timestamp_seconds'),
'timestamp_label': format_timestamp(note.get('timestamp_seconds')),
'created_at': created_at,
'pushed_to_outline': bool(entry.get('outline_document_id')) 'pushed_to_outline': bool(entry.get('outline_document_id'))
}) })
notes.sort(key=lambda n: n['last_accessed'], reverse=True) notes.sort(key=lambda n: n['created_at'], reverse=True)
return notes return notes
@@ -1583,7 +1660,7 @@ def build_study_guide_markdown(course: Course) -> str:
def node_has_notes(node: DirectoryNode) -> bool: def node_has_notes(node: DirectoryNode) -> bool:
for lesson in node.lessons: for lesson in node.lessons:
key = _resolve_lesson_progress_key(course, lesson, progress) key = _resolve_lesson_progress_key(course, lesson, progress)
if key and progress[key].get('note'): if key and ProgressTracker._notes_from_entry(progress[key]):
return True return True
return any(node_has_notes(child) for child in node.children.values()) return any(node_has_notes(child) for child in node.children.values())
@@ -1598,12 +1675,15 @@ def build_study_guide_markdown(course: Course) -> str:
for lesson in node.lessons: for lesson in node.lessons:
key = _resolve_lesson_progress_key(course, lesson, progress) key = _resolve_lesson_progress_key(course, lesson, progress)
note = progress[key].get('note') if key else None lesson_notes = ProgressTracker._notes_from_entry(progress[key]) if key else []
if not note: if not lesson_notes:
continue continue
lines.append(f"{'#' * min(heading_level + 1, 6)} {lesson.title}") lines.append(f"{'#' * min(heading_level + 1, 6)} {lesson.title}")
lines.append("") lines.append("")
lines.append(note) for note in lesson_notes:
ts = format_timestamp(note.get('timestamp_seconds'))
prefix = f"**[{ts}]** " if ts else ""
lines.append(f"- {prefix}{note.get('text', '')}")
lines.append("") lines.append("")
for child in node.children.values(): for child in node.children.values():
@@ -2145,6 +2225,7 @@ def open_recent():
course_path = request.args.get('course_path', '') course_path = request.args.get('course_path', '')
lesson_path = request.args.get('lesson_path', '') lesson_path = request.args.get('lesson_path', '')
seek_seconds = request.args.get('t', '')
if not course_path or not lesson_path or not os.path.exists(course_path): if not course_path or not lesson_path or not os.path.exists(course_path):
return redirect(url_for('index')) return redirect(url_for('index'))
@@ -2156,7 +2237,10 @@ def open_recent():
print(f"Could not load course for recent view: {e}") print(f"Could not load course for recent view: {e}")
return redirect(url_for('index')) return redirect(url_for('index'))
return redirect(url_for('view_lesson', lesson_path=lesson_path)) lesson_url = url_for('view_lesson', lesson_path=lesson_path)
if seek_seconds:
lesson_url = f"{lesson_url}?t={seek_seconds}"
return redirect(lesson_url)
@app.route('/help') @app.route('/help')
@@ -2263,16 +2347,19 @@ def view_lesson(lesson_path: str):
# Record for the cross-course "Recently Viewed" list on the dashboard # Record for the cross-course "Recently Viewed" list on the dashboard
record_recent_view(current_course.name, current_course.path, lesson_path, lesson.title) record_recent_view(current_course.name, current_course.path, lesson_path, lesson.title)
# Read the note directly from the progress file rather than the Lesson # Read notes directly from the progress file rather than the Lesson
# object - apply_progress_to_tree (which populates Lesson fields) isn't # object - apply_progress_to_tree (which populates Lesson fields) isn't
# called on this code path, only on the dashboard's tree render. # called on this code path, only on the dashboard's tree render.
lesson_progress = ProgressTracker.load_progress(current_course).get(lesson_path, {}) lesson_progress = ProgressTracker.load_progress(current_course).get(lesson_path, {})
seek_seconds = request.args.get('t', type=int)
return render_template('lesson_view.html', return render_template('lesson_view.html',
course=current_course, course=current_course,
lesson=lesson, lesson=lesson,
lesson_path=lesson_path, lesson_path=lesson_path,
lesson_note=lesson_progress.get('note', ''), lesson_notes=ProgressTracker._notes_from_entry(lesson_progress),
initial_seek_seconds=seek_seconds,
outline_topic_id=lesson_progress.get('outline_topic_id', ''), outline_topic_id=lesson_progress.get('outline_topic_id', ''),
outline_topic_name=lesson_progress.get('outline_topic_name', ''), outline_topic_name=lesson_progress.get('outline_topic_name', ''),
prev_lesson=prev_lesson, prev_lesson=prev_lesson,
@@ -2362,9 +2449,9 @@ def update_progress():
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@app.route('/api/lesson-note', methods=['POST']) @app.route('/api/lesson-note/add', methods=['POST'])
def update_lesson_note_api(): def add_lesson_note_api():
"""API endpoint to save (or clear) a lesson's note""" """Append a new timestamped note to a lesson."""
global current_course global current_course
if not current_course: if not current_course:
@@ -2372,12 +2459,60 @@ def update_lesson_note_api():
data = request.json or {} data = request.json or {}
lesson_path = data.get('lesson_path') lesson_path = data.get('lesson_path')
note = (data.get('note') or '').strip() text = (data.get('text') or '').strip()
if not lesson_path: if not lesson_path:
return jsonify({'error': 'lesson_path is required'}), 400 return jsonify({'error': 'lesson_path is required'}), 400
if not text:
return jsonify({'error': 'text is required'}), 400
ProgressTracker.update_lesson_note(current_course, lesson_path, note) timestamp_seconds = data.get('timestamp_seconds')
return jsonify({'success': True}) if timestamp_seconds is not None:
try:
timestamp_seconds = int(timestamp_seconds)
except (TypeError, ValueError):
timestamp_seconds = None
notes = ProgressTracker.add_lesson_note(current_course, lesson_path, text, timestamp_seconds)
return jsonify({'success': True, 'notes': notes})
@app.route('/api/lesson-note/update', methods=['POST'])
def update_lesson_note_api():
"""Edit one of a lesson's timestamped notes."""
global current_course
if not current_course:
return jsonify({'error': 'No course loaded'}), 400
data = request.json or {}
lesson_path = data.get('lesson_path')
note_id = data.get('note_id')
text = (data.get('text') or '').strip()
if not lesson_path or not note_id:
return jsonify({'error': 'lesson_path and note_id are required'}), 400
if not text:
return jsonify({'error': 'text is required'}), 400
notes = ProgressTracker.update_lesson_note_text(current_course, lesson_path, note_id, text)
return jsonify({'success': True, 'notes': notes})
@app.route('/api/lesson-note/delete', methods=['POST'])
def delete_lesson_note_api():
"""Remove one of a lesson's timestamped notes."""
global current_course
if not current_course:
return jsonify({'error': 'No course loaded'}), 400
data = request.json or {}
lesson_path = data.get('lesson_path')
note_id = data.get('note_id')
if not lesson_path or not note_id:
return jsonify({'error': 'lesson_path and note_id are required'}), 400
notes = ProgressTracker.delete_lesson_note(current_course, lesson_path, note_id)
return jsonify({'success': True, 'notes': notes})
@app.route('/api/lesson-note/topic', methods=['POST']) @app.route('/api/lesson-note/topic', methods=['POST'])
+378 -55
View File
@@ -185,21 +185,9 @@
.content { .content {
margin: 20px 0; margin: 20px 0;
} }
.notes-section { .outline-section {
margin: 20px 0; 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 { .note-status {
display: block; display: block;
font-size: 0.85em; font-size: 0.85em;
@@ -207,6 +195,144 @@
margin-top: 6px; margin-top: 6px;
min-height: 1.2em; 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 { .outline-topic-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -376,14 +502,10 @@
{% endif %} {% endif %}
</div> </div>
<div class="notes-section"> <div class="outline-section">
<h3>Notes</h3> <h3>Push to Outline</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-topic-row"> <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()"> <select id="outline-topic-select" onchange="handleOutlineTopicChange()">
<option value="">— Don't push —</option> <option value="">— Don't push —</option>
<option value="__new__">+ New topic…</option> <option value="__new__">+ New topic…</option>
@@ -394,6 +516,21 @@
<span id="outline-topic-status" class="note-status"></span> <span id="outline-topic-status" class="note-status"></span>
</div> </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"> <div class="nav-buttons">
{% if prev_lesson %} {% if prev_lesson %}
<a href="/lesson/{{ prev_lesson }}"> <a href="/lesson/{{ prev_lesson }}">
@@ -562,47 +699,228 @@
}).catch(() => {}); }).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 ---- // ---- Outline topic chooser + push-on-leave ----
const LESSON_PATH = {{ lesson_path|tojson }}; const LESSON_PATH = {{ lesson_path|tojson }};
const LESSON_TITLE = {{ lesson.title|tojson }}; const LESSON_TITLE = {{ lesson.title|tojson }};
const INITIAL_TOPIC_ID = {{ outline_topic_id|tojson }}; const INITIAL_TOPIC_ID = {{ outline_topic_id|tojson }};
const INITIAL_TOPIC_NAME = {{ outline_topic_name|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 topicSelect = document.getElementById('outline-topic-select');
const newTopicInput = document.getElementById('outline-new-topic-input'); const newTopicInput = document.getElementById('outline-new-topic-input');
const topicStatus = document.getElementById('outline-topic-status'); const topicStatus = document.getElementById('outline-topic-status');
@@ -818,6 +1136,11 @@
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (isTypingTarget(e.target)) return; 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) { if (activeMedia && !e.ctrlKey && !e.altKey && !e.metaKey) {
switch(e.key) { switch(e.key) {
case ' ': case ' ':
+15 -2
View File
@@ -167,6 +167,16 @@
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; 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 { .note-card-body {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
@@ -213,13 +223,16 @@
<span class="note-lesson-title">{{ note.lesson_title }}</span> <span class="note-lesson-title">{{ note.lesson_title }}</span>
<span class="note-course-name">{{ note.course_name }}</span> <span class="note-course-name">{{ note.course_name }}</span>
</div> </div>
{% if note.timestamp_label %}
<span class="timestamp-badge">{{ note.timestamp_label }}</span>
{% endif %}
{% if note.pushed_to_outline %} {% if note.pushed_to_outline %}
<span class="outline-badge">Outline</span> <span class="outline-badge">Outline</span>
{% endif %} {% endif %}
</div> </div>
<div class="note-card-body">{{ note.note }}</div> <div class="note-card-body">{{ note.text }}</div>
<a class="note-card-link" <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 → Open lesson →
</a> </a>
</div> </div>