Add colored grid-card fallbacks, bottom tab bar, dashboard reorder

Three visual/structural changes:

- Grid cards without a thumbnail (nearly all real courses in this
  library) showed the same generic graduation-cap icon, making the
  poster grid hard to visually scan. Replace it with a deterministic
  color+initial per course name (same idea as Slack/Google Photos
  default avatars), so cards without real cover art still look
  distinct from each other.

- Notes/Help/Settings only existed as small footer text links,
  meaning a scroll to the bottom of every page to navigate. Add a
  persistent bottom tab bar (Home/Notes/Help/Settings, current page
  highlighted via a new active_tab context var) to the dashboard,
  Notes Hub, Settings, and Help pages. Deliberately left the lesson
  page alone - it already has its own fixed-bottom notes panel, and a
  second fixed bar there would just eat into an already content-dense
  page.

- The no-course dashboard stacked five informational/queue cards
  (Library Stats, Next Up, Pick Back Up, Recently Added, Continue
  Watching, Recently Viewed) before "Your Courses," so the actual
  library browser was the last thing on the page. Move it up to right
  after the Surprise Me banner - it was already unconditional, so this
  only changes where it renders, not when.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 15:30:44 -04:00
co-authored by Claude Sonnet 5
parent 219c09692f
commit 21af7ae349
5 changed files with 235 additions and 175 deletions
+7 -4
View File
@@ -1833,7 +1833,8 @@ def index():
library_stats=format_library_stats(scan),
stale_courses=format_stale_courses(scan),
activity_heatmap=format_activity_heatmap(scan),
next_up=get_next_up_courses())
next_up=get_next_up_courses(),
active_tab='home')
# Apply progress data to tree
ProgressTracker.apply_progress_to_tree(current_course)
@@ -1854,7 +1855,8 @@ def index():
recent_views=recent_views,
has_note=bool(course_has_any_notes(current_course)),
is_queued=os.path.abspath(current_course.path) in get_next_up_paths(),
resume_lesson=resume_lesson)
resume_lesson=resume_lesson,
active_tab='home')
@app.route('/browse')
@@ -2252,6 +2254,7 @@ def settings_page():
density_choices=['comfortable', 'compact'],
card_style_choices=['flat', 'elevated', 'bordered'],
corner_radius_choices=['sharp', 'rounded', 'pill'],
active_tab='settings',
)
@@ -2383,13 +2386,13 @@ def open_recent():
@app.route('/help')
def help_page():
"""Static help page: how to use the app, supported file types."""
return render_template('help.html')
return render_template('help.html', active_tab='help')
@app.route('/notes')
def notes_hub():
"""Every lesson note across the whole library in one place."""
return render_template('notes_hub.html', notes=get_all_notes())
return render_template('notes_hub.html', notes=get_all_notes(), active_tab='notes')
@app.route('/library/search-transcripts')
+113 -78
View File
@@ -70,6 +70,7 @@
}
.page-content {
flex: 1;
padding-bottom: 70px;
}
.container {
@@ -144,45 +145,35 @@
word-break: break-word;
}
.app-footer {
.bottom-tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 9000;
display: flex;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
margin-top: 40px;
padding: 18px 0;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.app-footer .container {
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 15px;
flex-wrap: wrap;
}
.footer-brand {
color: var(--text-muted);
font-size: 0.9em;
display: flex;
align-items: center;
gap: 8px;
}
.footer-links {
display: flex;
gap: 20px;
}
.footer-links a {
gap: 2px;
padding: 8px 4px;
color: var(--text-muted);
text-decoration: none;
font-size: 0.9em;
display: flex;
align-items: center;
gap: 6px;
font-size: 0.72em;
transition: color 0.2s;
}
.footer-links a:hover {
.tab-item .tab-icon {
font-size: 1.3em;
}
.tab-item:hover,
.tab-item.active {
color: var(--accent);
}
@@ -735,6 +726,17 @@
object-fit: cover;
display: block;
}
.grid-card-initial {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.6em;
font-weight: 700;
font-family: var(--font-family);
}
.grid-card-name {
font-size: 0.9em;
font-weight: 500;
@@ -1079,7 +1081,44 @@
<div class="container">
<a class="btn surprise-me-btn" href="/random-pick">🎲 Surprise Me</a>
</div>
{% endif %}
<div class="container">
<div class="card" id="library-card">
<h2>Your Courses</h2>
<div class="library-toolbar">
<input type="text" id="library-search-input" class="library-search-input"
placeholder="Search courses…" oninput="handleLibrarySearchInput(this.value)">
<select id="library-sort" class="library-sort-select" onchange="reapplySort()">
<option value="name-asc">Name (A→Z)</option>
<option value="name-desc">Name (Z→A)</option>
</select>
<div class="library-view-toggle">
<button type="button" class="view-toggle-btn" id="library-view-list-btn"
onclick="setLibraryViewMode('list')" title="List view"></button>
<button type="button" class="view-toggle-btn" id="library-view-grid-btn"
onclick="setLibraryViewMode('grid')" title="Grid view"></button>
</div>
<button type="button" class="view-toggle-btn" id="library-select-btn"
onclick="toggleSelectionMode()" title="Select multiple"></button>
</div>
<label class="transcript-search-toggle">
<input type="checkbox" id="search-transcripts-toggle" onchange="handleLibrarySearchInput(document.getElementById('library-search-input').value)">
Also search transcripts
</label>
<div id="bulk-action-bar" class="bulk-action-bar" style="display: none;">
<span id="bulk-selected-count" class="bulk-selected-count"></span>
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkHideSelected()">🚫 Hide Selected</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkQueueSelected()">📌 Add to Next Up</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleSelectionMode()">Cancel</button>
</div>
<p id="library-path-bar" class="library-breadcrumb"></p>
<div id="library-groups" style="margin-top: 15px;"></div>
<div id="transcript-results"></div>
</div>
</div>
{% if library_stats and library_stats.total_courses %}
<div class="container">
<div class="card">
<h2 class="collapsible-header" data-card-id="library-stats">📊 Library Stats</h2>
@@ -1196,55 +1235,23 @@
</div>
</div>
{% endif %}
<div class="container">
<div class="card" id="library-card">
<h2>Your Courses</h2>
<div class="library-toolbar">
<input type="text" id="library-search-input" class="library-search-input"
placeholder="Search courses…" oninput="handleLibrarySearchInput(this.value)">
<select id="library-sort" class="library-sort-select" onchange="reapplySort()">
<option value="name-asc">Name (A→Z)</option>
<option value="name-desc">Name (Z→A)</option>
</select>
<div class="library-view-toggle">
<button type="button" class="view-toggle-btn" id="library-view-list-btn"
onclick="setLibraryViewMode('list')" title="List view"></button>
<button type="button" class="view-toggle-btn" id="library-view-grid-btn"
onclick="setLibraryViewMode('grid')" title="Grid view"></button>
</div>
<button type="button" class="view-toggle-btn" id="library-select-btn"
onclick="toggleSelectionMode()" title="Select multiple"></button>
</div>
<label class="transcript-search-toggle">
<input type="checkbox" id="search-transcripts-toggle" onchange="handleLibrarySearchInput(document.getElementById('library-search-input').value)">
Also search transcripts
</label>
<div id="bulk-action-bar" class="bulk-action-bar" style="display: none;">
<span id="bulk-selected-count" class="bulk-selected-count"></span>
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkHideSelected()">🚫 Hide Selected</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkQueueSelected()">📌 Add to Next Up</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleSelectionMode()">Cancel</button>
</div>
<p id="library-path-bar" class="library-breadcrumb"></p>
<div id="library-groups" style="margin-top: 15px;"></div>
<div id="transcript-results"></div>
</div>
</div>
{% endif %}
</div>
<footer class="app-footer">
<div class="container">
<div class="footer-brand">
📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
</div>
<div class="footer-links">
<a href="/notes">📝 Notes</a>
<a href="/help">❓ Help</a>
<a href="/settings">⚙ Settings</a>
</div>
</div>
</footer>
<nav class="bottom-tab-bar">
<a href="/reset_course" class="tab-item {% if active_tab == 'home' %}active{% endif %}">
<span class="tab-icon">🏠</span><span class="tab-label">Home</span>
</a>
<a href="/notes" class="tab-item {% if active_tab == 'notes' %}active{% endif %}">
<span class="tab-icon">📝</span><span class="tab-label">Notes</span>
</a>
<a href="/help" class="tab-item {% if active_tab == 'help' %}active{% endif %}">
<span class="tab-icon"></span><span class="tab-label">Help</span>
</a>
<a href="/settings" class="tab-item {% if active_tab == 'settings' %}active{% endif %}">
<span class="tab-icon"></span><span class="tab-label">Settings</span>
</a>
</nav>
<script>
function toggleTree(element) {
@@ -1423,6 +1430,33 @@
return span;
}
// Deterministic color+initial per course name (same idea as
// Slack/Google Photos default avatars) - almost none of this
// library's courses have real cover art, so without this every
// grid card without a thumbnail looked identical, defeating the
// point of browsing by appearance.
function courseColorHue(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return Math.abs(hash) % 360;
}
function courseInitial(name) {
const match = name.match(/[a-zA-Z0-9]/);
return match ? match[0].toUpperCase() : '?';
}
function courseInitialHtml(name) {
return `<div class="grid-card-initial" style="background: hsl(${courseColorHue(name)}, 40%, 32%);">${courseInitial(name)}</div>`;
}
function gridFallbackInitial(name) {
const div = document.createElement('div');
div.className = 'grid-card-initial';
div.style.background = `hsl(${courseColorHue(name)}, 40%, 32%)`;
div.textContent = courseInitial(name);
return div;
}
// For embedding a name in an HTML attribute (e.g. title="...") -
// grid-card names are clamped to 2 lines and clipped, so the title
// attribute is what lets a hover reveal the full name.
@@ -1526,9 +1560,10 @@
function courseCardHtml(item) {
const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'");
const iconHtml = item.has_thumbnail
? `<img class="grid-card-thumb" src="/library/thumbnail?path=${encodeURIComponent(item.path)}" alt="" onerror="this.replaceWith(gridFallbackIcon('🎓'))">`
: `<span>🎓</span>`;
? `<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}')`;
return `
<div class="library-grid-card" onclick="${clickHandler}">
+39 -30
View File
@@ -55,6 +55,7 @@
.page-content {
flex: 1;
padding: 20px;
padding-bottom: 70px;
}
.app-header {
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
@@ -77,31 +78,36 @@
font-weight: 600;
letter-spacing: 0.5px;
}
.app-footer {
.bottom-tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 9000;
display: flex;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
padding: 18px 0;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.app-footer .footer-inner {
max-width: 800px;
margin: 0 auto;
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 15px;
flex-wrap: wrap;
}
.footer-brand {
gap: 2px;
padding: 8px 4px;
color: var(--text-muted);
font-size: 0.9em;
display: flex;
align-items: center;
gap: 8px;
}
.footer-links a {
color: var(--accent);
text-decoration: none;
font-size: 0.9em;
font-size: 0.72em;
transition: color 0.2s;
}
.tab-item .tab-icon {
font-size: 1.3em;
}
.tab-item:hover,
.tab-item.active {
color: var(--accent);
}
.container {
max-width: 800px;
@@ -171,7 +177,7 @@
<li>Every note is stamped with the video's timestamp when you save it - click a timestamp to jump straight to that moment</li>
<li>Press <strong>N</strong> anywhere on a lesson page to capture one without reaching for the mouse</li>
<li>Edit or delete any note from the floating panel at the bottom of the lesson page</li>
<li><strong>Notes</strong> (in the footer) collects every note across your whole library in one searchable list</li>
<li><strong>Notes</strong> (in the bottom bar) collects every note across your whole library in one searchable list</li>
<li><strong>Download Study Guide</strong> on a loaded course exports every note as one markdown file, organized by section</li>
<li>If you use <a href="/settings">Outline</a>, pick a topic on a lesson's notes to push them there automatically when you navigate away</li>
</ul>
@@ -211,17 +217,20 @@
</div>
</div>
<footer class="app-footer">
<div class="footer-inner">
<div class="footer-brand">
📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
</div>
<div class="footer-links">
<a href="/notes">📝 Notes</a>
<a href="/">← Back to app</a>
</div>
</div>
</footer>
<nav class="bottom-tab-bar">
<a href="/reset_course" class="tab-item {% if active_tab == 'home' %}active{% endif %}">
<span class="tab-icon">🏠</span><span class="tab-label">Home</span>
</a>
<a href="/notes" class="tab-item {% if active_tab == 'notes' %}active{% endif %}">
<span class="tab-icon">📝</span><span class="tab-label">Notes</span>
</a>
<a href="/help" class="tab-item {% if active_tab == 'help' %}active{% endif %}">
<span class="tab-icon"></span><span class="tab-label">Help</span>
</a>
<a href="/settings" class="tab-item {% if active_tab == 'settings' %}active{% endif %}">
<span class="tab-icon"></span><span class="tab-label">Settings</span>
</a>
</nav>
<script src="/static/theme.js"></script>
</body>
+38 -29
View File
@@ -57,6 +57,7 @@
.page-content {
flex: 1;
padding: 20px;
padding-bottom: 70px;
}
.app-header {
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
@@ -79,32 +80,36 @@
font-weight: 600;
letter-spacing: 0.5px;
}
.app-footer {
.bottom-tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 9000;
display: flex;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
padding: 18px 0;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.app-footer .footer-inner {
max-width: 800px;
margin: 0 auto;
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 15px;
flex-wrap: wrap;
}
.footer-brand {
gap: 2px;
padding: 8px 4px;
color: var(--text-muted);
font-size: 0.9em;
display: flex;
align-items: center;
gap: 8px;
}
.footer-links { display: flex; gap: 20px; }
.footer-links a {
color: var(--accent);
text-decoration: none;
font-size: 0.9em;
font-size: 0.72em;
transition: color 0.2s;
}
.tab-item .tab-icon {
font-size: 1.3em;
}
.tab-item:hover,
.tab-item.active {
color: var(--accent);
}
.container {
max-width: 800px;
@@ -255,16 +260,20 @@
</div>
</div>
<footer class="app-footer">
<div class="footer-inner">
<div class="footer-brand">
📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
</div>
<div class="footer-links">
<a href="/">← Back to app</a>
</div>
</div>
</footer>
<nav class="bottom-tab-bar">
<a href="/reset_course" class="tab-item {% if active_tab == 'home' %}active{% endif %}">
<span class="tab-icon">🏠</span><span class="tab-label">Home</span>
</a>
<a href="/notes" class="tab-item {% if active_tab == 'notes' %}active{% endif %}">
<span class="tab-icon">📝</span><span class="tab-label">Notes</span>
</a>
<a href="/help" class="tab-item {% if active_tab == 'help' %}active{% endif %}">
<span class="tab-icon"></span><span class="tab-label">Help</span>
</a>
<a href="/settings" class="tab-item {% if active_tab == 'settings' %}active{% endif %}">
<span class="tab-icon"></span><span class="tab-label">Settings</span>
</a>
</nav>
<script>
function filterNotes(query) {
+38 -34
View File
@@ -55,6 +55,7 @@
.page-content {
flex: 1;
padding: 20px;
padding-bottom: 70px;
}
.app-header {
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
@@ -79,35 +80,36 @@
font-weight: 600;
letter-spacing: 0.5px;
}
.app-footer {
.bottom-tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 9000;
display: flex;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
padding: 18px 0;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.app-footer .footer-inner {
max-width: 800px;
margin: 0 auto;
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 15px;
flex-wrap: wrap;
}
.footer-brand {
gap: 2px;
padding: 8px 4px;
color: var(--text-muted);
font-size: 0.9em;
display: flex;
align-items: center;
gap: 8px;
}
.footer-links {
display: flex;
gap: 20px;
}
.footer-links a {
color: var(--accent);
text-decoration: none;
font-size: 0.9em;
font-size: 0.72em;
transition: color 0.2s;
}
.tab-item .tab-icon {
font-size: 1.3em;
}
.tab-item:hover,
.tab-item.active {
color: var(--accent);
}
.container {
max-width: 800px;
@@ -565,18 +567,20 @@
</div>
</div>
<footer class="app-footer">
<div class="footer-inner">
<div class="footer-brand">
📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
</div>
<div class="footer-links">
<a href="/notes">📝 Notes</a>
<a href="/help">❓ Help</a>
<a href="/">← Back to app</a>
</div>
</div>
</footer>
<nav class="bottom-tab-bar">
<a href="/reset_course" class="tab-item {% if active_tab == 'home' %}active{% endif %}">
<span class="tab-icon">🏠</span><span class="tab-label">Home</span>
</a>
<a href="/notes" class="tab-item {% if active_tab == 'notes' %}active{% endif %}">
<span class="tab-icon">📝</span><span class="tab-label">Notes</span>
</a>
<a href="/help" class="tab-item {% if active_tab == 'help' %}active{% endif %}">
<span class="tab-icon"></span><span class="tab-label">Help</span>
</a>
<a href="/settings" class="tab-item {% if active_tab == 'settings' %}active{% endif %}">
<span class="tab-icon"></span><span class="tab-label">Settings</span>
</a>
</nav>
<script src="/static/theme.js"></script>
<script>