Add section lesson list, section progress, and library grid view

Three comparison-driven upgrades against typical course/media platforms:

- Lesson page gets a collapsible "Lessons in this section" list so you
  can jump between siblings without leaving the page, instead of only
  global Prev/Next buttons. find_lesson_in_tree() now also returns the
  DirectoryNode that owns the lesson (its .lessons list is the sibling
  set) since DirectoryNode has no parent pointer; a new
  get_section_lessons() resolves each sibling's progress the same way
  view_lesson() already does for the current lesson.

- Course tree section headers show "X/Y watched" instead of a flat item
  count, via DynamicCourseParser._calculate_completion_stats (already
  computed recursive completion for any node, just never called
  per-section) exposed as a Jinja global.

- Library browser gets a list/grid toggle for browsing large category
  folders by poster-style thumbnail instead of a 32px-icon list.
  Client-side only, persisted via localStorage, defaults to the
  existing list view; both folder browsing and search results already
  funnel through the same render function so grid mode covers both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 14:04:14 -04:00
co-authored by Claude Sonnet 5
parent 3fedf849d3
commit bc6725ee4c
3 changed files with 318 additions and 20 deletions
+117
View File
@@ -188,6 +188,65 @@
.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;
@@ -502,6 +561,43 @@
{% 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' %}🎥
{% elif item.lesson_type == 'audio' %}🎵
{% elif item.lesson_type == 'quiz' %}📝
{% elif item.lesson_type == 'mixed' %}📦
{% else %}📄{% endif %}
</span>
<span class="section-lesson-title">{{ item.title }}</span>
{% if item.completed %}
<span class="section-lesson-status completed"></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">
@@ -904,6 +1000,27 @@
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();