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
+35 -5
View File
@@ -325,6 +325,12 @@
.storage-row:first-of-type {
border-top: none;
}
.storage-row-clickable {
cursor: pointer;
}
.storage-row-clickable:hover {
background: var(--bg-tertiary);
}
.storage-row-name {
flex: 0 0 160px;
font-weight: 600;
@@ -662,6 +668,7 @@
<button class="btn btn-secondary" id="storage-scan-btn" onclick="scanStorageUsage()">{{ icons.icon('refresh', 14) }} Scan Disk Usage</button>
<span id="storage-status"></span>
</div>
<div id="storage-breadcrumb"></div>
<div id="storage-results"></div>
</div>
@@ -1675,15 +1682,20 @@
}
// ---- Storage Usage ----
function scanStorageUsage() {
let storagePathStack = [];
function scanStorageUsage(path) {
const btn = document.getElementById('storage-scan-btn');
const status = document.getElementById('storage-status');
const results = document.getElementById('storage-results');
const breadcrumb = document.getElementById('storage-breadcrumb');
if (!path) storagePathStack = [];
btn.disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = 'Scanning… this reads every file, may take a moment.';
results.innerHTML = '';
fetch('/api/library/storage-usage')
const url = path ? `/api/library/storage-usage?path=${encodeURIComponent(path)}` : '/api/library/storage-usage';
fetch(url)
.then(r => r.json())
.then(data => {
btn.disabled = false;
@@ -1692,6 +1704,9 @@
status.textContent = data.error;
return;
}
breadcrumb.innerHTML = !data.is_root
? `<button class="btn btn-secondary btn-sm" onclick="storageGoBack()">${'←'} Back</button> <span style="color: var(--text-muted); font-size: 0.85em;">${escapeHtml(data.path)}</span>`
: '';
const entries = data.entries || [];
if (!entries.length) {
status.style.color = 'var(--text-muted)';
@@ -1702,7 +1717,7 @@
status.textContent = `Total: ${data.total_human}`;
const maxBytes = Math.max(...entries.map(e => e.bytes), 1);
results.innerHTML = entries.map(e => `
<div class="storage-row">
<div class="storage-row${e.is_dir ? ' storage-row-clickable' : ''}" ${e.is_dir ? `onclick="storageDrillDown('${e.path.replace(/'/g, "\\'")}')"` : ''}>
<span class="storage-row-name">${escapeHtml(e.name)}</span>
<div class="storage-bar-track"><div class="storage-bar-fill" style="width: ${Math.round((e.bytes / maxBytes) * 100)}%;"></div></div>
<span class="storage-row-size">${escapeHtml(e.human)}</span>
@@ -1716,6 +1731,21 @@
});
}
function storageDrillDown(path) {
const status = document.getElementById('storage-status');
const current = status.dataset.currentPath || '';
storagePathStack.push(current);
status.dataset.currentPath = path;
scanStorageUsage(path);
}
function storageGoBack() {
const status = document.getElementById('storage-status');
const prev = storagePathStack.pop() || '';
status.dataset.currentPath = prev;
scanStorageUsage(prev || undefined);
}
// ---- Duplicate Courses ----
function scanDuplicates() {
const btn = document.getElementById('dup-scan-btn');
@@ -1918,9 +1948,9 @@
.then(r => r.json())
.then(data => {
btn.disabled = false;
const categoryLabels = { hidden: 'Hidden', next_up: 'Next Up', recent_views: 'Recently Viewed' };
const categoryLabels = { hidden: 'Hidden', next_up: 'Next Up', recent_views: 'Recently Viewed', favorites: 'Favorite' };
const items = [];
for (const category of ['hidden', 'next_up', 'recent_views']) {
for (const category of ['hidden', 'next_up', 'recent_views', 'favorites']) {
for (const entry of (data[category] || [])) {
items.push({ path: entry.path, name: entry.name, category, label: categoryLabels[category] });
}