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
+259 -122
View File
@@ -1,4 +1,5 @@
{% import '_icons.html' as icons %}
{% import '_course_tree.html' as tree %}
<!DOCTYPE html>
<html lang="en">
<head>
@@ -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 @@
</div>
<div class="page-content">
<div class="container">
<div class="lesson-layout">
<div class="container lesson-main">
<h1 class="lesson-title">{{ lesson.title }}</h1>
<div class="lesson-path">
@@ -590,43 +748,6 @@
{% endif %}
</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">
<h3>Push to Outline</h3>
<div class="outline-topic-row">
@@ -673,6 +794,15 @@
<button disabled>Next →</button>
{% endif %}
</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>
// Client-rendered mirror of a few templates/_icons.html entries -
@@ -1055,33 +1185,41 @@
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();
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();
// Seek once metadata is available (activeMedia.duration is NaN
@@ -1442,7 +1580,6 @@
}
</script>
</div>
</div>
<footer class="app-footer">
<div class="container-inner">