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:
@@ -145,6 +145,38 @@
|
||||
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 {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -766,6 +798,16 @@
|
||||
font-size: 0.75em;
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -1260,7 +1302,23 @@
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
|
||||
<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) {
|
||||
console.log('Toggle clicked:', element);
|
||||
|
||||
@@ -1552,7 +1610,7 @@
|
||||
return item && item.type === 'course';
|
||||
});
|
||||
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;
|
||||
}
|
||||
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}'))">`
|
||||
: courseInitialHtml(item.name);
|
||||
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 `
|
||||
<div class="library-grid-card" onclick="${clickHandler}">
|
||||
${selectableAttrs(item)}
|
||||
<div class="grid-card-thumb-wrap">${iconHtml}</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>
|
||||
`;
|
||||
}
|
||||
@@ -1601,6 +1664,11 @@
|
||||
? `<img class="lesson-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(courseFallbackIcon('🎓'))">`
|
||||
: `<span class="lesson-icon">🎓</span>`;
|
||||
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 `
|
||||
<div class="lesson-item" onclick="${clickHandler}">
|
||||
<div class="lesson-title">
|
||||
@@ -1609,9 +1677,11 @@
|
||||
<span class="lesson-name">${item.name}</span>
|
||||
</div>
|
||||
<div class="lesson-meta">
|
||||
${progressBadge}
|
||||
<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>
|
||||
</div>
|
||||
${progressBar}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1753,7 +1823,7 @@
|
||||
history.pushState({ courseLoaded: true }, '');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + data.error);
|
||||
showToast('Error: ' + data.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1809,7 +1879,7 @@
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error: ' + (data.error || 'Unknown error'));
|
||||
showToast('Error: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user