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:
+259
-122
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user