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:
@@ -618,6 +618,82 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.library-view-toggle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.view-toggle-btn {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.view-toggle-btn:first-child {
|
||||
border-radius: var(--radius) 0 0 var(--radius);
|
||||
border-right: none;
|
||||
}
|
||||
.view-toggle-btn:last-child {
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
}
|
||||
.view-toggle-btn.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.library-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.library-grid-card {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.library-grid-card:hover {
|
||||
background: var(--bg-tertiary-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.grid-card-thumb-wrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.4em;
|
||||
}
|
||||
.grid-card-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.grid-card-name {
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.grid-card-meta {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.transcript-search-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -840,13 +916,14 @@
|
||||
|
||||
<div class="tree-container">
|
||||
{% macro render_tree_node(node, 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">📁</span>
|
||||
<span class="tree-name">{{ node.name }}</span>
|
||||
<span class="tree-stats">
|
||||
{{ (node.children|length + node.lessons|length) }} items
|
||||
{% if stats.total_lessons %}{{ stats.completed_lessons }}/{{ stats.total_lessons }} watched{% else %}Empty{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% if node.children or node.lessons %}
|
||||
@@ -1067,6 +1144,12 @@
|
||||
<option value="name-asc">Name (A→Z)</option>
|
||||
<option value="name-desc">Name (Z→A)</option>
|
||||
</select>
|
||||
<div class="library-view-toggle">
|
||||
<button type="button" class="view-toggle-btn" id="library-view-list-btn"
|
||||
onclick="setLibraryViewMode('list')" title="List view">☰</button>
|
||||
<button type="button" class="view-toggle-btn" id="library-view-grid-btn"
|
||||
onclick="setLibraryViewMode('grid')" title="Grid view">▦</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="transcript-search-toggle">
|
||||
<input type="checkbox" id="search-transcripts-toggle" onchange="handleLibrarySearchInput(document.getElementById('library-search-input').value)">
|
||||
@@ -1261,6 +1344,56 @@
|
||||
return span;
|
||||
}
|
||||
|
||||
// Same idea as courseFallbackIcon, but sized for a grid card's
|
||||
// square thumbnail area rather than an inline list-row icon.
|
||||
function gridFallbackIcon(emoji) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = emoji;
|
||||
return span;
|
||||
}
|
||||
|
||||
// ---- Library list/grid view toggle (client-only, remembered per device) ----
|
||||
let libraryViewMode = localStorage.getItem('offlineu-library-view-mode') || 'list';
|
||||
|
||||
function updateViewModeButtons() {
|
||||
const listBtn = document.getElementById('library-view-list-btn');
|
||||
const gridBtn = document.getElementById('library-view-grid-btn');
|
||||
if (listBtn) listBtn.classList.toggle('active', libraryViewMode === 'list');
|
||||
if (gridBtn) gridBtn.classList.toggle('active', libraryViewMode === 'grid');
|
||||
}
|
||||
|
||||
function setLibraryViewMode(mode) {
|
||||
libraryViewMode = mode;
|
||||
localStorage.setItem('offlineu-library-view-mode', mode);
|
||||
updateViewModeButtons();
|
||||
if (lastLibraryItems) renderItemRows(sortLibraryItems(lastLibraryItems));
|
||||
}
|
||||
|
||||
function courseCardHtml(item) {
|
||||
const safePath = item.path.replace(/'/g, "\\'");
|
||||
const iconHtml = item.has_thumbnail
|
||||
? `<img class="grid-card-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(gridFallbackIcon('🎓'))">`
|
||||
: `<span>🎓</span>`;
|
||||
return `
|
||||
<div class="library-grid-card" onclick="loadCoursePath('${safePath}')">
|
||||
<div class="grid-card-thumb-wrap">${iconHtml}</div>
|
||||
<div class="grid-card-name">${item.name}</div>
|
||||
<div class="grid-card-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function directoryCardHtml(item) {
|
||||
const safePath = item.path.replace(/'/g, "\\'");
|
||||
const safeName = item.name.replace(/'/g, "\\'");
|
||||
return `
|
||||
<div class="library-grid-card" onclick="enterLibraryDir('${safePath}', '${safeName}')">
|
||||
<div class="grid-card-thumb-wrap">📁</div>
|
||||
<div class="grid-card-name">${item.name}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function courseRowHtml(item) {
|
||||
const safePath = item.path.replace(/'/g, "\\'");
|
||||
const iconHtml = item.has_thumbnail
|
||||
@@ -1295,8 +1428,11 @@
|
||||
}
|
||||
|
||||
function renderItemRows(items) {
|
||||
document.getElementById('library-groups').innerHTML =
|
||||
items.map(item => item.type === 'course' ? courseRowHtml(item) : directoryRowHtml(item)).join('');
|
||||
const container = document.getElementById('library-groups');
|
||||
container.classList.toggle('library-grid', libraryViewMode === 'grid');
|
||||
container.innerHTML = libraryViewMode === 'grid'
|
||||
? items.map(item => item.type === 'course' ? courseCardHtml(item) : directoryCardHtml(item)).join('')
|
||||
: items.map(item => item.type === 'course' ? courseRowHtml(item) : directoryRowHtml(item)).join('');
|
||||
}
|
||||
|
||||
function sortLibraryItems(items) {
|
||||
@@ -1512,6 +1648,7 @@
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadLibrary);
|
||||
document.addEventListener('DOMContentLoaded', updateViewModeButtons);
|
||||
document.addEventListener('DOMContentLoaded', initCollapsibleCards);
|
||||
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user