Add Favorites, storage drill-down, keyboard shortcuts, and What's New

- Favorites: star toggle on course rows, the course page, and a new
  dashboard card; rebased on rename/move, backed up/restored, and
  caught by stale-reference cleanup (also fixed a pre-existing gap
  where favorites.json wasn't in the backup file list).
- Storage Usage: click a folder row to drill into its contents one
  level at a time, with a Back button.
- Lesson player: added ,/. (speed step), [/] (prev/next lesson), and
  F (fullscreen) keyboard shortcuts.
- Settings: a CHANGELOG.md-backed "What's New" list, since VERSION
  alone only ever shows the current build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 19:23:40 -04:00
co-authored by Claude Sonnet 5
parent 1fbf0c9312
commit fc83876abe
10 changed files with 404 additions and 39 deletions
+101
View File
@@ -728,6 +728,13 @@
color: white;
border-color: var(--accent);
}
.favorite-toggle-btn.active {
color: #e0a825;
border-color: #e0a825;
}
.favorite-toggle-btn.active svg {
fill: currentColor;
}
#library-select-btn {
border-radius: var(--radius);
margin-left: 4px;
@@ -1044,6 +1051,9 @@
<button class="btn btn-secondary btn-sm" id="queue-toggle-btn" onclick="toggleQueued()">
{{ icons.icon('pin', 14) }} {{ 'Remove from Next Up' if is_queued else 'Add to Next Up' }}
</button>
<button class="btn btn-secondary btn-sm{{ ' favorite-toggle-btn active' if is_favorited else ' favorite-toggle-btn' }}" id="favorite-toggle-btn" onclick="toggleFavorited()">
{{ icons.icon('star', 14) }} {{ 'Remove from Favorites' if is_favorited else 'Add to Favorites' }}
</button>
{% if has_note %}
<a class="btn btn-secondary btn-sm" href="/course/study-guide">{{ icons.icon('file-text', 14) }} Download Study Guide</a>
{% endif %}
@@ -1249,6 +1259,31 @@
</div>
</div>
{% endif %}
{% if favorites %}
<div class="container">
<div class="card" id="favorites-card">
<h2 class="collapsible-header" data-card-id="favorites"><span class="card-header-label">{{ icons.icon('star') }}Favorites</span></h2>
<div class="card-body" id="favorites-list">
{% for item in favorites %}
<div class="lesson-item" data-path="{{ item.path }}">
<div class="lesson-title" style="cursor: pointer;" onclick="loadCoursePath('{{ item.path|replace("'", "\\'") }}')">
{% if item.has_thumbnail %}
<img class="lesson-thumb" src="/library/thumbnail?path={{ item.path | urlencode }}" alt=""
onerror="this.replaceWith(courseFallbackIcon('graduation-cap'))">
{% else %}
<span class="lesson-icon">{{ icons.icon('graduation-cap', 16) }}</span>
{% endif %}
<span class="lesson-name">{{ item.name }}</span>
</div>
<div class="lesson-meta">
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); removeFavorite(this)" title="Remove from Favorites">{{ icons.icon('x', 14) }}</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if next_up %}
<div class="container">
<div class="card" id="next-up-card">
@@ -1346,6 +1381,8 @@
play: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>',
'graduation-cap': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path></svg>',
pin: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21Z"></path><circle cx="12" cy="9.5" r="2.3"></circle></svg>',
star: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>',
'star-filled': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>',
folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg>',
};
@@ -1708,6 +1745,7 @@
const progressBar = pct
? `<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: ${pct}%;"></div></div>`
: '';
const favorited = !!item.favorited;
return `
<div class="lesson-item" onclick="${clickHandler}">
<div class="lesson-title">
@@ -1718,6 +1756,7 @@
<div class="lesson-meta">
${progressBadge}
<span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}${item.duration_display ? ` · ${item.duration_display}` : ''}</span>
<button class="btn btn-secondary btn-sm favorite-toggle-btn${favorited ? ' active' : ''}" data-path="${escapeAttr(item.path)}" onclick="event.stopPropagation(); favoriteCourse('${safePath}', this)" title="${favorited ? 'Remove from Favorites' : 'Add to Favorites'}">${favorited ? ICON_SVGS['star-filled'] : ICON_SVGS.star}</button>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">${ICON_SVGS.pin}</button>
</div>
${progressBar}
@@ -1889,6 +1928,50 @@
saveNextUpOrder();
}
function removeFavorite(btnEl) {
const row = btnEl.closest('.lesson-item');
const path = row.dataset.path;
fetch('/api/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, favorited: false })
})
.then(r => r.json())
.then(data => {
if (data.success) {
row.remove();
const list = document.getElementById('favorites-list');
if (list && list.children.length === 0) {
document.getElementById('favorites-card').closest('.container').remove();
}
document.querySelectorAll(`.favorite-toggle-btn[data-path="${CSS.escape(path)}"]`).forEach(btn => {
btn.classList.remove('active');
btn.innerHTML = ICON_SVGS.star;
btn.title = 'Add to Favorites';
});
}
})
.catch(() => {});
}
function favoriteCourse(path, btnEl) {
const nowFavorited = !btnEl.classList.contains('active');
fetch('/api/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, favorited: nowFavorited })
})
.then(r => r.json())
.then(data => {
if (data.success) {
btnEl.classList.toggle('active', nowFavorited);
btnEl.innerHTML = nowFavorited ? ICON_SVGS['star-filled'] : ICON_SVGS.star;
btnEl.title = nowFavorited ? 'Remove from Favorites' : 'Add to Favorites';
}
})
.catch(() => {});
}
function removeNextUp(btnEl) {
const row = btnEl.closest('.lesson-item');
const path = row.dataset.path;
@@ -1940,6 +2023,24 @@
})
.catch(() => {});
}
function toggleFavorited() {
const btn = document.getElementById('favorite-toggle-btn');
const currentlyFavorited = btn.classList.contains('active');
fetch('/api/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: {{ course.path|tojson }}, favorited: !currentlyFavorited })
})
.then(r => r.json())
.then(data => {
if (data.success) {
btn.classList.toggle('active', !currentlyFavorited);
btn.innerHTML = ICON_SVGS.star + ' ' + (currentlyFavorited ? 'Add to Favorites' : 'Remove from Favorites');
}
})
.catch(() => {});
}
{% endif %}
// ---- Collapsible dashboard cards (client-only, remembered per device) ----