Add section lesson list, section progress, and library grid view

Three comparison-driven upgrades against typical course/media platforms:

- Lesson page gets a collapsible "Lessons in this section" list so you
  can jump between siblings without leaving the page, instead of only
  global Prev/Next buttons. find_lesson_in_tree() now also returns the
  DirectoryNode that owns the lesson (its .lessons list is the sibling
  set) since DirectoryNode has no parent pointer; a new
  get_section_lessons() resolves each sibling's progress the same way
  view_lesson() already does for the current lesson.

- Course tree section headers show "X/Y watched" instead of a flat item
  count, via DynamicCourseParser._calculate_completion_stats (already
  computed recursive completion for any node, just never called
  per-section) exposed as a Jinja global.

- Library browser gets a list/grid toggle for browsing large category
  folders by poster-style thumbnail instead of a 32px-icon list.
  Client-side only, persisted via localStorage, defaults to the
  existing list view; both folder browsing and search results already
  funnel through the same render function so grid mode covers both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 14:04:14 -04:00
co-authored by Claude Sonnet 5
parent 3fedf849d3
commit bc6725ee4c
3 changed files with 318 additions and 20 deletions
+61 -17
View File
@@ -496,6 +496,13 @@ class DynamicCourseParser:
}
# Exposed to templates so a section header can show "X/Y watched" for any
# DirectoryNode without a separate per-node data-loading pass - the course
# tree is walked once per render regardless, and this method already
# recurses on whatever node it's given.
app.jinja_env.globals['section_stats'] = DynamicCourseParser._calculate_completion_stats
def _has_direct_media(directory: Path) -> bool:
"""Check whether a directory contains media files directly (not recursively)"""
try:
@@ -2331,9 +2338,9 @@ def view_lesson(lesson_path: str):
if not current_course:
return redirect(url_for('index'))
# Find the lesson in the tree
lesson = find_lesson_in_tree(current_course.root_node, lesson_path)
# Find the lesson in the tree, and the section (DirectoryNode) it belongs to
lesson, section_node = find_lesson_in_tree(current_course.root_node, lesson_path)
if not lesson:
return redirect(url_for('index'))
@@ -2380,6 +2387,7 @@ def view_lesson(lesson_path: str):
initial_seek_seconds=seek_seconds,
outline_topic_id=lesson_progress.get('outline_topic_id', ''),
outline_topic_name=lesson_progress.get('outline_topic_name', ''),
section_lessons=get_section_lessons(current_course, section_node, lesson),
prev_lesson=prev_lesson,
next_lesson=next_lesson)
@@ -2397,33 +2405,69 @@ def get_lesson_url(lesson: Lesson, course_path: str) -> str:
return lesson_url
def find_lesson_in_tree(node: DirectoryNode, target_path: str) -> Optional[Lesson]:
"""Find a lesson in the tree by path"""
def find_lesson_in_tree(node: DirectoryNode, target_path: str) -> Tuple[Optional[Lesson], Optional[DirectoryNode]]:
"""
Find a lesson in the tree by path, along with the DirectoryNode that
directly owns it - DirectoryNode has no parent pointer, so this is the
only way to get "the section this lesson belongs to" (its `.lessons`
list is exactly that section's sibling set, used for the lesson page's
"up next in this section" list).
"""
# Check lessons in current node
for lesson in node.lessons:
lesson_url = get_lesson_url(lesson, current_course.path)
# Check multiple possible path formats
lesson_file_path = os.path.relpath(lesson.path, current_course.path)
lesson_file_path = lesson_file_path.replace('\\', '/')
if lesson_file_path.startswith('/'):
lesson_file_path = lesson_file_path[1:]
# Also check with lesson title appended
lesson_path_with_title = f"{lesson_file_path}/{lesson.title.replace(' ', '_')}"
if (lesson_url == target_path or
lesson_file_path == target_path or
if (lesson_url == target_path or
lesson_file_path == target_path or
lesson_path_with_title == target_path):
return lesson
return lesson, node
# Recursively search children
for child in node.children.values():
result = find_lesson_in_tree(child, target_path)
if result:
return result
return None
result_lesson, result_node = find_lesson_in_tree(child, target_path)
if result_lesson:
return result_lesson, result_node
return None, None
def get_section_lessons(course: Course, section_node: DirectoryNode, current_lesson: Lesson) -> List[Dict[str, Any]]:
"""
The sibling lessons in current_lesson's own section, for the lesson
page's "up next in this section" list. view_lesson() isn't on the
apply_progress_to_tree() code path, so - like that route already does
for the current lesson's own notes/progress - this reads progress
directly from the progress file rather than relying on Lesson fields.
"""
progress = ProgressTracker.load_progress(course)
siblings = []
for sibling in section_node.lessons:
key = _resolve_lesson_progress_key(course, sibling, progress)
entry = progress.get(key, {}) if key else {}
completed = entry.get('completed', False)
progress_seconds = entry.get('progress_seconds', 0)
duration_seconds = entry.get('duration_seconds', 0)
percent_watched = 100 if completed else (
round(100 * progress_seconds / duration_seconds) if duration_seconds and progress_seconds else 0
)
siblings.append({
'title': sibling.title,
'url': get_lesson_url(sibling, course.path),
'lesson_type': sibling.lesson_type,
'completed': completed,
'percent_watched': percent_watched,
'is_current': sibling is current_lesson,
})
return siblings
def get_all_lessons(node: DirectoryNode) -> List[Tuple[str, Lesson]]: