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:
@@ -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
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
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
@@ -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 = []
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user