diff --git a/OfflineU-project-summary.md b/OfflineU-project-summary.md index 3b93c75..27f0e1c 100644 --- a/OfflineU-project-summary.md +++ b/OfflineU-project-summary.md @@ -324,6 +324,47 @@ export, a Next Up queue, and an activity heatmap. into the existing Library Stats card, using the same per-day counts the shared scan already computes for the streak stat. +## Visual polish pass (this session) + +Templates-only, no backend changes. Confirmed by inventory +(`grep -noE '#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b'` across all 5 templates) +before touching anything, to separate real bugs from the `:root` theme +palette definitions themselves. + +- **Theme-aware colors (the actual bug)** — JS-generated status/loading + text and a few component styles used hardcoded hex instead of the CSS + variables the theme system already provides: `#007acc` instead of + `var(--accent)` (the course completion-% number, lesson_view.html's + notification banner), `#999`/`#666` instead of `var(--text-muted)` for + loading/empty/error hint text, and `#28a745`/`#ff6b6b` (success/error) + hardcoded in ~25 places with no shared variable at all. Added + `--success`/`--error` to all 5 templates' `:root` blocks (fixed values, + not theme-varied - semantic status colors stay recognizable across + themes by convention) and replaced every non-`:root` occurrence. + Deliberately left alone: the `.lesson-type` badge colors (video/audio/ + text/quiz/mixed - intentional fixed content-type coding) and generic UI + chrome grays (progress-bar track, disabled-button background) unrelated + to theme. Verified live by switching to Nord and Light themes and + confirming the completion-% number and status text actually track the + chosen accent/muted colors instead of staying stuck on the old defaults. +- **Dashboard card collapse** — click a card's `

` to collapse it, + remembered in `localStorage` per card, no backend involved. Applied to + the six stackable no-course-dashboard cards (Library Stats, Next Up, + Pick Back Up, Recently Added, Continue Watching, Recently Viewed) - the + Library browser card stays always-visible as the primary action. +- **Course page action buttons** — the three stats-card buttons (Mark all + completed / Add to Next Up / Download Study Guide) now use consistent + `.btn-sm` compact sizing with icons, in a `.course-actions` flex row + instead of a plain inline-styled stack. +- **Skeleton loading states** — the Library browser and transcript search + show shimmering placeholder rows (`skeletonRowsHtml()`, pure CSS + gradient-position animation) while a fetch is in flight, instead of + bare "Loading…"/"Searching…" text. +- **Hover consistency** — `.transcript-result` now gets the same lift + hover `.lesson-item` already had; `.heatmap-day` gets a GitHub-style + hover scale-up; `.note-card-link` (Notes Hub) gets a background-chip + hover instead of just underline. + ## Workflow that's been in use Edit locally → `git add . && git commit -m "..." && git push` to the private Gitea repo → redeploy the stack in Dockhand (which builds from the fresh diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index 543864a..247c63c 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -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 @@
← Select Different Course Progress - + {{ stats.total_lessons }} lessons
@@ -736,7 +800,7 @@ {{ stats.completed_lessons }}/{{ stats.total_lessons }} lessons completed
-
+
{{ "%.1f"|format(stats.completion_percentage) }}%
{% if stats.remaining_display %} @@ -751,15 +815,15 @@
{% if stats.total_lessons %} -
- - {% if has_note %} - Download Study Guide + 📄 Download Study Guide {% endif %}
{% endif %} @@ -879,48 +943,50 @@ {% if library_stats and library_stats.total_courses %}
-

📊 Library Stats

-
-
-
{{ library_stats.total_courses }}
-
Courses
+

📊 Library Stats

+
+
+
+
{{ library_stats.total_courses }}
+
Courses
+
+
+
{{ library_stats.completed_lessons }}/{{ library_stats.lessons_tracked }}
+
Lessons completed
+
+
+
{{ library_stats.watched_display }}
+
Watched
+
+
+
{{ library_stats.streak_days }}
+
Day streak
+
-
-
{{ library_stats.completed_lessons }}/{{ library_stats.lessons_tracked }}
-
Lessons completed
-
-
-
{{ library_stats.watched_display }}
-
Watched
-
-
-
{{ library_stats.streak_days }}
-
Day streak
-
-
- {% if activity_heatmap %} -
-
- {% for week in activity_heatmap|batch(7) %} -
- {% 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)) %} -
+ {% if activity_heatmap %} +
+
+ {% for week in activity_heatmap|batch(7) %} +
+ {% 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)) %} +
+ {% endfor %} +
{% endfor %}
- {% endfor %}
+ {% endif %}
- {% endif %}
{% endif %} {% if next_up %}
-

📌 Next Up

-
+

📌 Next Up

+
{% for item in next_up %}
@@ -946,8 +1012,8 @@ {% if stale_courses %}
-

⏰ Pick Back Up

-
+

⏰ Pick Back Up

+
{% for item in stale_courses %} {{ render_course_row(item, 'Last touched ' + item.last_touched_display) }} {% endfor %} @@ -958,8 +1024,8 @@ {% if recently_added %}
-

🆕 Recently Added

-
+

🆕 Recently Added

+
{% for item in recently_added %} {{ render_course_row(item, item.added_display) }} {% endfor %} @@ -970,8 +1036,8 @@ {% if continue_watching %}
-

▶ Continue Watching

-
+

▶ Continue Watching

+
{% for view in continue_watching %} {{ render_view_row(view) }} {% endfor %} @@ -982,8 +1048,8 @@ {% if recent_views %}
-

🕐 Recently Viewed

-
+

🕐 Recently Viewed

+
{% 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 = '

Loading...

'; + 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 = - '

Could not reach the library scanner.

'; + '

Could not reach the library scanner.

'; }); } @@ -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 = `

${reason}${isRoot ? ' You can still load a course by path below.' : ''}

`; + container.innerHTML = `

${reason}${isRoot ? ' You can still load a course by path below.' : ''}

`; 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('
').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 = '

Searching...

'; + container.innerHTML = skeletonRowsHtml(2); if (transcriptContainer) transcriptContainer.innerHTML = ''; fetch(`/library/search?q=${encodeURIComponent(query)}`) @@ -1225,13 +1297,13 @@ `Search results for "${query}"`; lastLibraryItems = data.results; if (!data.results.length) { - container.innerHTML = `

No courses match "${query}".

`; + container.innerHTML = `

No courses match "${query}".

`; return; } renderItemRows(sortLibraryItems(data.results)); }) .catch(() => { - container.innerHTML = '

Search failed.

'; + container.innerHTML = '

Search failed.

'; }); 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 = '

Searching transcripts...

'; + 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); diff --git a/templates/help.html b/templates/help.html index 40f9426..0b81e32 100644 --- a/templates/help.html +++ b/templates/help.html @@ -22,6 +22,8 @@ --font-size-base: 16px; --container-max-width: 1600px; --radius: 8px; + --success: #28a745; + --error: #ff6b6b; } [data-theme="light"] { --bg-primary: #f2f2f2; diff --git a/templates/lesson_view.html b/templates/lesson_view.html index 1340d3f..b16c2fe 100644 --- a/templates/lesson_view.html +++ b/templates/lesson_view.html @@ -22,6 +22,8 @@ --font-size-base: 16px; --container-max-width: 1600px; --radius: 8px; + --success: #28a745; + --error: #ff6b6b; } [data-theme="light"] { --bg-primary: #f2f2f2; @@ -364,7 +366,7 @@

{{ text_file.split('/')[-1] }}

-
Loading content...
+
Loading content...
📄 Open {{ text_file.split('/')[-1] }} in new tab @@ -450,7 +452,7 @@ // fetching them as text would show garbled binary content, // so just point at the download/open link instead. contentDiv.innerHTML = - '
Preview isn\'t available for this file type. ' + + '
Preview isn\'t available for this file type. ' + 'Use the "Open in new tab" link below to view or download it.
'; } else { // For text files, fetch and display content @@ -478,9 +480,9 @@ .catch(error => { console.error('Error loading content:', error); if (contentDiv) { - contentDiv.innerHTML = - '
Error loading content: ' + error.message + '
' + - '
File path: ' + filePath + '
'; + contentDiv.innerHTML = + '
Error loading content: ' + error.message + '
' + + '
File path: ' + filePath + '
'; } }); } @@ -762,7 +764,7 @@ const btn = document.querySelector('button[onclick="markCompleted()"]'); if (btn) { btn.textContent = 'Completed ✓'; - btn.style.background = '#28a745'; + btn.style.background = 'var(--success)'; btn.disabled = true; } isCompleted = true; @@ -775,7 +777,7 @@ position: fixed; top: 20px; right: 20px; - background: ${type === 'success' ? '#28a745' : '#007acc'}; + background: ${type === 'success' ? 'var(--success)' : 'var(--accent)'}; color: white; padding: 15px 20px; border-radius: 5px; diff --git a/templates/notes_hub.html b/templates/notes_hub.html index e7e3121..e777712 100644 --- a/templates/notes_hub.html +++ b/templates/notes_hub.html @@ -22,6 +22,8 @@ --font-size-base: 16px; --container-max-width: 1600px; --radius: 8px; + --success: #28a745; + --error: #ff6b6b; } [data-theme="light"] { --bg-primary: #f2f2f2; @@ -171,13 +173,19 @@ line-height: 1.5; } .note-card-link { - display: block; + display: inline-block; margin-top: 12px; font-size: 0.85em; color: var(--accent); text-decoration: none; + padding: 4px 10px; + border-radius: var(--radius); + transition: background 0.2s ease; + } + .note-card-link:hover { + background: var(--bg-tertiary); + text-decoration: underline; } - .note-card-link:hover { text-decoration: underline; } diff --git a/templates/settings.html b/templates/settings.html index d899959..2f49646 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -22,6 +22,8 @@ --font-size-base: 16px; --container-max-width: 1600px; --radius: 8px; + --success: #28a745; + --error: #ff6b6b; } [data-theme="light"] { --bg-primary: #f2f2f2; @@ -279,7 +281,7 @@ .curate-row-status { flex-basis: 100%; font-size: 0.8em; - color: #ff6b6b; + color: var(--error); } .bulk-rename-row { display: flex; @@ -307,7 +309,7 @@ } #save-status { font-size: 0.9em; - color: #28a745; + color: var(--success); opacity: 0; transition: opacity 0.3s; } @@ -658,7 +660,7 @@ renderCurateLevel(data, container); }) .catch(() => { - container.innerHTML = '

Could not reach the library scanner.

'; + container.innerHTML = '

Could not reach the library scanner.

'; }); } @@ -834,7 +836,7 @@ const preview = document.getElementById('bulk-rename-preview'); preview.innerHTML = ''; if (!pattern) { - status.style.color = '#ff6b6b'; + status.style.color = 'var(--error)'; status.textContent = 'Enter text to find first.'; return; } @@ -847,7 +849,7 @@ renderBulkRenamePreview(); }) .catch(() => { - status.style.color = '#ff6b6b'; + status.style.color = 'var(--error)'; status.textContent = 'Could not reach the server.'; }); } @@ -898,7 +900,7 @@ const failed = results.filter(r => !r.success); const activeCourseReset = results.some(r => r.active_course_reset); - status.style.color = failed.length ? '#ff6b6b' : '#28a745'; + status.style.color = failed.length ? 'var(--error)' : 'var(--success)'; let message = `${succeeded} renamed`; if (failed.length) { message += `, ${failed.length} failed: ` + @@ -919,7 +921,7 @@ loadCurateLevel(null, document.getElementById('curate-tree'), true); }) .catch(() => { - status.style.color = '#ff6b6b'; + status.style.color = 'var(--error)'; status.textContent = 'Could not reach the server.'; }); } @@ -935,16 +937,16 @@ .then(r => r.json()) .then(data => { if (data.success) { - status.style.color = '#28a745'; + status.style.color = 'var(--success)'; status.textContent = '✓ Saved — reload the main page to see it take effect'; showSaved(); } else { - status.style.color = '#ff6b6b'; + status.style.color = 'var(--error)'; status.textContent = data.error || 'Could not save'; } }) .catch(err => { - status.style.color = '#ff6b6b'; + status.style.color = 'var(--error)'; status.textContent = 'Could not reach the server'; }); } @@ -955,7 +957,7 @@ const coursePath = input.value.trim(); if (!coursePath) { - status.style.color = '#ff6b6b'; + status.style.color = 'var(--error)'; status.textContent = 'Please enter a course path.'; return; } @@ -971,16 +973,16 @@ .then(r => r.json()) .then(data => { if (data.success) { - status.style.color = '#28a745'; + status.style.color = 'var(--success)'; status.textContent = `✓ Loaded "${data.course_name}" — redirecting...`; setTimeout(() => { window.location.href = '/'; }, 1000); } else { - status.style.color = '#ff6b6b'; + status.style.color = 'var(--error)'; status.textContent = data.error || 'Could not load course'; } }) .catch(() => { - status.style.color = '#ff6b6b'; + status.style.color = 'var(--error)'; status.textContent = 'Could not reach the server'; }); } @@ -1055,17 +1057,17 @@ .then(r => r.json()) .then(data => { if (data.success) { - status.style.color = '#28a745'; + status.style.color = 'var(--success)'; status.textContent = 'Saved.'; document.getElementById('outline-token').value = ''; loadOutlineConfig(); } else { - status.style.color = '#ff6b6b'; + status.style.color = 'var(--error)'; status.textContent = data.error || 'Could not save.'; } }) .catch(() => { - status.style.color = '#ff6b6b'; + status.style.color = 'var(--error)'; status.textContent = 'Could not reach the server.'; }); } @@ -1077,11 +1079,11 @@ fetch('/api/outline/test') .then(r => r.json()) .then(data => { - status.style.color = data.success ? '#28a745' : '#ff6b6b'; + status.style.color = data.success ? 'var(--success)' : 'var(--error)'; status.textContent = data.success ? 'Connected.' : (data.error || 'Connection failed.'); }) .catch(() => { - status.style.color = '#ff6b6b'; + status.style.color = 'var(--error)'; status.textContent = 'Could not reach the server.'; }); }