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:
+35
-5
@@ -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:
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user