Visual polish: theme-aware colors, collapsible cards, skeletons, hover consistency
Templates only, no backend changes: - Fixed the real bug: JS-generated status/loading text and a few component styles used hardcoded hex (#007acc, #999/#666, #28a745, #ff6b6b) instead of the CSS variables the theme system already provides, so they didn't track the user's chosen theme/accent - most visible on Light or any named theme other than the default. Added --success/--error to all 5 templates' :root blocks and replaced every non-:root occurrence. Left the .lesson-type badge colors and generic UI chrome grays alone (intentional, not theme-dependent). Verified live by switching to Nord and Light themes. - Dashboard cards (Library Stats, Next Up, Pick Back Up, Recently Added, Continue Watching, Recently Viewed) are now collapsible, state remembered per device via localStorage - the no-course dashboard had grown to 7 stacked cards. - Course page's three action buttons get consistent compact sizing and icons instead of a plain stack. - Library browser and transcript search show shimmering skeleton rows instead of bare "Loading..."/"Searching..." text while a fetch is in flight. - Hover treatment made consistent: transcript results get the same lift .lesson-item already had, heatmap cells get a GitHub-style hover scale-up, and Notes Hub links get a background-chip hover. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+153
-60
@@ -28,6 +28,8 @@
|
||||
--font-size-base: 16px;
|
||||
--container-max-width: 1600px;
|
||||
--radius: 8px;
|
||||
--success: #28a745;
|
||||
--error: #ff6b6b;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f2f2f2;
|
||||
@@ -301,17 +303,77 @@
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
opacity: 0.12;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.heatmap-day.level-1 { opacity: 0.4; }
|
||||
.heatmap-day.level-2 { opacity: 0.7; }
|
||||
.heatmap-day.level-3 { opacity: 1; }
|
||||
|
||||
.heatmap-day:hover {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.course-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
height: 48px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 5px;
|
||||
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-tertiary-hover) 50%, var(--bg-tertiary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.collapsible-header {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.collapsible-header::after {
|
||||
content: '▾';
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.collapsible-header.collapsed::after {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
overflow: hidden;
|
||||
max-height: 3000px;
|
||||
transition: max-height 0.3s ease, opacity 0.2s ease, margin-top 0.3s ease;
|
||||
margin-top: 12px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-body.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.library-breadcrumb {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
@@ -375,11 +437,11 @@
|
||||
}
|
||||
|
||||
.tree-header.lesson {
|
||||
border-left-color: #28a745;
|
||||
border-left-color: var(--success);
|
||||
}
|
||||
|
||||
.tree-header.completed {
|
||||
border-left-color: #28a745;
|
||||
border-left-color: var(--success);
|
||||
background: #1e3d1e;
|
||||
}
|
||||
|
||||
@@ -466,7 +528,7 @@
|
||||
}
|
||||
|
||||
.lesson-item.completed {
|
||||
border-left-color: #28a745;
|
||||
border-left-color: var(--success);
|
||||
background: #1e3d1e;
|
||||
}
|
||||
|
||||
@@ -493,7 +555,7 @@
|
||||
}
|
||||
|
||||
.lesson-item.completed .lesson-progress-fill {
|
||||
background: #28a745;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.watched-badge {
|
||||
@@ -573,10 +635,12 @@
|
||||
margin-bottom: 5px;
|
||||
border-left: 3px solid var(--accent);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.transcript-result:hover {
|
||||
background: var(--bg-tertiary-hover);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.transcript-result-title {
|
||||
@@ -641,16 +705,16 @@
|
||||
}
|
||||
|
||||
.status-icon.completed {
|
||||
color: #28a745;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-icon.pending {
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.last-accessed {
|
||||
background: #2d5a2d;
|
||||
border-left-color: #28a745;
|
||||
border-left-color: var(--success);
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
@@ -722,7 +786,7 @@
|
||||
<div class="container">
|
||||
<a href="/reset_course">← Select Different Course</a>
|
||||
<a href="#stats">Progress</a>
|
||||
<span style="color: #666; margin-left: auto;">
|
||||
<span style="color: var(--text-muted); margin-left: auto;">
|
||||
{{ stats.total_lessons }} lessons
|
||||
</span>
|
||||
</div>
|
||||
@@ -736,7 +800,7 @@
|
||||
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<div style="color: #007acc; font-size: 1.2em;">
|
||||
<div style="color: var(--accent); font-size: 1.2em;">
|
||||
{{ "%.1f"|format(stats.completion_percentage) }}%
|
||||
</div>
|
||||
{% if stats.remaining_display %}
|
||||
@@ -751,15 +815,15 @@
|
||||
</div>
|
||||
|
||||
{% if stats.total_lessons %}
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 5px;">
|
||||
<button class="btn btn-secondary" onclick="markCourseWatched()">
|
||||
Mark all as completed
|
||||
<div class="course-actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="markCourseWatched()">
|
||||
✓ Mark all as completed
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="queue-toggle-btn" onclick="toggleQueued()">
|
||||
<button class="btn btn-secondary btn-sm" id="queue-toggle-btn" onclick="toggleQueued()">
|
||||
{{ '📌 Remove from Next Up' if is_queued else '📌 Add to Next Up' }}
|
||||
</button>
|
||||
{% if has_note %}
|
||||
<a class="btn btn-secondary" href="/course/study-guide">Download Study Guide</a>
|
||||
<a class="btn btn-secondary btn-sm" href="/course/study-guide">📄 Download Study Guide</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -879,48 +943,50 @@
|
||||
{% if library_stats and library_stats.total_courses %}
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h2>📊 Library Stats</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stats-tile">
|
||||
<div class="stats-tile-value">{{ library_stats.total_courses }}</div>
|
||||
<div class="stats-tile-label">Courses</div>
|
||||
<h2 class="collapsible-header" data-card-id="library-stats">📊 Library Stats</h2>
|
||||
<div class="card-body">
|
||||
<div class="stats-grid">
|
||||
<div class="stats-tile">
|
||||
<div class="stats-tile-value">{{ library_stats.total_courses }}</div>
|
||||
<div class="stats-tile-label">Courses</div>
|
||||
</div>
|
||||
<div class="stats-tile">
|
||||
<div class="stats-tile-value">{{ library_stats.completed_lessons }}/{{ library_stats.lessons_tracked }}</div>
|
||||
<div class="stats-tile-label">Lessons completed</div>
|
||||
</div>
|
||||
<div class="stats-tile">
|
||||
<div class="stats-tile-value">{{ library_stats.watched_display }}</div>
|
||||
<div class="stats-tile-label">Watched</div>
|
||||
</div>
|
||||
<div class="stats-tile">
|
||||
<div class="stats-tile-value">{{ library_stats.streak_days }}</div>
|
||||
<div class="stats-tile-label">Day streak</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-tile">
|
||||
<div class="stats-tile-value">{{ library_stats.completed_lessons }}/{{ library_stats.lessons_tracked }}</div>
|
||||
<div class="stats-tile-label">Lessons completed</div>
|
||||
</div>
|
||||
<div class="stats-tile">
|
||||
<div class="stats-tile-value">{{ library_stats.watched_display }}</div>
|
||||
<div class="stats-tile-label">Watched</div>
|
||||
</div>
|
||||
<div class="stats-tile">
|
||||
<div class="stats-tile-value">{{ library_stats.streak_days }}</div>
|
||||
<div class="stats-tile-label">Day streak</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if activity_heatmap %}
|
||||
<div class="heatmap-scroll">
|
||||
<div class="heatmap-grid">
|
||||
{% for week in activity_heatmap|batch(7) %}
|
||||
<div class="heatmap-week">
|
||||
{% for day in week %}
|
||||
{% set level = 0 if day.count == 0 else (1 if day.count == 1 else (2 if day.count == 2 else 3)) %}
|
||||
<div class="heatmap-day level-{{ level }}"
|
||||
title="{{ day.date }}: {{ day.count }} lesson{{ '' if day.count == 1 else 's' }}"></div>
|
||||
{% if activity_heatmap %}
|
||||
<div class="heatmap-scroll">
|
||||
<div class="heatmap-grid">
|
||||
{% for week in activity_heatmap|batch(7) %}
|
||||
<div class="heatmap-week">
|
||||
{% for day in week %}
|
||||
{% set level = 0 if day.count == 0 else (1 if day.count == 1 else (2 if day.count == 2 else 3)) %}
|
||||
<div class="heatmap-day level-{{ level }}"
|
||||
title="{{ day.date }}: {{ day.count }} lesson{{ '' if day.count == 1 else 's' }}"></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if next_up %}
|
||||
<div class="container">
|
||||
<div class="card" id="next-up-card">
|
||||
<h2>📌 Next Up</h2>
|
||||
<div id="next-up-list" style="margin-top: 12px;">
|
||||
<h2 class="collapsible-header" data-card-id="next-up">📌 Next Up</h2>
|
||||
<div class="card-body" id="next-up-list">
|
||||
{% for item in next_up %}
|
||||
<div class="lesson-item" data-path="{{ item.path }}">
|
||||
<div class="lesson-title" style="cursor: pointer;" onclick="loadCoursePath('{{ item.path|replace("'", "\\'") }}')">
|
||||
@@ -946,8 +1012,8 @@
|
||||
{% if stale_courses %}
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h2>⏰ Pick Back Up</h2>
|
||||
<div style="margin-top: 12px;">
|
||||
<h2 class="collapsible-header" data-card-id="pick-back-up">⏰ Pick Back Up</h2>
|
||||
<div class="card-body">
|
||||
{% for item in stale_courses %}
|
||||
{{ render_course_row(item, 'Last touched ' + item.last_touched_display) }}
|
||||
{% endfor %}
|
||||
@@ -958,8 +1024,8 @@
|
||||
{% if recently_added %}
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h2>🆕 Recently Added</h2>
|
||||
<div style="margin-top: 12px;">
|
||||
<h2 class="collapsible-header" data-card-id="recently-added">🆕 Recently Added</h2>
|
||||
<div class="card-body">
|
||||
{% for item in recently_added %}
|
||||
{{ render_course_row(item, item.added_display) }}
|
||||
{% endfor %}
|
||||
@@ -970,8 +1036,8 @@
|
||||
{% if continue_watching %}
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h2>▶ Continue Watching</h2>
|
||||
<div style="margin-top: 12px;">
|
||||
<h2 class="collapsible-header" data-card-id="continue-watching">▶ Continue Watching</h2>
|
||||
<div class="card-body">
|
||||
{% for view in continue_watching %}
|
||||
{{ render_view_row(view) }}
|
||||
{% endfor %}
|
||||
@@ -982,8 +1048,8 @@
|
||||
{% if recent_views %}
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h2>🕐 Recently Viewed</h2>
|
||||
<div style="margin-top: 12px;">
|
||||
<h2 class="collapsible-header" data-card-id="recently-viewed">🕐 Recently Viewed</h2>
|
||||
<div class="card-body">
|
||||
{% for view in recent_views %}
|
||||
{{ render_view_row(view) }}
|
||||
{% endfor %}
|
||||
@@ -1077,7 +1143,7 @@
|
||||
if (transcriptResults) transcriptResults.innerHTML = '';
|
||||
|
||||
const container = document.getElementById('library-groups');
|
||||
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Loading...</p>';
|
||||
container.innerHTML = skeletonRowsHtml(3);
|
||||
const url = path ? `/library?path=${encodeURIComponent(path)}` : '/library';
|
||||
fetch(url)
|
||||
.then(r => r.json())
|
||||
@@ -1087,7 +1153,7 @@
|
||||
})
|
||||
.catch(() => {
|
||||
container.innerHTML =
|
||||
'<p style="color:#ff6b6b;">Could not reach the library scanner.</p>';
|
||||
'<p style="color:var(--error);">Could not reach the library scanner.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1125,7 +1191,7 @@
|
||||
const reason = (data.errors && data.errors.length)
|
||||
? data.errors.join(' ')
|
||||
: (isRoot ? `No course folders found under ${data.library_path}.` : 'No courses in here.');
|
||||
container.innerHTML = `<p style="color:#999; padding: 6px 0;">${reason}${isRoot ? ' You can still load a course by path below.' : ''}</p>`;
|
||||
container.innerHTML = `<p style="color:var(--text-muted); padding: 6px 0;">${reason}${isRoot ? ' You can still load a course by path below.' : ''}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1138,6 +1204,12 @@
|
||||
fetchLibraryLevel(path);
|
||||
}
|
||||
|
||||
// A few shimmering placeholder rows for in-flight fetches, instead
|
||||
// of bare "Loading..." text.
|
||||
function skeletonRowsHtml(count) {
|
||||
return Array(count).fill('<div class="skeleton-row"></div>').join('');
|
||||
}
|
||||
|
||||
// Fallback if a course's thumbnail image 404s/errors after the row
|
||||
// already rendered (e.g. the file was removed on disk in between) -
|
||||
// swaps back to the plain icon rather than showing a broken image.
|
||||
@@ -1215,7 +1287,7 @@
|
||||
searchActive = true;
|
||||
const container = document.getElementById('library-groups');
|
||||
const transcriptContainer = document.getElementById('transcript-results');
|
||||
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Searching...</p>';
|
||||
container.innerHTML = skeletonRowsHtml(2);
|
||||
if (transcriptContainer) transcriptContainer.innerHTML = '';
|
||||
|
||||
fetch(`/library/search?q=${encodeURIComponent(query)}`)
|
||||
@@ -1225,13 +1297,13 @@
|
||||
`<span class="crumb current" style="max-width: none;">Search results for "${query}"</span>`;
|
||||
lastLibraryItems = data.results;
|
||||
if (!data.results.length) {
|
||||
container.innerHTML = `<p style="color:#999; padding: 6px 0;">No courses match "${query}".</p>`;
|
||||
container.innerHTML = `<p style="color:var(--text-muted); padding: 6px 0;">No courses match "${query}".</p>`;
|
||||
return;
|
||||
}
|
||||
renderItemRows(sortLibraryItems(data.results));
|
||||
})
|
||||
.catch(() => {
|
||||
container.innerHTML = '<p style="color:#ff6b6b;">Search failed.</p>';
|
||||
container.innerHTML = '<p style="color:var(--error);">Search failed.</p>';
|
||||
});
|
||||
|
||||
const searchTranscripts = document.getElementById('search-transcripts-toggle');
|
||||
@@ -1243,7 +1315,7 @@
|
||||
function runTranscriptSearch(query) {
|
||||
const transcriptContainer = document.getElementById('transcript-results');
|
||||
if (!transcriptContainer) return;
|
||||
transcriptContainer.innerHTML = '<p style="color:#999; padding: 6px 0;">Searching transcripts...</p>';
|
||||
transcriptContainer.innerHTML = skeletonRowsHtml(2);
|
||||
fetch(`/library/search-transcripts?q=${encodeURIComponent(query)}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -1373,7 +1445,28 @@
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
// ---- Collapsible dashboard cards (client-only, remembered per device) ----
|
||||
function initCollapsibleCards() {
|
||||
document.querySelectorAll('.collapsible-header').forEach(function(header) {
|
||||
const cardId = header.dataset.cardId;
|
||||
const body = header.nextElementSibling;
|
||||
if (!cardId || !body) return;
|
||||
|
||||
if (localStorage.getItem('offlineu-collapsed-' + cardId) === '1') {
|
||||
header.classList.add('collapsed');
|
||||
body.classList.add('collapsed');
|
||||
}
|
||||
|
||||
header.addEventListener('click', function() {
|
||||
const isCollapsed = body.classList.toggle('collapsed');
|
||||
header.classList.toggle('collapsed', isCollapsed);
|
||||
localStorage.setItem('offlineu-collapsed-' + cardId, isCollapsed ? '1' : '0');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadLibrary);
|
||||
document.addEventListener('DOMContentLoaded', initCollapsibleCards);
|
||||
|
||||
</script>
|
||||
<script src="/static/theme.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user