Add course outline sidebar to the lesson page

Shows every section and lesson in the course - videos and standalone
documents alike - so jumping to a different section no longer means
backing out to the course page first. Current lesson is highlighted,
its section auto-expands and scrolls into view; sticky on desktop,
stacks below the player on narrow viewports. Replaces the old
"Lessons in this section" list, which only showed the current
section.

The tree-rendering markup (shared with the loaded-course dashboard
view) is now a single macro in templates/_course_tree.html instead of
being duplicated, so both views stay in sync going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 09:13:09 -04:00
co-authored by Claude Sonnet 5
parent 72c7f67c3d
commit 4f25d4201b
7 changed files with 359 additions and 217 deletions
+1
View File
@@ -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 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:33 UTC — Widen thumbnail sampling window past platform bumpers
2026-08-25 00:26 UTC — Fix race condition in thumbnail candidate generation 2026-08-25 00:26 UTC — Fix race condition in thumbnail candidate generation
+8
View File
@@ -225,6 +225,14 @@ silently stay blank instead of erroring.
**Playback & progress** **Playback & progress**
- Video/audio player with resize, playback-speed presets, and resume-from- - Video/audio player with resize, playback-speed presets, and resume-from-
last-position 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 - Auto-play next lesson when one ends, with a cancelable few-second
countdown - on by default, toggle it off in Settings → Video Player countdown - on by default, toggle it off in Settings → Video Player
- Keyboard shortcuts on the lesson page: Space (play/pause), ←/→ (seek - Keyboard shortcuts on the lesson page: Space (play/pause), ←/→ (seek
+1 -1
View File
@@ -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
+10 -33
View File
@@ -4554,8 +4554,16 @@ def view_lesson(lesson_path: str):
if not current_course: if not current_course:
return redirect(url_for('index')) return redirect(url_for('index'))
# Find the lesson in the tree, and the section (DirectoryNode) it belongs to # Populates the whole tree's Lesson objects (completed/progress_seconds/
lesson, section_node = find_lesson_in_tree(current_course.root_node, lesson_path) # 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: if not lesson:
return redirect(url_for('index')) return redirect(url_for('index'))
@@ -4603,7 +4611,6 @@ def view_lesson(lesson_path: str):
initial_seek_seconds=seek_seconds, 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', ''),
section_lessons=get_section_lessons(current_course, section_node, lesson),
prev_lesson=prev_lesson, prev_lesson=prev_lesson,
next_lesson=next_lesson) next_lesson=next_lesson)
@@ -4656,36 +4663,6 @@ def find_lesson_in_tree(node: DirectoryNode, target_path: str) -> Tuple[Optional
return None, None 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]]: def get_all_lessons(node: DirectoryNode) -> List[Tuple[str, Lesson]]:
"""Get all lessons from the tree with their paths""" """Get all lessons from the tree with their paths"""
lessons = [] lessons = []
+78
View File
@@ -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) %}
<div class="tree-item">
<div class="tree-header directory" onclick="toggleTree(this)">
<div class="tree-title">
<span class="tree-icon">{{ icons.icon('folder', 16) }}</span>
<span class="tree-name">{{ node.name }}</span>
<span class="tree-stats">
{% if stats.total_lessons %}{{ stats.completed_lessons }}/{{ stats.total_lessons }} watched{% else %}Empty{% endif %}
</span>
</div>
{% if node.children or node.lessons %}
<button class="tree-toggle"></button>
{% endif %}
</div>
{% if node.children or node.lessons %}
<div class="tree-content">
{% 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 %}
<div class="lesson-item {% if is_current %}current{% elif lesson.completed %}completed{% elif percent_watched %}in-progress{% endif %}"
{% if not is_current %}onclick="window.location.href='/lesson/{{ lesson_relative_path }}/{{ lesson.title|replace(' ', '_') }}'"{% endif %}>
<div class="lesson-title">
<span class="lesson-icon">
{% 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 %}
</span>
<span class="lesson-name">{{ lesson.title }}</span>
</div>
<div class="lesson-meta">
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
{% if lesson.duration_seconds and lesson.duration_seconds >= 60 %}
<span class="lesson-duration">{{ lesson.duration_seconds|format_duration }}</span>
{% endif %}
{% if lesson.completed %}
<span class="status-icon completed">{{ icons.icon('check', 14) }}</span>
{% elif percent_watched %}
<span class="watched-badge">{{ percent_watched }}% watched</span>
{% else %}
<span class="status-icon pending">{{ icons.icon('circle', 14) }}</span>
{% endif %}
</div>
{% if percent_watched %}
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ percent_watched }}%;"></div></div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
+2 -61
View File
@@ -1,4 +1,5 @@
{% import '_icons.html' as icons %} {% import '_icons.html' as icons %}
{% import '_course_tree.html' as tree %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -1089,67 +1090,7 @@
</div> </div>
<div class="tree-container"> <div class="tree-container">
{% macro render_tree_node(node, depth=0) %} {{ tree.render_tree_node(course.root_node, course.path) }}
{% set stats = section_stats(node) %}
<div class="tree-item">
<div class="tree-header directory" onclick="toggleTree(this)">
<div class="tree-title">
<span class="tree-icon">{{ icons.icon('folder', 16) }}</span>
<span class="tree-name">{{ node.name }}</span>
<span class="tree-stats">
{% if stats.total_lessons %}{{ stats.completed_lessons }}/{{ stats.total_lessons }} watched{% else %}Empty{% endif %}
</span>
</div>
{% if node.children or node.lessons %}
<button class="tree-toggle"></button>
{% endif %}
</div>
{% if node.children or node.lessons %}
<div class="tree-content">
{% 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) %}
<div class="lesson-item {% if lesson.completed %}completed{% elif percent_watched %}in-progress{% endif %}"
onclick="window.location.href='/lesson/{{ lesson_relative_path }}/{{ lesson.title|replace(' ', '_') }}'">
<div class="lesson-title">
<span class="lesson-icon">
{% 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 %}
</span>
<span class="lesson-name">{{ lesson.title }}</span>
</div>
<div class="lesson-meta">
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
{% if lesson.duration_seconds and lesson.duration_seconds >= 60 %}
<span class="lesson-duration">{{ lesson.duration_seconds|format_duration }}</span>
{% endif %}
{% if lesson.completed %}
<span class="status-icon completed">{{ icons.icon('check', 14) }}</span>
{% elif percent_watched %}
<span class="watched-badge">{{ percent_watched }}% watched</span>
{% else %}
<span class="status-icon pending">{{ icons.icon('circle', 14) }}</span>
{% endif %}
</div>
{% if percent_watched %}
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ percent_watched }}%;"></div></div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
{{ render_tree_node(course.root_node) }}
</div> </div>
</div> </div>
{% else %} {% else %}
+259 -122
View File
@@ -1,4 +1,5 @@
{% import '_icons.html' as icons %} {% import '_icons.html' as icons %}
{% import '_course_tree.html' as tree %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -134,14 +135,229 @@
vertical-align: -3px; vertical-align: -3px;
flex-shrink: 0; flex-shrink: 0;
} }
.container { .container {
max-width: var(--container-max-width); max-width: var(--container-max-width);
width: 95%; width: 95%;
margin: 20px auto; margin: 20px auto;
background: var(--bg-secondary); background: var(--bg-secondary);
padding: 20px; padding: 20px;
border-radius: var(--radius); 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 { video, audio {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
@@ -207,65 +423,6 @@
.outline-section { .outline-section {
margin: 20px 0; 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 { .note-status {
display: block; display: block;
font-size: 0.85em; font-size: 0.85em;
@@ -525,7 +682,8 @@
</div> </div>
<div class="page-content"> <div class="page-content">
<div class="container"> <div class="lesson-layout">
<div class="container lesson-main">
<h1 class="lesson-title">{{ lesson.title }}</h1> <h1 class="lesson-title">{{ lesson.title }}</h1>
<div class="lesson-path"> <div class="lesson-path">
@@ -590,43 +748,6 @@
{% endif %} {% endif %}
</div> </div>
{% if section_lessons and section_lessons|length > 1 %}
<div class="section-lessons">
<div class="section-lessons-header" onclick="toggleSectionLessons()">
<span id="section-lessons-arrow"></span>
<span>Lessons in this section</span>
<span class="section-lessons-count">({{ section_lessons|length }})</span>
</div>
<div class="section-lessons-list expanded" id="section-lessons-list-wrap">
{% for item in section_lessons %}
{% if item.is_current %}
<span class="section-lesson-row current">
{% else %}
<a class="section-lesson-row" href="/lesson/{{ item.url }}">
{% endif %}
<span class="section-lesson-icon">
{% if item.lesson_type == 'video' %}{{ icons.icon('video', 16) }}
{% elif item.lesson_type == 'audio' %}{{ icons.icon('music', 16) }}
{% elif item.lesson_type == 'quiz' %}{{ icons.icon('clipboard', 16) }}
{% elif item.lesson_type == 'mixed' %}{{ icons.icon('package', 16) }}
{% else %}{{ icons.icon('file-text', 16) }}{% endif %}
</span>
<span class="section-lesson-title">{{ item.title }}</span>
{% if item.completed %}
<span class="section-lesson-status completed">{{ icons.icon('check', 14) }}</span>
{% elif item.percent_watched %}
<span class="section-lesson-status">{{ item.percent_watched }}%</span>
{% endif %}
{% if item.is_current %}
</span>
{% else %}
</a>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
<div class="outline-section"> <div class="outline-section">
<h3>Push to Outline</h3> <h3>Push to Outline</h3>
<div class="outline-topic-row"> <div class="outline-topic-row">
@@ -673,6 +794,15 @@
<button disabled>Next →</button> <button disabled>Next →</button>
{% endif %} {% endif %}
</div> </div>
</div>
<div class="container lesson-sidebar">
<h3>Course Outline</h3>
<div class="tree-container">
{{ tree.render_tree_node(course.root_node, course.path, lesson) }}
</div>
</div>
</div>
<script> <script>
// Client-rendered mirror of a few templates/_icons.html entries - // Client-rendered mirror of a few templates/_icons.html entries -
@@ -1055,33 +1185,41 @@
if (notesPanelToggleArrow) notesPanelToggleArrow.textContent = '▴'; 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() { function openQuickCapture() {
if (activeMedia && !activeMedia.paused) activeMedia.pause(); if (activeMedia && !activeMedia.paused) activeMedia.pause();
updateQuickCaptureBadge(); updateQuickCaptureBadge();
if (quickNoteInput) quickNoteInput.focus(); if (quickNoteInput) quickNoteInput.focus();
} }
// ---- Course outline sidebar (shared tree markup/macro with the
// dashboard's loaded-course view - see _course_tree.html) ----
function toggleTree(element) {
const content = element.nextElementSibling;
const toggleBtn = element.querySelector('.tree-toggle');
if (content && content.classList.contains('tree-content')) {
const isExpanded = content.classList.toggle('expanded');
if (toggleBtn) toggleBtn.textContent = isExpanded ? '▼' : '▶';
}
}
// Sections default collapsed (same as the dashboard) - expand
// just the ancestor chain down to the current lesson so it's
// visible without the user having to hunt for it, and scroll
// it into view within the sidebar's own scroll area.
(function revealCurrentLessonInSidebar() {
const current = document.querySelector('.lesson-sidebar .lesson-item.current');
if (!current) return;
let content = current.closest('.tree-content');
while (content) {
content.classList.add('expanded');
const header = content.previousElementSibling;
const toggleBtn = header ? header.querySelector('.tree-toggle') : null;
if (toggleBtn) toggleBtn.textContent = '▼';
content = content.parentElement ? content.parentElement.closest('.tree-content') : null;
}
current.scrollIntoView({ block: 'center' });
})();
renderNotes(); renderNotes();
// Seek once metadata is available (activeMedia.duration is NaN // Seek once metadata is available (activeMedia.duration is NaN
@@ -1442,7 +1580,6 @@
} }
</script> </script>
</div> </div>
</div>
<footer class="app-footer"> <footer class="app-footer">
<div class="container-inner"> <div class="container-inner">