diff --git a/CHANGELOG.md b/CHANGELOG.md index 03208f7..eb34210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +2026-08-26 13:12 UTC — Add course outline sidebar to the lesson page 2026-08-25 12:25 UTC — Remove unreliable duplicate-lesson-file matching 2026-08-25 00:33 UTC — Widen thumbnail sampling window past platform bumpers 2026-08-25 00:26 UTC — Fix race condition in thumbnail candidate generation diff --git a/README.md b/README.md index 142fcbc..59d3b55 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,14 @@ silently stay blank instead of erroring. **Playback & progress** - Video/audio player with resize, playback-speed presets, and resume-from- last-position +- Course outline sidebar on the lesson page: every section and lesson in + the course (videos and documents alike - a standalone PDF/doc is its own + entry, same as a video), one click to jump anywhere without backing out + to the course page first. The current lesson's section opens + automatically and scrolls into view; sticky and independently + scrollable on desktop, stacks below the player on narrow viewports. + Shares its rendering with the loaded-course dashboard view (`templates/ + _course_tree.html`), so the two always look and behave the same. - Auto-play next lesson when one ends, with a cancelable few-second countdown - on by default, toggle it off in Settings → Video Player - Keyboard shortcuts on the lesson page: Space (play/pause), ←/→ (seek diff --git a/VERSION b/VERSION index 8658d3e..159c086 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2026-08-25 12:25 UTC — remove unreliable duplicate-lesson-file matching +2026-08-26 13:12 UTC — add course outline sidebar to the lesson page diff --git a/offlineu_core.py b/offlineu_core.py index c0bb4ae..48fc718 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -4554,8 +4554,16 @@ def view_lesson(lesson_path: str): if not current_course: return redirect(url_for('index')) - # 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) + # Populates the whole tree's Lesson objects (completed/progress_seconds/ + # duration_seconds) from disk - needed for the course-outline sidebar, + # which (like the dashboard's tree) reads those fields directly rather + # than loading progress per-row. + ProgressTracker.apply_progress_to_tree(current_course) + + # Find the lesson in the tree (the section/DirectoryNode it belongs to + # isn't needed here anymore - the sidebar shows the whole course, not + # just this lesson's own section) + lesson, _ = find_lesson_in_tree(current_course.root_node, lesson_path) if not lesson: return redirect(url_for('index')) @@ -4603,7 +4611,6 @@ 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) @@ -4656,36 +4663,6 @@ def find_lesson_in_tree(node: DirectoryNode, target_path: str) -> Tuple[Optional 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]]: """Get all lessons from the tree with their paths""" lessons = [] diff --git a/templates/_course_tree.html b/templates/_course_tree.html new file mode 100644 index 0000000..9f5120e --- /dev/null +++ b/templates/_course_tree.html @@ -0,0 +1,78 @@ +{% import '_icons.html' as icons %} +{# + Shared recursive course-outline renderer - one section/lesson tree + markup used by both the loaded-course dashboard view and the lesson + page's sidebar, so the two stay visually and behaviorally in sync + instead of drifting apart as separate copies. Needs `toggleTree()` + (JS) and the .tree-*/.lesson-*/.status-icon/.watched-badge CSS to be + defined by whichever page imports this - see course_dashboard.html + for the canonical versions. + + `current_lesson` (a Lesson object, not a path string) is optional - + when given, the matching row gets a `current` class instead of being + a plain click target, and stays unclickable since you're already + there. Path-string comparison isn't used for this because the lesson + page's incoming URL can be in several different formats (see + find_lesson_in_tree) - comparing lesson.path (the raw absolute + filesystem path, always unique and unambiguous) sidesteps all of that. +#} +{% macro render_tree_node(node, course_path, current_lesson=none, depth=0) %} +{% set stats = section_stats(node) %} +
+
+
+ {{ icons.icon('folder', 16) }} + {{ node.name }} + + {% if stats.total_lessons %}{{ stats.completed_lessons }}/{{ stats.total_lessons }} watched{% else %}Empty{% endif %} + +
+ {% if node.children or node.lessons %} + + {% endif %} +
+ + {% if node.children or node.lessons %} +
+ {% for child_name, child_node in node.children.items() %} + {{ render_tree_node(child_node, course_path, current_lesson, depth + 1) }} + {% endfor %} + + {% for lesson in node.lessons %} + {% set lesson_relative_path = lesson.path|replace('\\', '/')|replace(course_path|replace('\\', '/'), '')|replace('//', '/')|replace('/', '', 1) %} + {% set percent_watched = 100 if lesson.completed else (((100 * lesson.progress_seconds / lesson.duration_seconds)|round|int) if (lesson.duration_seconds and lesson.progress_seconds) else 0) %} + {% set is_current = current_lesson and lesson.path == current_lesson.path %} +
+
+ + {% if lesson.lesson_type == 'video' %}{{ icons.icon('video', 16) }} + {% elif lesson.lesson_type == 'audio' %}{{ icons.icon('music', 16) }} + {% elif lesson.lesson_type == 'quiz' %}{{ icons.icon('clipboard', 16) }} + {% elif lesson.lesson_type == 'mixed' %}{{ icons.icon('package', 16) }} + {% else %}{{ icons.icon('file-text', 16) }}{% endif %} + + {{ lesson.title }} +
+
+ {{ lesson.lesson_type|title }} + {% if lesson.duration_seconds and lesson.duration_seconds >= 60 %} + {{ lesson.duration_seconds|format_duration }} + {% endif %} + {% if lesson.completed %} + {{ icons.icon('check', 14) }} + {% elif percent_watched %} + {{ percent_watched }}% watched + {% else %} + {{ icons.icon('circle', 14) }} + {% endif %} +
+ {% if percent_watched %} +
+ {% endif %} +
+ {% endfor %} +
+ {% endif %} +
+{% endmacro %} diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index 57feeb3..b44909c 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -1,4 +1,5 @@ {% import '_icons.html' as icons %} +{% import '_course_tree.html' as tree %} @@ -1089,67 +1090,7 @@
- {% macro render_tree_node(node, depth=0) %} - {% set stats = section_stats(node) %} -
-
-
- {{ icons.icon('folder', 16) }} - {{ node.name }} - - {% if stats.total_lessons %}{{ stats.completed_lessons }}/{{ stats.total_lessons }} watched{% else %}Empty{% endif %} - -
- {% if node.children or node.lessons %} - - {% endif %} -
- - {% if node.children or node.lessons %} -
- {% for child_name, child_node in node.children.items() %} - {{ render_tree_node(child_node, depth + 1) }} - {% endfor %} - - {% for lesson in node.lessons %} - {% set lesson_relative_path = lesson.path|replace('\\', '/')|replace(course.path|replace('\\', '/'), '')|replace('//', '/')|replace('/', '', 1) %} - {% set percent_watched = 100 if lesson.completed else (((100 * lesson.progress_seconds / lesson.duration_seconds)|round|int) if (lesson.duration_seconds and lesson.progress_seconds) else 0) %} -
-
- - {% if lesson.lesson_type == 'video' %}{{ icons.icon('video', 16) }} - {% elif lesson.lesson_type == 'audio' %}{{ icons.icon('music', 16) }} - {% elif lesson.lesson_type == 'quiz' %}{{ icons.icon('clipboard', 16) }} - {% elif lesson.lesson_type == 'mixed' %}{{ icons.icon('package', 16) }} - {% else %}{{ icons.icon('file-text', 16) }}{% endif %} - - {{ lesson.title }} -
-
- {{ lesson.lesson_type|title }} - {% if lesson.duration_seconds and lesson.duration_seconds >= 60 %} - {{ lesson.duration_seconds|format_duration }} - {% endif %} - {% if lesson.completed %} - {{ icons.icon('check', 14) }} - {% elif percent_watched %} - {{ percent_watched }}% watched - {% else %} - {{ icons.icon('circle', 14) }} - {% endif %} -
- {% if percent_watched %} -
- {% endif %} -
- {% endfor %} -
- {% endif %} -
- {% endmacro %} - - {{ render_tree_node(course.root_node) }} + {{ tree.render_tree_node(course.root_node, course.path) }}
{% else %} diff --git a/templates/lesson_view.html b/templates/lesson_view.html index f05b5eb..03fd7c2 100644 --- a/templates/lesson_view.html +++ b/templates/lesson_view.html @@ -1,4 +1,5 @@ {% import '_icons.html' as icons %} +{% import '_course_tree.html' as tree %} @@ -134,14 +135,229 @@ vertical-align: -3px; flex-shrink: 0; } - .container { - max-width: var(--container-max-width); + .container { + max-width: var(--container-max-width); width: 95%; - margin: 20px auto; + margin: 20px auto; background: var(--bg-secondary); padding: 20px; border-radius: var(--radius); } + .lesson-layout { + max-width: var(--container-max-width); + width: 95%; + margin: 20px auto; + display: flex; + align-items: flex-start; + gap: 20px; + } + .lesson-layout .container { + width: auto; + max-width: none; + margin: 0; + } + .lesson-main { + flex: 1; + min-width: 0; + } + .lesson-sidebar { + flex: 0 0 300px; + position: sticky; + top: 20px; + max-height: calc(100vh - 40px); + overflow-y: auto; + } + .lesson-sidebar h3 { + margin-top: 0; + } + @media (max-width: 900px) { + .lesson-layout { + flex-direction: column; + } + .lesson-sidebar { + flex: 1 1 auto; + width: 100%; + position: static; + max-height: 420px; + } + } + /* Course-outline sidebar tree - scoped under .lesson-sidebar so + these don't collide with this page's own .lesson-title (the H1) + and similarly-named rules. Mirrors course_dashboard.html's tree + styling (shared macro, see _course_tree.html) at a denser size + for the narrower column. */ + .lesson-sidebar .tree-container { + margin-top: 10px; + } + .lesson-sidebar .tree-item { + margin-bottom: 5px; + } + .lesson-sidebar .tree-header { + background: var(--bg-tertiary); + padding: 10px 12px; + border-radius: var(--radius); + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + transition: background 0.3s; + border-left: 3px solid #666; + } + .lesson-sidebar .tree-header:hover { + background: var(--bg-tertiary-hover); + } + .lesson-sidebar .tree-header.directory { + border-left-color: var(--accent); + } + .lesson-sidebar .tree-title { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + } + .lesson-sidebar .tree-icon { + font-size: 1.1em; + width: 18px; + text-align: center; + flex-shrink: 0; + } + .lesson-sidebar .tree-name { + font-weight: 500; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9em; + } + .lesson-sidebar .tree-stats { + font-size: 0.75em; + color: var(--text-muted); + margin-left: 8px; + flex-shrink: 0; + white-space: nowrap; + } + .lesson-sidebar .tree-toggle { + background: none; + border: none; + color: var(--text-primary); + font-size: 1.1em; + cursor: pointer; + padding: 4px; + border-radius: 3px; + transition: background 0.3s; + } + .lesson-sidebar .tree-toggle:hover { + background: #555; + } + .lesson-sidebar .tree-content { + margin-left: 16px; + margin-top: 5px; + display: none; + } + .lesson-sidebar .tree-content.expanded { + display: block; + } + .lesson-sidebar .lesson-item { + background: var(--bg-tertiary); + padding: 8px 12px; + border-radius: var(--radius); + margin-bottom: 5px; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + transition: all 0.2s; + cursor: pointer; + border-left: 3px solid #666; + position: relative; + overflow: hidden; + gap: 4px; + } + .lesson-sidebar .lesson-item:hover { + border-left-color: var(--accent); + background: var(--bg-tertiary-hover); + } + .lesson-sidebar .lesson-item.completed { + border-left-color: var(--success); + } + .lesson-sidebar .lesson-item.in-progress { + border-left-color: var(--accent); + } + .lesson-sidebar .lesson-item.current { + border-left-color: var(--accent); + background: var(--bg-tertiary-hover); + cursor: default; + font-weight: 600; + } + .lesson-sidebar .lesson-progress-track { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(255, 255, 255, 0.08); + } + .lesson-sidebar .lesson-progress-fill { + height: 100%; + background: var(--accent); + } + .lesson-sidebar .lesson-item.completed .lesson-progress-fill { + background: var(--success); + } + .lesson-sidebar .watched-badge { + font-size: 0.7em; + background: var(--bg-primary); + color: var(--accent); + padding: 1px 6px; + border-radius: 3px; + border: 1px solid var(--accent); + white-space: nowrap; + } + .lesson-sidebar .lesson-item .lesson-title { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + } + .lesson-sidebar .lesson-icon { + font-size: 1em; + flex-shrink: 0; + } + .lesson-sidebar .lesson-name { + font-size: 0.85em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .lesson-sidebar .lesson-meta { + font-size: 0.75em; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + .lesson-sidebar .lesson-type { + display: none; + } + .lesson-sidebar .lesson-duration { + font-size: 0.75em; + color: var(--text-muted); + white-space: nowrap; + } + .lesson-sidebar .status-icon { + font-size: 1.1em; + font-weight: bold; + } + .lesson-sidebar .status-icon.completed { + color: var(--success); + } + .lesson-sidebar .status-icon.pending { + color: var(--text-muted); + } video, audio { width: 100%; max-width: 100%; @@ -207,65 +423,6 @@ .outline-section { margin: 20px 0; } - .section-lessons { - margin: 20px 0; - background: var(--bg-tertiary); - border-radius: var(--radius); - } - .section-lessons-header { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 14px; - cursor: pointer; - font-weight: 600; - } - .section-lessons-count { - color: var(--text-muted); - font-weight: 400; - font-size: 0.85em; - } - .section-lessons-list { - max-height: 0; - overflow: hidden; - transition: max-height 0.25s ease; - } - .section-lessons-list.expanded { - max-height: 2000px; - } - .section-lesson-row { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 14px; - border-top: 1px solid var(--border-color); - text-decoration: none; - color: var(--text-primary); - transition: background 0.2s; - } - a.section-lesson-row:hover { - background: var(--bg-tertiary-hover); - } - .section-lesson-row.current { - background: var(--bg-tertiary-hover); - font-weight: 600; - cursor: default; - } - .section-lesson-title { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .section-lesson-status { - font-size: 0.8em; - color: var(--text-muted); - flex-shrink: 0; - } - .section-lesson-status.completed { - color: var(--success); - } .note-status { display: block; font-size: 0.85em; @@ -525,7 +682,8 @@
-
+
+

{{ lesson.title }}

@@ -590,43 +748,6 @@ {% endif %}
- {% if section_lessons and section_lessons|length > 1 %} - - {% endif %} -

Push to Outline

@@ -673,6 +794,15 @@ {% endif %}
+
+ +
+

Course Outline

+
+ {{ tree.render_tree_node(course.root_node, course.path, lesson) }} +
+
+
-