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:
+168
-33
@@ -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
@@ -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 ' ':
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user