Detect course roots per branch and add a category layer above them
The scanner no longer assumes courses sit at a fixed depth. It walks each branch and marks a folder is_course when it holds media directly or its children read as chapters (01_, 1. , Module 2, Week 3); everything above that is a category, and every folder still gets a course_id pointing at its nearest course for cheap rollups. An empty .course/.category marker file overrides the heuristic per folder. Also: - Roll up progress for the whole library in two grouped queries instead of one prefix-matched query per course/folder card. - Store folders/videos/quizzes paths as utf8mb4_bin so folders that differ only in case (CCNP Notes vs CCNP NOtes) aren't merged by a case-insensitive UNIQUE key. - migrateLibrarySchema() brings an older install's tables up to date at scan time, so there's no separate migration step. - Library UI gains a categories view alongside shelves/catalog; the continue-watching strip's play button now seeks to the saved position on open. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FfMPJK7TQV18JhFv6yTKev
This commit is contained in:
+166
-26
@@ -20,6 +20,7 @@
|
||||
csrf: null, // session token, echoed back on every POST
|
||||
meta: null, // totals / shelves / last scan, for the chrome
|
||||
suppressSave: false, // set while a reset lands, so teardown doesn't re-save
|
||||
playOnLoad: null, // video id a play button asked to start, not just open
|
||||
scanning: false,
|
||||
};
|
||||
let cleanupView = null;
|
||||
@@ -33,6 +34,8 @@
|
||||
|
||||
const SHELF_HUE = { progress: '#B85A2E', finished: '#3F7A50', fresh: '#374B5C', all: '#C2410C' };
|
||||
const SHELF_NAME = { progress: 'In progress', finished: 'Finished', fresh: 'Not started', all: 'All courses' };
|
||||
// What a folder is, for search results: the three now mean different things.
|
||||
const KIND_NAME = { category: 'Category', course: 'Course', section: 'Section' };
|
||||
|
||||
const ICONS = {
|
||||
home: '<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V21h14V9.5"></path>',
|
||||
@@ -127,7 +130,7 @@
|
||||
|
||||
/* ---------- preferences ---------- */
|
||||
|
||||
const PREF_DEFAULTS = { view: 'grid', layout: 'shelves', autoplay: true };
|
||||
const PREF_DEFAULTS = { view: 'grid', layout: 'categories', autoplay: true };
|
||||
|
||||
function pref(key) {
|
||||
try {
|
||||
@@ -352,6 +355,46 @@
|
||||
</a>`;
|
||||
}
|
||||
|
||||
// A category is a folder of courses. It gets the same card as a course so the
|
||||
// library reads as one grid, with the course count where a course shows its
|
||||
// videos, and no play affordance — there is nothing to play at this level.
|
||||
function categoryCard(c) {
|
||||
const hue = hueOf(c.id);
|
||||
return `<a class="course-card" href="#/folder/${c.id}">
|
||||
${thumb(hue, { cat: 'Category', dur: fmtLong(c.duration) })}
|
||||
<div class="course-card-body">
|
||||
<div class="course-title">${esc(c.name)}</div>
|
||||
<div class="course-meta">${c.courses} course${c.courses === 1 ? '' : 's'} · ${esc(courseMeta(c))}</div>
|
||||
<div class="bar-row">
|
||||
${bar(c.progress.percent, hue)}
|
||||
<span class="mono">${c.progress.percent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function categoryRow(c) {
|
||||
const hue = hueOf(c.id);
|
||||
return `<a class="row" href="#/folder/${c.id}">
|
||||
<span class="initial initial-sm" style="--c:${hue}">${esc(initialOf(c.name))}</span>
|
||||
<div class="row-fixed">
|
||||
<div class="row-title">${esc(c.name)}</div>
|
||||
<div class="row-sub">${esc(c.path || '')}</div>
|
||||
</div>
|
||||
<span class="row-col">${c.courses} course${c.courses === 1 ? '' : 's'}</span>
|
||||
<span class="row-num">${c.videos} vids</span>
|
||||
<span class="row-num">${esc(fmtLong(c.duration))}</span>
|
||||
<div class="row-bar">
|
||||
${bar(c.progress.percent, hue)}
|
||||
<span class="mono">${c.progress.percent}%</span>
|
||||
</div>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
const categoryList = (cats, mode) => mode === 'list'
|
||||
? `<div class="rows">${cats.map(categoryRow).join('')}</div>`
|
||||
: `<div class="grid">${cats.map(categoryCard).join('')}</div>`;
|
||||
|
||||
function courseRow(c) {
|
||||
const hue = hueOf(c.id);
|
||||
return `<div class="row">
|
||||
@@ -527,7 +570,8 @@
|
||||
const left = resume.duration ? Math.max(0, resume.duration - resume.position) : 0;
|
||||
const hue = hueOf(resume.course_id ?? resume.id);
|
||||
return `<div class="hero">
|
||||
<div class="hero-art">${thumb(hue, { play: true, percent: pct })}</div>
|
||||
<a class="hero-art" href="#/watch/${resume.id}" data-play="${resume.id}"
|
||||
aria-label="Resume ${esc(resume.name)}">${thumb(hue, { play: true, percent: pct })}</a>
|
||||
<div class="hero-body">
|
||||
<span class="hero-eyebrow">Continue watching</span>
|
||||
<div class="hero-title">${esc(resume.name)}</div>
|
||||
@@ -607,11 +651,38 @@
|
||||
|
||||
/* ---------- library ---------- */
|
||||
|
||||
/**
|
||||
* Split courses onto the three progress shelves. The API sends one flat list
|
||||
* and this partitions it — shipping the partition as well would put every
|
||||
* course in the payload twice.
|
||||
*/
|
||||
const shelveCourses = (courses) =>
|
||||
[['progress', 'In progress'], ['finished', 'Finished'], ['fresh', 'Not started']]
|
||||
.map(([key, name]) => ({ key, name, courses: courses.filter((c) => c.state === key) }))
|
||||
.filter((s) => s.courses.length);
|
||||
|
||||
/**
|
||||
* A shelf block: a heading, a count and the cards on it. Used by the library
|
||||
* and by a category page, which shelves its own courses the same way.
|
||||
*/
|
||||
const shelfBlock = (shelf, mode) => `
|
||||
<div style="margin-bottom:34px">
|
||||
<div class="shelf-head">
|
||||
<span class="swatch" style="--c:${SHELF_HUE[shelf.key]}"></span>
|
||||
<h2>${esc(shelf.name)}</h2>
|
||||
<span class="mono">${shelf.courses.length} course${shelf.courses.length === 1 ? '' : 's'}</span>
|
||||
<div class="rule"></div>
|
||||
</div>
|
||||
${courseList(shelf.courses, mode)}
|
||||
</div>`;
|
||||
|
||||
async function renderLibrary(filter) {
|
||||
if (filter && !(filter in SHELF_NAME)) filter = null; // #/library/whatever → every course
|
||||
spinner();
|
||||
const data = await load('api/library.php?view=library');
|
||||
|
||||
// A shelf link from the sidebar is always a flat list of matching courses;
|
||||
// otherwise the saved layout decides.
|
||||
const layout = filter && filter !== 'all' ? 'catalog' : pref('layout');
|
||||
const mode = pref('view');
|
||||
const active = filter || 'all';
|
||||
@@ -629,17 +700,19 @@
|
||||
<p>Add course folders to the videos directory, then scan.</p>
|
||||
<button class="btn btn-accent" data-scan>Scan library</button>
|
||||
</div>`;
|
||||
} else if (layout === 'shelves') {
|
||||
body = data.shelves.map((s) => `
|
||||
<div style="margin-bottom:34px">
|
||||
<div class="shelf-head">
|
||||
<span class="swatch" style="--c:${SHELF_HUE[s.key]}"></span>
|
||||
<h2>${esc(s.name)}</h2>
|
||||
<span class="mono">${s.courses.length} course${s.courses.length === 1 ? '' : 's'}</span>
|
||||
<div class="rule"></div>
|
||||
</div>
|
||||
${courseList(s.courses, mode)}
|
||||
</div>`).join('');
|
||||
} else if (layout === 'categories' && data.categories.length) {
|
||||
// The folders above the courses are the library's own shape — a few dozen
|
||||
// cards to browse instead of a wall of every course at once.
|
||||
body = categoryList(data.categories, mode)
|
||||
+ (data.top_courses.length ? `
|
||||
<div class="shelf-head" style="margin-top:34px">
|
||||
<h2>Courses</h2>
|
||||
<span class="mono">${data.top_courses.length}</span>
|
||||
<div class="rule"></div>
|
||||
</div>
|
||||
${courseList(data.top_courses, mode)}` : '');
|
||||
} else if (layout === 'shelves' || (layout === 'categories' && !data.categories.length)) {
|
||||
body = shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join('');
|
||||
} else {
|
||||
body = `<div class="chips">${chips}</div>` + (shown.length
|
||||
? courseList(shown, mode)
|
||||
@@ -649,19 +722,27 @@
|
||||
}
|
||||
|
||||
const totalVideos = data.courses.reduce((n, c) => n + c.videos, 0);
|
||||
const sub = [
|
||||
`${data.courses.length} course${data.courses.length === 1 ? '' : 's'}`,
|
||||
data.totals.categories ? `${data.totals.categories} categor${data.totals.categories === 1 ? 'y' : 'ies'}` : '',
|
||||
`${totalVideos} video${totalVideos === 1 ? '' : 's'} on this machine`,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
const layoutBtn = (key, label) =>
|
||||
`<button class="${layout === key ? 'is-on' : ''}" data-layout="${key}">${label}</button>`;
|
||||
|
||||
view.innerHTML = `
|
||||
<div>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Library</h1>
|
||||
<p class="page-sub">${data.courses.length} course${data.courses.length === 1 ? '' : 's'} ·
|
||||
${totalVideos} video${totalVideos === 1 ? '' : 's'} on this machine</p>
|
||||
<p class="page-sub">${sub}</p>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div class="seg">
|
||||
<button class="${layout === 'shelves' ? 'is-on' : ''}" data-layout="shelves">Shelves</button>
|
||||
<button class="${layout === 'catalog' ? 'is-on' : ''}" data-layout="catalog">Catalog</button>
|
||||
${data.categories.length ? layoutBtn('categories', 'Categories') : ''}
|
||||
${layoutBtn('shelves', 'Shelves')}
|
||||
${layoutBtn('catalog', 'Catalog')}
|
||||
</div>
|
||||
${viewToggle()}
|
||||
</div>
|
||||
@@ -678,27 +759,50 @@
|
||||
const data = await load(`api/library.php?view=folder&id=${id}`);
|
||||
const mode = pref('view');
|
||||
const hue = hueOf(data.course_id);
|
||||
const isCategory = data.kind === 'category';
|
||||
|
||||
const sections = data.subfolders.length ? `
|
||||
<div class="section-head">
|
||||
<h2 class="h-section">Sections</h2>
|
||||
${viewToggle()}
|
||||
</div>
|
||||
<div style="margin-bottom:32px">${courseList(data.subfolders, mode)}</div>` : '';
|
||||
// A category holds courses (and possibly more categories), so it reads like
|
||||
// a small library of its own; a course holds sections, which are just a
|
||||
// list. Both come back on the same route — only the shape below differs.
|
||||
const sections = isCategory
|
||||
? (data.categories.length ? `
|
||||
<div class="section-head">
|
||||
<h2 class="h-section">Categories</h2>
|
||||
${viewToggle()}
|
||||
</div>
|
||||
<div style="margin-bottom:32px">${categoryList(data.categories, mode)}</div>` : '')
|
||||
+ (data.courses.length ? `
|
||||
<div class="section-head">
|
||||
<h2 class="h-section">Courses</h2>
|
||||
${data.categories.length ? '' : viewToggle()}
|
||||
</div>
|
||||
${shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join('')}` : '')
|
||||
: (data.subfolders.length ? `
|
||||
<div class="section-head">
|
||||
<h2 class="h-section">Sections</h2>
|
||||
${viewToggle()}
|
||||
</div>
|
||||
<div style="margin-bottom:32px">${courseList(data.subfolders, mode)}</div>` : '');
|
||||
|
||||
const items = [
|
||||
...data.videos.map((v) => ({ ...v, kind: 'video' })),
|
||||
...data.quizzes.map((q) => ({ ...q, kind: 'quiz' })),
|
||||
].sort((a, b) => a.filename.localeCompare(b.filename, undefined, { numeric: true, sensitivity: 'base' }));
|
||||
|
||||
const hasChildren = isCategory
|
||||
? (data.categories.length + data.courses.length) > 0
|
||||
: data.subfolders.length > 0;
|
||||
|
||||
const content = items.length ? `
|
||||
<h2 class="h-section" style="margin-bottom:14px">Content</h2>
|
||||
<div class="rows">${items.map((it) => it.kind === 'quiz' ? quizRow(it) : videoRow(it)).join('')}</div>`
|
||||
: (!data.subfolders.length
|
||||
: (!hasChildren
|
||||
? `<div class="empty"><h2>This folder is empty</h2>
|
||||
<p>No videos or quizzes were found here in the last scan.</p></div>` : '');
|
||||
|
||||
const courseCount = isCategory ? data.categories.reduce((n, c) => n + c.courses, data.courses.length) : 0;
|
||||
const meta = [
|
||||
courseCount ? `${courseCount} course${courseCount === 1 ? '' : 's'}` : '',
|
||||
data.rollup.videos ? `${data.rollup.videos} video${data.rollup.videos === 1 ? '' : 's'}` : '',
|
||||
data.rollup.quizzes ? `${data.rollup.quizzes} quiz${data.rollup.quizzes === 1 ? '' : 'zes'}` : '',
|
||||
fmtLong(data.rollup.duration),
|
||||
@@ -710,6 +814,7 @@
|
||||
${crumbs(data.breadcrumbs)}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
${isCategory ? '<span class="eyebrow eyebrow-sm">Category</span>' : ''}
|
||||
<h1>${esc(data.folder.name)}</h1>
|
||||
<p class="page-sub">${esc(meta || 'Empty folder')}</p>
|
||||
</div>
|
||||
@@ -758,7 +863,7 @@
|
||||
<span class="initial initial-lg" style="--c:${hueOf(c.id)}">${esc(initialOf(c.name))}</span>
|
||||
<div class="row-main">
|
||||
<div class="row-title">${esc(c.name)}</div>
|
||||
<div class="row-sub">${esc([courseMeta(c), c.path].filter(Boolean).join(' · '))}</div>
|
||||
<div class="row-sub">${esc([KIND_NAME[c.kind], courseMeta(c), c.path].filter(Boolean).join(' · '))}</div>
|
||||
</div>
|
||||
<div class="row-bar" style="flex:none;width:120px">
|
||||
${bar(c.progress.percent, hueOf(c.id))}
|
||||
@@ -1034,6 +1139,12 @@
|
||||
</div>
|
||||
<div class="fact-row"><span>Indexed</span>
|
||||
<span class="mono">${data.totals.videos} videos · ${data.totals.quizzes} quizzes · ${esc(fmtBytes(data.totals.bytes) || '0 mb')}</span></div>
|
||||
<div class="fact-row"><span>Courses found</span>
|
||||
<span class="mono">${data.totals.courses} in ${data.totals.categories} categor${data.totals.categories === 1 ? 'y' : 'ies'}</span></div>
|
||||
<div class="fact-row"><span>Shelves</span>
|
||||
<span class="mono">${data.shelf_counts.progress} in progress ·
|
||||
${data.shelf_counts.finished} finished ·
|
||||
${data.shelf_counts.fresh} not started</span></div>
|
||||
<div class="fact-row"><span>Total running time</span>
|
||||
<span class="mono">${esc(fmtLong(data.totals.duration) || '—')}</span></div>
|
||||
<div class="fact-row"><span>A video counts as watched at</span>
|
||||
@@ -1109,6 +1220,18 @@
|
||||
resumed = true;
|
||||
});
|
||||
|
||||
// A play button elsewhere in the app (the home hero) asked for the video to
|
||||
// start, not just for the player to open. Autoplay is only permitted off the
|
||||
// gesture that got us here, so the flag is consumed once and never survives
|
||||
// a reload — and it waits for the resume seek above, so play() picks up at
|
||||
// the saved position rather than at zero.
|
||||
if (state.playOnLoad === data.video.id) {
|
||||
state.playOnLoad = null;
|
||||
const start = () => { const p = el.play(); if (p) p.catch(() => { /* autoplay refused — controls are there */ }); };
|
||||
if (el.readyState >= 1) start();
|
||||
else el.addEventListener('loadedmetadata', start, { once: true });
|
||||
}
|
||||
|
||||
let lastSaved = -1;
|
||||
let saveTimer = null;
|
||||
|
||||
@@ -1149,6 +1272,10 @@
|
||||
save(true); // the final position always lands, even within 1 s of the last heartbeat
|
||||
if (data.next_id && pref('autoplay') !== false) {
|
||||
status(`${icon('next', 14, 1.7)}Playing next video…`);
|
||||
// "Autoplay next" has to actually start it — navigating alone just
|
||||
// opened the next video paused. The gesture that began this playback
|
||||
// still counts, so the browser lets the next one start.
|
||||
state.playOnLoad = data.next_id;
|
||||
nextTimer = setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500);
|
||||
}
|
||||
});
|
||||
@@ -1160,6 +1287,12 @@
|
||||
return function cleanup() {
|
||||
saveBeacon();
|
||||
clearTimeout(nextTimer); // leaving during the 1.5 s countdown must not jump ahead later
|
||||
// Autoplay-next arms playOnLoad before it navigates, and route() tears the
|
||||
// old view down after the hash has already changed — so only disarm when
|
||||
// we are leaving for somewhere other than the video it queued.
|
||||
if (state.playOnLoad === data.next_id && location.hash !== `#/watch/${data.next_id}`) {
|
||||
state.playOnLoad = null;
|
||||
}
|
||||
if (saveTimer) clearInterval(saveTimer);
|
||||
document.removeEventListener('visibilitychange', onHide);
|
||||
window.removeEventListener('pagehide', saveBeacon);
|
||||
@@ -1218,7 +1351,7 @@
|
||||
try {
|
||||
const res = await api('api/scan.php', { method: 'POST' });
|
||||
const s = res.stats;
|
||||
toast(`Scan finished — ${s.folders} folders, ${s.videos} videos, ${s.quizzes} quizzes indexed.`);
|
||||
toast(`Scan finished — ${s.courses} courses, ${s.videos} videos, ${s.quizzes} quizzes indexed.`);
|
||||
await loadMeta();
|
||||
route();
|
||||
} catch (err) {
|
||||
@@ -1336,6 +1469,13 @@
|
||||
/* ---------- delegated actions ---------- */
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
// Not a preventDefault: the link still navigates to #/watch/N, this only
|
||||
// tells the player it was reached from a play button and should start.
|
||||
const playLink = e.target.closest('[data-play]');
|
||||
if (playLink && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) {
|
||||
state.playOnLoad = +playLink.dataset.play;
|
||||
}
|
||||
|
||||
const scan = e.target.closest('[data-scan]');
|
||||
if (scan) { e.preventDefault(); runScan(); return; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user