From bc6725ee4c9ca27af94a642373cbf187dff4ecc8 Mon Sep 17 00:00:00 2001 From: rmsitz Date: Sun, 23 Aug 2026 14:04:14 -0400 Subject: [PATCH] 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 --- offlineu_core.py | 78 +++++++++++++---- templates/course_dashboard.html | 143 +++++++++++++++++++++++++++++++- templates/lesson_view.html | 117 ++++++++++++++++++++++++++ 3 files changed, 318 insertions(+), 20 deletions(-) diff --git a/offlineu_core.py b/offlineu_core.py index a544e81..c19bfcd 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -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]]: diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index 99f0492..bcf88c8 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -618,6 +618,82 @@ font-size: 14px; } + .library-view-toggle { + display: flex; + flex-shrink: 0; + } + .view-toggle-btn { + background: var(--bg-tertiary); + color: var(--text-muted); + border: 1px solid var(--border-color); + padding: 8px 12px; + margin: 0; + font-size: 14px; + cursor: pointer; + } + .view-toggle-btn:first-child { + border-radius: var(--radius) 0 0 var(--radius); + border-right: none; + } + .view-toggle-btn:last-child { + border-radius: 0 var(--radius) var(--radius) 0; + } + .view-toggle-btn.active { + background: var(--accent); + color: white; + border-color: var(--accent); + } + + .library-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 14px; + } + .library-grid-card { + background: var(--bg-tertiary); + border-radius: var(--radius); + padding: 10px; + cursor: pointer; + transition: background 0.2s, transform 0.2s; + display: flex; + flex-direction: column; + gap: 8px; + } + .library-grid-card:hover { + background: var(--bg-tertiary-hover); + transform: translateY(-2px); + } + .grid-card-thumb-wrap { + width: 100%; + aspect-ratio: 1 / 1; + border-radius: var(--radius); + overflow: hidden; + background: var(--bg-primary); + display: flex; + align-items: center; + justify-content: center; + font-size: 2.4em; + } + .grid-card-thumb { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + .grid-card-name { + font-size: 0.9em; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .grid-card-meta { + font-size: 0.75em; + color: var(--text-muted); + } + .transcript-search-toggle { display: flex; align-items: center; @@ -840,13 +916,14 @@
{% macro render_tree_node(node, depth=0) %} + {% set stats = section_stats(node) %}
šŸ“ {{ node.name }} - {{ (node.children|length + node.lessons|length) }} items + {% if stats.total_lessons %}{{ stats.completed_lessons }}/{{ stats.total_lessons }} watched{% else %}Empty{% endif %}
{% if node.children or node.lessons %} @@ -1067,6 +1144,12 @@ +
+ + +
+ {% if section_lessons and section_lessons|length > 1 %} + + {% endif %} +

Push to Outline

@@ -904,6 +1000,27 @@ if (notesPanelToggleArrow) notesPanelToggleArrow.textContent = 'ā–“'; } + // "Lessons in this section" defaults open (unlike notes, this + // is primary navigation) - only collapse it if the user + // explicitly did so on a previous visit. + function toggleSectionLessons() { + const listWrap = document.getElementById('section-lessons-list-wrap'); + const arrow = document.getElementById('section-lessons-arrow'); + if (!listWrap) return; + const expanded = listWrap.classList.toggle('expanded'); + if (arrow) arrow.textContent = expanded ? 'ā–¾' : 'ā–ø'; + localStorage.setItem('offlineu-section-lessons-expanded', expanded ? '1' : '0'); + } + + (function() { + const listWrap = document.getElementById('section-lessons-list-wrap'); + const arrow = document.getElementById('section-lessons-arrow'); + if (listWrap && localStorage.getItem('offlineu-section-lessons-expanded') === '0') { + listWrap.classList.remove('expanded'); + if (arrow) arrow.textContent = 'ā–ø'; + } + })(); + function openQuickCapture() { if (activeMedia && !activeMedia.paused) activeMedia.pause(); updateQuickCaptureBadge();