Add per-course progress badges to library cards, replace alert() with toasts

Compute a cheap, uncached completion percentage per course (completed
lessons / media files) and surface it as a badge + thin progress bar on
library cards in both list and grid view, and in search results - untouched
courses stay unbadged so the list doesn't get noisy with "0%".

Replace the native alert() dialogs used for bulk-select validation and
course-load errors with a small slide-in toast component that matches the
rest of the UI instead of a jarring browser popup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 21:22:00 -04:00
co-authored by Claude Sonnet 5
parent 3039d1206d
commit e2db5ecef8
2 changed files with 109 additions and 9 deletions
+35 -5
View File
@@ -806,6 +806,7 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str,
if _looks_like_course(entry): if _looks_like_course(entry):
item = _course_summary(entry) item = _course_summary(entry)
item['hidden'] = is_hidden item['hidden'] = is_hidden
item['completion_percentage'] = _course_completion_percentage(entry, item['media_files'])
items.append(item) items.append(item)
else: else:
if skip_hidden: if skip_hidden:
@@ -904,6 +905,32 @@ def _course_summary(course_dir: Path) -> Dict[str, Any]:
return dict(cache_get_or_compute(f'course_summary:{course_dir}', compute)) return dict(cache_get_or_compute(f'course_summary:{course_dir}', compute))
def _course_completion_percentage(course_dir: Path, media_files: int) -> Optional[float]:
"""
Cheap, uncached completion percentage for a single course card - reads
only that course's own small progress JSON directly (same pattern as
_scan_library_activity), never the cached _course_summary result, so a
lesson finished a second ago shows up immediately instead of waiting out
the library-scan cache TTL. None if nothing's completed yet, so
untouched courses don't show a noisy "0%" badge.
"""
if media_files <= 0:
return None
try:
with open(course_dir / '.offlineu_progress.json', 'r') as f:
progress = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
completed = sum(
1 for key, entry in progress.items()
if key != 'last_accessed_path' and isinstance(entry, dict) and entry.get('completed')
)
if completed == 0:
return None
return round(min(100.0, 100 * completed / media_files), 1)
def search_library_courses(query: str) -> List[Dict[str, Any]]: def search_library_courses(query: str) -> List[Dict[str, Any]]:
""" """
Search the library for courses whose name contains `query` Search the library for courses whose name contains `query`
@@ -912,11 +939,14 @@ def search_library_courses(query: str) -> List[Dict[str, Any]]:
get_all_course_dirs. get_all_course_dirs.
""" """
query_lower = query.lower() query_lower = query.lower()
return [ results = []
_course_summary(course) for course in get_all_course_dirs():
for course in get_all_course_dirs() if query_lower not in course.name.lower():
if query_lower in course.name.lower() continue
] item = _course_summary(course)
item['completion_percentage'] = _course_completion_percentage(course, item['media_files'])
results.append(item)
return results
def _extract_subtitle_snippet(text: str, query_lower: str, context_chars: int = 80) -> str: def _extract_subtitle_snippet(text: str, query_lower: str, context_chars: int = 80) -> str:
+74 -4
View File
@@ -145,6 +145,38 @@
word-break: break-word; word-break: break-word;
} }
.toast-container {
position: fixed;
left: 0;
right: 0;
bottom: calc(60px + env(safe-area-inset-bottom, 0px));
z-index: 9500;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 0 16px;
pointer-events: none;
}
.toast {
max-width: 480px;
width: 100%;
background: var(--bg-tertiary);
color: var(--text-primary);
border-left: 4px solid var(--error);
border-radius: var(--radius);
padding: 12px 16px;
font-size: 0.9em;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
}
.toast.toast-visible {
opacity: 1;
transform: translateY(0);
}
.bottom-tab-bar { .bottom-tab-bar {
position: fixed; position: fixed;
left: 0; left: 0;
@@ -766,6 +798,16 @@
font-size: 0.75em; font-size: 0.75em;
color: var(--text-muted); color: var(--text-muted);
} }
.grid-card-progress-track {
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.grid-card-progress-fill {
height: 100%;
background: var(--accent);
}
.transcript-search-toggle { .transcript-search-toggle {
display: flex; display: flex;
@@ -1260,7 +1302,23 @@
</a> </a>
</nav> </nav>
<div id="toast-container" class="toast-container"></div>
<script> <script>
function showToast(message) {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add('toast-visible'));
setTimeout(() => {
toast.classList.remove('toast-visible');
setTimeout(() => toast.remove(), 250);
}, 3500);
}
function toggleTree(element) { function toggleTree(element) {
console.log('Toggle clicked:', element); console.log('Toggle clicked:', element);
@@ -1552,7 +1610,7 @@
return item && item.type === 'course'; return item && item.type === 'course';
}); });
if (!coursePaths.length) { if (!coursePaths.length) {
alert('Select at least one course (not a folder) to add to Next Up.'); showToast('Select at least one course (not a folder) to add to Next Up.');
return; return;
} }
fetch('/api/next-up/bulk', { fetch('/api/next-up/bulk', {
@@ -1572,12 +1630,17 @@
? `<img class="grid-card-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(gridFallbackInitial('${safeName}'))">` ? `<img class="grid-card-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(gridFallbackInitial('${safeName}'))">`
: courseInitialHtml(item.name); : courseInitialHtml(item.name);
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`; const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
const pct = item.completion_percentage;
const progressBar = pct
? `<div class="grid-card-progress-track"><div class="grid-card-progress-fill" style="width: ${pct}%;"></div></div>`
: '';
return ` return `
<div class="library-grid-card" onclick="${clickHandler}"> <div class="library-grid-card" onclick="${clickHandler}">
${selectableAttrs(item)} ${selectableAttrs(item)}
<div class="grid-card-thumb-wrap">${iconHtml}</div> <div class="grid-card-thumb-wrap">${iconHtml}</div>
<div class="grid-card-name" title="${escapeAttr(item.name)}">${item.name}</div> <div class="grid-card-name" title="${escapeAttr(item.name)}">${item.name}</div>
<div class="grid-card-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</div> <div class="grid-card-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}${pct ? ` · ${pct}% done` : ''}</div>
${progressBar}
</div> </div>
`; `;
} }
@@ -1601,6 +1664,11 @@
? `<img class="lesson-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(courseFallbackIcon('🎓'))">` ? `<img class="lesson-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(courseFallbackIcon('🎓'))">`
: `<span class="lesson-icon">🎓</span>`; : `<span class="lesson-icon">🎓</span>`;
const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`; const clickHandler = selectionMode ? `toggleSelection('${safePath}')` : `loadCoursePath('${safePath}')`;
const pct = item.completion_percentage;
const progressBadge = pct ? `<span class="watched-badge">${pct}% done</span>` : '';
const progressBar = pct
? `<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: ${pct}%;"></div></div>`
: '';
return ` return `
<div class="lesson-item" onclick="${clickHandler}"> <div class="lesson-item" onclick="${clickHandler}">
<div class="lesson-title"> <div class="lesson-title">
@@ -1609,9 +1677,11 @@
<span class="lesson-name">${item.name}</span> <span class="lesson-name">${item.name}</span>
</div> </div>
<div class="lesson-meta"> <div class="lesson-meta">
${progressBadge}
<span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span> <span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">📌</button> <button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">📌</button>
</div> </div>
${progressBar}
</div> </div>
`; `;
} }
@@ -1753,7 +1823,7 @@
history.pushState({ courseLoaded: true }, ''); history.pushState({ courseLoaded: true }, '');
location.reload(); location.reload();
} else { } else {
alert('Error: ' + data.error); showToast('Error: ' + data.error);
} }
}); });
} }
@@ -1809,7 +1879,7 @@
if (data.success) { if (data.success) {
location.reload(); location.reload();
} else { } else {
alert('Error: ' + (data.error || 'Unknown error')); showToast('Error: ' + (data.error || 'Unknown error'));
} }
}); });
} }