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:
@@ -5,4 +5,4 @@
|
||||
platforms and clash with the app's vector logo. Import with
|
||||
{% import '_icons.html' as icons %} and call icons.icon('name', size).
|
||||
#}
|
||||
{% macro icon(name, size=18) %}<svg class="icon" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{% if name == 'home' %}<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"></path>{% elif name == 'pencil' %}<path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>{% elif name == 'help' %}<circle cx="12" cy="12" r="9"></circle><path d="M9.1 9a2.9 2.9 0 0 1 5.6 1c0 2-2.9 2.5-2.9 4.5"></path><line x1="12" y1="17.5" x2="12" y2="17.51"></line>{% elif name == 'settings' %}<circle cx="12" cy="12" r="3"></circle><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"></path>{% elif name == 'dice' %}<rect x="3" y="3" width="18" height="18" rx="4"></rect><circle cx="8" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="8" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"></circle>{% elif name == 'folder' %}<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>{% elif name == 'graduation-cap' %}<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>{% elif name == 'check' %}<polyline points="20 6 9 17 4 12"></polyline>{% elif name == 'circle' %}<circle cx="12" cy="12" r="9"></circle>{% elif name == 'x' %}<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>{% elif name == 'pin' %}<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>{% elif name == 'ban' %}<circle cx="12" cy="12" r="9"></circle><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"></line>{% elif name == 'video' %}<rect x="2" y="5" width="14" height="14" rx="2"></rect><path d="M16 10.5 22 7v10l-6-3.5Z"></path>{% elif name == 'music' %}<path d="M9 18V5l11-2v13"></path><circle cx="6.5" cy="18" r="2.5"></circle><circle cx="17.5" cy="16" r="2.5"></circle>{% elif name == 'clipboard' %}<rect x="6" y="4" width="12" height="17" rx="2"></rect><path d="M9 4V3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"></path><line x1="9" y1="11" x2="15" y2="11"></line><line x1="9" y1="15" x2="13" y2="15"></line>{% elif name == 'package' %}<path d="M21 8 12 3 3 8v8l9 5 9-5Z"></path><path d="M3 8l9 5 9-5M12 13v8"></path>{% elif name == 'file-text' %}<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"></path><path d="M14 2v6h6"></path><line x1="9" y1="13" x2="15" y2="13"></line><line x1="9" y1="17" x2="15" y2="17"></line>{% elif name == 'trash' %}<path d="M4 7h16"></path><path d="M10 11v6M14 11v6"></path><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"></path><path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"></path>{% elif name == 'refresh' %}<path d="M21 12a9 9 0 0 1-15.3 6.4"></path><path d="M3 12a9 9 0 0 1 15.3-6.4"></path><polyline points="21 3 21 9 15 9"></polyline><polyline points="3 21 3 15 9 15"></polyline>{% elif name == 'checkbox' %}<path d="M9 11l3 3 7-7"></path><path d="M21 12v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h10"></path>{% elif name == 'list' %}<line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line>{% elif name == 'grid' %}<rect x="3" y="3" width="8" height="8" rx="1.5"></rect><rect x="13" y="3" width="8" height="8" rx="1.5"></rect><rect x="3" y="13" width="8" height="8" rx="1.5"></rect><rect x="13" y="13" width="8" height="8" rx="1.5"></rect>{% elif name == 'bar-chart' %}<line x1="6" y1="20" x2="6" y2="14"></line><line x1="12" y1="20" x2="12" y2="8"></line><line x1="18" y1="20" x2="18" y2="11"></line>{% elif name == 'list-dots' %}<line x1="8" y1="6" x2="20" y2="6"></line><line x1="8" y1="12" x2="20" y2="12"></line><line x1="8" y1="18" x2="20" y2="18"></line><circle cx="4" cy="6" r="1"></circle><circle cx="4" cy="12" r="1"></circle><circle cx="4" cy="18" r="1"></circle>{% elif name == 'history' %}<path d="M3 12a9 9 0 1 0 3-6.7"></path><polyline points="3 4 3 9 8 9"></polyline>{% elif name == 'plus-circle' %}<circle cx="12" cy="12" r="9"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line>{% elif name == 'play-circle' %}<circle cx="12" cy="12" r="9"></circle><polygon points="10 8 16 12 10 16 10 8"></polygon>{% elif name == 'play' %}<polygon points="6 3 20 12 6 21 6 3"></polygon>{% endif %}</svg>{% endmacro %}
|
||||
{% macro icon(name, size=18) %}<svg class="icon" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{% if name == 'home' %}<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"></path>{% elif name == 'pencil' %}<path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>{% elif name == 'help' %}<circle cx="12" cy="12" r="9"></circle><path d="M9.1 9a2.9 2.9 0 0 1 5.6 1c0 2-2.9 2.5-2.9 4.5"></path><line x1="12" y1="17.5" x2="12" y2="17.51"></line>{% elif name == 'settings' %}<circle cx="12" cy="12" r="3"></circle><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"></path>{% elif name == 'dice' %}<rect x="3" y="3" width="18" height="18" rx="4"></rect><circle cx="8" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="8" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"></circle>{% elif name == 'folder' %}<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>{% elif name == 'graduation-cap' %}<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>{% elif name == 'check' %}<polyline points="20 6 9 17 4 12"></polyline>{% elif name == 'circle' %}<circle cx="12" cy="12" r="9"></circle>{% elif name == 'x' %}<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>{% elif name == 'pin' %}<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>{% elif name == 'ban' %}<circle cx="12" cy="12" r="9"></circle><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"></line>{% elif name == 'video' %}<rect x="2" y="5" width="14" height="14" rx="2"></rect><path d="M16 10.5 22 7v10l-6-3.5Z"></path>{% elif name == 'music' %}<path d="M9 18V5l11-2v13"></path><circle cx="6.5" cy="18" r="2.5"></circle><circle cx="17.5" cy="16" r="2.5"></circle>{% elif name == 'clipboard' %}<rect x="6" y="4" width="12" height="17" rx="2"></rect><path d="M9 4V3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"></path><line x1="9" y1="11" x2="15" y2="11"></line><line x1="9" y1="15" x2="13" y2="15"></line>{% elif name == 'package' %}<path d="M21 8 12 3 3 8v8l9 5 9-5Z"></path><path d="M3 8l9 5 9-5M12 13v8"></path>{% elif name == 'file-text' %}<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"></path><path d="M14 2v6h6"></path><line x1="9" y1="13" x2="15" y2="13"></line><line x1="9" y1="17" x2="15" y2="17"></line>{% elif name == 'trash' %}<path d="M4 7h16"></path><path d="M10 11v6M14 11v6"></path><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"></path><path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"></path>{% elif name == 'refresh' %}<path d="M21 12a9 9 0 0 1-15.3 6.4"></path><path d="M3 12a9 9 0 0 1 15.3-6.4"></path><polyline points="21 3 21 9 15 9"></polyline><polyline points="3 21 3 15 9 15"></polyline>{% elif name == 'checkbox' %}<path d="M9 11l3 3 7-7"></path><path d="M21 12v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h10"></path>{% elif name == 'list' %}<line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line>{% elif name == 'grid' %}<rect x="3" y="3" width="8" height="8" rx="1.5"></rect><rect x="13" y="3" width="8" height="8" rx="1.5"></rect><rect x="3" y="13" width="8" height="8" rx="1.5"></rect><rect x="13" y="13" width="8" height="8" rx="1.5"></rect>{% elif name == 'bar-chart' %}<line x1="6" y1="20" x2="6" y2="14"></line><line x1="12" y1="20" x2="12" y2="8"></line><line x1="18" y1="20" x2="18" y2="11"></line>{% elif name == 'list-dots' %}<line x1="8" y1="6" x2="20" y2="6"></line><line x1="8" y1="12" x2="20" y2="12"></line><line x1="8" y1="18" x2="20" y2="18"></line><circle cx="4" cy="6" r="1"></circle><circle cx="4" cy="12" r="1"></circle><circle cx="4" cy="18" r="1"></circle>{% elif name == 'history' %}<path d="M3 12a9 9 0 1 0 3-6.7"></path><polyline points="3 4 3 9 8 9"></polyline>{% elif name == 'plus-circle' %}<circle cx="12" cy="12" r="9"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line>{% elif name == 'play-circle' %}<circle cx="12" cy="12" r="9"></circle><polygon points="10 8 16 12 10 16 10 8"></polygon>{% elif name == 'play' %}<polygon points="6 3 20 12 6 21 6 3"></polygon>{% elif name == 'star' %}<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>{% endif %}</svg>{% endmacro %}
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
@@ -173,6 +173,9 @@
|
||||
<li><strong>Space</strong> - play/pause</li>
|
||||
<li><strong>← / →</strong> - seek back/forward 10 seconds</li>
|
||||
<li><strong>↑ / ↓</strong> - volume up/down</li>
|
||||
<li><strong>, / .</strong> - step playback speed down/up</li>
|
||||
<li><strong>[ / ]</strong> - previous/next lesson</li>
|
||||
<li><strong>F</strong> - toggle fullscreen</li>
|
||||
<li><strong>N</strong> - jump into the quick-capture note box (pauses the video)</li>
|
||||
<li>Drag the video's bottom-right corner to resize it, and pick a default playback speed in Settings - both are remembered</li>
|
||||
<li>Reopening a lesson resumes from where you left off, and the <strong>▶ Resume</strong> button at the top of a loaded course jumps straight back to the last lesson you had open</li>
|
||||
|
||||
@@ -1291,6 +1291,18 @@
|
||||
return tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT' || el.isContentEditable;
|
||||
}
|
||||
|
||||
const PLAYBACK_SPEEDS = ['0.75', '1', '1.25', '1.5', '1.75', '2'];
|
||||
|
||||
function stepPlaybackSpeed(direction) {
|
||||
if (!activeMedia) return;
|
||||
let idx = PLAYBACK_SPEEDS.findIndex(s => parseFloat(s) === activeMedia.playbackRate);
|
||||
if (idx === -1) idx = PLAYBACK_SPEEDS.indexOf('1');
|
||||
idx = Math.max(0, Math.min(PLAYBACK_SPEEDS.length - 1, idx + direction));
|
||||
const speed = PLAYBACK_SPEEDS[idx];
|
||||
setPlaybackSpeed(speed);
|
||||
showNotification(`Speed: ${speed}x`);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (isTypingTarget(e.target)) return;
|
||||
if ((e.key === 'n' || e.key === 'N') && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
@@ -1298,6 +1310,33 @@
|
||||
openQuickCapture();
|
||||
return;
|
||||
}
|
||||
if (!e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
switch(e.key) {
|
||||
case '[':
|
||||
{% if prev_lesson %}
|
||||
e.preventDefault();
|
||||
window.location.href = '/lesson/{{ prev_lesson }}';
|
||||
{% endif %}
|
||||
break;
|
||||
case ']':
|
||||
{% if next_lesson %}
|
||||
e.preventDefault();
|
||||
window.location.href = '/lesson/{{ next_lesson }}';
|
||||
{% endif %}
|
||||
break;
|
||||
case 'f':
|
||||
case 'F':
|
||||
if (video) {
|
||||
e.preventDefault();
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
video.requestFullscreen();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (activeMedia && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
switch(e.key) {
|
||||
case ' ':
|
||||
@@ -1326,6 +1365,14 @@
|
||||
activeMedia.volume = Math.max(0, activeMedia.volume - 0.1);
|
||||
showNotification(`Volume: ${Math.round(activeMedia.volume * 100)}%`);
|
||||
break;
|
||||
case ',':
|
||||
e.preventDefault();
|
||||
stepPlaybackSpeed(-1);
|
||||
break;
|
||||
case '.':
|
||||
e.preventDefault();
|
||||
stepPlaybackSpeed(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -470,6 +470,17 @@
|
||||
<p style="text-align: center; color: var(--text-muted); font-size: 0.8em; margin-top: 30px;">
|
||||
Version {{ build_version }}
|
||||
</p>
|
||||
|
||||
{% if changelog_entries %}
|
||||
<details style="max-width: 500px; margin: 10px auto 0; color: var(--text-muted); font-size: 0.8em;">
|
||||
<summary style="text-align: center; cursor: pointer;">What's New</summary>
|
||||
<ul style="margin: 8px 0 0; padding-left: 20px;">
|
||||
{% for entry in changelog_entries %}
|
||||
<li style="margin-bottom: 4px;">{{ entry }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+35
-5
@@ -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] });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user