UI improved
This commit is contained in:
@@ -1,13 +1,27 @@
|
||||
# CourseHub
|
||||
|
||||
Self-hosted course video player: PHP 8.0+ + MySQL/MariaDB + Bootstrap 5 + [Plyr](https://github.com/sampotts/plyr).
|
||||
Self-hosted course video player: PHP 8.0+ + MySQL/MariaDB, its own CSS design
|
||||
system, [Plyr](https://github.com/sampotts/plyr) and
|
||||
[anime.js](https://animejs.com/). No CSS framework.
|
||||
|
||||
Single-page app: `index.php` is a static shell; all navigation, login, browsing,
|
||||
playback, and scanning happen over AJAX against the JSON API in `api/`
|
||||
(`auth.php`, `library.php`, `progress.php`, `scan.php`), with hash-based routes
|
||||
(`#/`, `#/folder/N`, `#/watch/N`) so views switch without page reloads and
|
||||
browser back/forward still work. Only `stream.php` (the mp4 bytes) is a plain
|
||||
HTTP resource.
|
||||
Single-page app: `index.php` is a static shell (sidebar, search header, mobile
|
||||
tab bar); all navigation, login, browsing, playback, scanning and settings
|
||||
happen over AJAX against the JSON API in `api/` (`auth.php`, `library.php`,
|
||||
`progress.php`, `scan.php`, `quiz.php`, `reset.php`), with hash-based routes so
|
||||
views switch without page reloads and browser back/forward still work:
|
||||
|
||||
| Route | Screen |
|
||||
| --- | --- |
|
||||
| `#/` | home — resume card, stats, in progress, last scan |
|
||||
| `#/library`, `#/library/<shelf>` | every course, as shelves or a filtered catalog |
|
||||
| `#/folder/N` | a course or section: sections + content |
|
||||
| `#/watch/N` | player, playlist, section quizzes |
|
||||
| `#/quiz/N` | take and grade a quiz |
|
||||
| `#/search/Q` | courses, videos and quizzes matching `Q` |
|
||||
| `#/settings` | library folder, preferences, reset |
|
||||
|
||||
Only `stream.php` (the mp4 bytes) and `thumb.php` (cached course thumbnails)
|
||||
are plain HTTP resources.
|
||||
|
||||
- Recursively scans a videos directory for `.mp4` files and mirrors the folder
|
||||
hierarchy (courses → sections → videos, any nesting depth).
|
||||
@@ -16,6 +30,15 @@ HTTP resource.
|
||||
percentage bar computed over its whole subtree.
|
||||
- "Continue watching" strip, auto-play next video, playback speed, keyboard
|
||||
shortcuts (Plyr), completed checkmarks, natural sorting (`Video2` before `Video10`).
|
||||
- **Search** courses, sections, videos and quizzes from the top bar.
|
||||
- **Reset progress** for one video, one quiz, a whole course/section, or the
|
||||
entire account.
|
||||
- Course and section listings switch between **grid and list** layout, and the
|
||||
library between **shelves and catalog**; both choices are remembered.
|
||||
- **Shelves** group courses by how far you are: in progress, finished, not
|
||||
started. The app has no course categories, so progress is what sorts them.
|
||||
- **Course thumbnails** — a real frame captured from each course's first
|
||||
video, 40% of the way through it.
|
||||
- Seekable streaming via HTTP Range requests; files are addressed by DB id only
|
||||
(no path traversal).
|
||||
|
||||
@@ -51,6 +74,15 @@ Top-level folders become courses on the home page.
|
||||
the web server / PHP user).
|
||||
- DB credentials (change the password in production).
|
||||
- `ALLOW_REGISTRATION`: set `false` once your users have accounts.
|
||||
- `THUMBNAILS_DIR`: where generated course thumbnails are cached — must be
|
||||
*writable* by the web server / PHP user (default: `cache/thumbnails`
|
||||
next to `config.php`).
|
||||
- `THUMBNAIL_SEEK_PERCENT`: how far into a course's first video to capture
|
||||
its thumbnail (default `0.4`, i.e. 40%).
|
||||
|
||||
An `ffmpeg` binary on `$PATH` is required for thumbnails (and is the
|
||||
fallback for video duration if the pure-PHP mp4 parser can't read a file).
|
||||
Without it, courses just keep their colour-block placeholder.
|
||||
|
||||
3. **Run** — with the PHP built-in server for testing:
|
||||
|
||||
@@ -96,6 +128,108 @@ correct set. Best score, last score, and attempt count are stored per user;
|
||||
a quiz is *completed* once every question is answered correctly in one
|
||||
attempt, and quiz scores count toward folder progress alongside videos.
|
||||
|
||||
## Thumbnails
|
||||
|
||||
Each course's card is a real frame, not just a colour block: the scanner finds
|
||||
the course's first video — in the same order you'd browse to it (subfolders in
|
||||
listing order, then that folder's videos) — and asks `ffmpeg` for a frame
|
||||
`THUMBNAIL_SEEK_PERCENT` (default 40%) of the way through it, scaled and
|
||||
cropped to 640×360. The result is cached at `THUMBNAILS_DIR/<course id>.jpg`
|
||||
and served by `thumb.php?course_id=N`, which looks the file up by that ID only
|
||||
(same containment check as `stream.php`) and sends normal `ETag`/
|
||||
`Last-Modified` caching headers.
|
||||
|
||||
- Regeneration only happens when a course's first video has changed since the
|
||||
last scan (tracked via `folders.thumbnail_video_id`), so rescans stay fast.
|
||||
- If `ffmpeg` is missing, a video's duration can't be read, or generation
|
||||
otherwise fails, the course simply keeps its colour-block placeholder — the
|
||||
`<img>` 404s and the client removes it (`onerror="this.remove()"`), no error
|
||||
is shown to the user. Failures are logged (`error_log`) and rolled up into
|
||||
the scan's `thumbnails_failed` count.
|
||||
- `thumbnail_video_id`/`thumbnail_generated_at` on `folders` are added
|
||||
automatically on first use (`ensureThumbnailColumns()`) if a database
|
||||
created before this feature doesn't have them yet — no manual migration
|
||||
needed, the same self-healing `app_meta` uses.
|
||||
|
||||
## Search
|
||||
|
||||
The top-bar box searches as you type (from two characters, debounced) and also
|
||||
on Enter. It matches folder, video and quiz **names** and their relative
|
||||
**paths**, so `Course 1/Intro` lists everything under that section. Results are
|
||||
grouped into courses/sections, videos and quizzes, with your progress shown on
|
||||
each row. Searches live at `#/search/<query>`, so a result page can be
|
||||
bookmarked or shared.
|
||||
|
||||
## Resetting progress
|
||||
|
||||
`api/reset.php` deletes progress rows for the logged-in user only — never for
|
||||
anyone else, and never any video, quiz or folder itself. Four scopes:
|
||||
|
||||
| Where | Scope | Clears |
|
||||
| --- | --- | --- |
|
||||
| Player page → **Reset** | `video` | that video's position + completion |
|
||||
| Quiz page → **Reset attempts** | `quiz` | that quiz's attempts, best/last score |
|
||||
| Folder page → **Reset progress** | `folder` | everything in that folder's subtree |
|
||||
| Library list row → ↺ | `folder` | that one course's subtree |
|
||||
| Settings / avatar menu → **Reset all progress** | `all` | every video and quiz on the account |
|
||||
|
||||
Each one asks for confirmation first, and the reply reports how many video and
|
||||
quiz rows were removed. Resetting the video you are watching also suppresses
|
||||
the player's teardown save, so the position you just cleared isn't written back.
|
||||
|
||||
## Look and feel
|
||||
|
||||
The UI implements the **Course Library** design (a Claude Design document).
|
||||
`assets/style.css` is the whole design system — tokens first, then layout, then
|
||||
components. There is no CSS framework under it, and the icons are inline SVG
|
||||
from the design rather than an icon font.
|
||||
|
||||
| Token | Hex | Used for |
|
||||
| --- | --- | --- |
|
||||
| Paper | `#EFEAE1` | outermost ground |
|
||||
| Surface | `#FAF8F5` | app background |
|
||||
| Sidebar | `#F5F1EA` | left rail |
|
||||
| Card | `#FFFFFF` | cards, rows, dialogs |
|
||||
| Ink | `#1A1814` → `#B8B0A2` | four text weights (`--ink` … `--ink-4`) |
|
||||
| Accent | `#1F6F5C` | primary action, links, progress, completion |
|
||||
| Danger | `#B84A3C` | reset and errors |
|
||||
|
||||
Type: **Inter** throughout — headings, interface text, and counts/durations/
|
||||
paths (the design's own 'Elms Sans' ships only inside the design document).
|
||||
Numeric text uses `tabular-nums` so columns of counts and durations still
|
||||
line up without a separate monospace face.
|
||||
|
||||
Courses have no cover art, so each one gets a hue from the design's six-colour
|
||||
set (clay, sage, slate, sand, plum, sky), picked from its folder id so it stays
|
||||
the same across renders and reloads. That hue drives its thumbnail, its initial
|
||||
badge and its progress bar.
|
||||
|
||||
anime.js drives the motion: views fade and stagger in, progress bars grow from
|
||||
zero (so a reset is visible), correct quiz answers settle and wrong ones shake.
|
||||
Everything is skipped when the CDN script is unavailable, when the visitor has
|
||||
`prefers-reduced-motion: reduce` set, or when **Animate transitions** is turned
|
||||
off in settings — the views then render in their final state.
|
||||
|
||||
Below 900 px the layout follows the design's mobile frame: the sidebar gives way
|
||||
to a bottom tab bar, the search field becomes a pill with a round scan button
|
||||
next to it, and grids drop to two columns.
|
||||
|
||||
## Settings
|
||||
|
||||
`#/settings` shows what the server actually knows and what this browser
|
||||
remembers:
|
||||
|
||||
- **Library folder** — `VIDEOS_ROOT`, whether it is readable, indexed counts,
|
||||
total running time, the watched threshold, and when the last scan ran.
|
||||
- **Preferences**, stored per browser: autoplay next video, animate
|
||||
transitions, open the library in list view.
|
||||
- **Reset progress** — the account-wide reset, with a count of how many courses
|
||||
currently hold progress.
|
||||
|
||||
Scan time lives in an `app_meta` key/value table. Reads tolerate the table not
|
||||
existing, so an install created before it was added keeps working until its
|
||||
next scan creates it.
|
||||
|
||||
## How progress works
|
||||
|
||||
- The player POSTs `{video_id, position, duration}` to `api/progress.php`
|
||||
|
||||
+127
-8
@@ -1,7 +1,11 @@
|
||||
<?php
|
||||
// GET api/library.php?view=home -> courses + continue-watching
|
||||
// GET api/library.php?view=meta -> sidebar/header chrome: totals, shelves, last scan
|
||||
// GET api/library.php?view=home -> resume card, stats, in progress, recent additions
|
||||
// GET api/library.php?view=library -> every course, grouped into shelves
|
||||
// GET api/library.php?view=folder&id=N -> folder contents + progress
|
||||
// GET api/library.php?view=video&id=N -> video + playlist + resume info
|
||||
// GET api/library.php?view=search&q=text -> matching folders, videos, quizzes
|
||||
// GET api/library.php?view=settings -> library paths, thresholds, progress summary
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/functions.php';
|
||||
|
||||
@@ -53,12 +57,64 @@ function crumbList(array $folder): array
|
||||
);
|
||||
}
|
||||
|
||||
/** The three shelves the library groups courses into. */
|
||||
function shelveCourses(array $courses): array
|
||||
{
|
||||
$shelves = [
|
||||
['key' => 'progress', 'name' => 'In progress', 'courses' => []],
|
||||
['key' => 'finished', 'name' => 'Finished', 'courses' => []],
|
||||
['key' => 'fresh', 'name' => 'Not started', 'courses' => []],
|
||||
];
|
||||
foreach ($courses as $c) {
|
||||
foreach ($shelves as $i => $shelf) {
|
||||
if ($shelf['key'] === $c['state']) {
|
||||
$shelves[$i]['courses'][] = $c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_values(array_filter($shelves, fn($s) => $s['courses']));
|
||||
}
|
||||
|
||||
$view = $_GET['view'] ?? 'home';
|
||||
|
||||
switch ($view) {
|
||||
case 'home':
|
||||
case 'meta':
|
||||
$courses = courseCards($userId);
|
||||
$counts = ['progress' => 0, 'finished' => 0, 'fresh' => 0];
|
||||
foreach ($courses as $c) {
|
||||
$counts[$c['state']]++;
|
||||
}
|
||||
echo json_encode([
|
||||
'courses' => array_map(fn($c) => folderSummary($c, $userId), childFolders(null)),
|
||||
'totals' => libraryTotals(),
|
||||
'shelves' => [
|
||||
['key' => 'progress', 'name' => 'In progress', 'count' => $counts['progress']],
|
||||
['key' => 'finished', 'name' => 'Finished', 'count' => $counts['finished']],
|
||||
['key' => 'fresh', 'name' => 'Not started', 'count' => $counts['fresh']],
|
||||
],
|
||||
'last_scan' => appMetaGet('last_scan_at'),
|
||||
'username' => currentUsername(),
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'home':
|
||||
$courses = courseCards($userId);
|
||||
$resume = continueWatching($userId, 1);
|
||||
$resumeCourseId = null;
|
||||
if ($resume) {
|
||||
$resumeFolder = getFolder((int)$resume[0]['folder_id']);
|
||||
if ($resumeFolder) $resumeCourseId = courseIdOf($resumeFolder);
|
||||
}
|
||||
echo json_encode([
|
||||
'stats' => userStats($userId, $courses),
|
||||
'resume' => $resume ? [
|
||||
'id' => (int)$resume[0]['id'],
|
||||
'course_id' => $resumeCourseId,
|
||||
'name' => $resume[0]['name'],
|
||||
'folder_path' => $resume[0]['folder_path'],
|
||||
'position' => (float)$resume[0]['position_seconds'],
|
||||
'duration' => $resume[0]['duration_seconds'] !== null
|
||||
? (float)$resume[0]['duration_seconds'] : null,
|
||||
] : null,
|
||||
'continue' => array_map(fn($r) => [
|
||||
'id' => (int)$r['id'],
|
||||
'name' => $r['name'],
|
||||
@@ -66,6 +122,18 @@ switch ($view) {
|
||||
'position' => (float)$r['position_seconds'],
|
||||
'duration' => $r['duration_seconds'] !== null ? (float)$r['duration_seconds'] : null,
|
||||
], continueWatching($userId)),
|
||||
'in_progress' => array_values(array_filter($courses, fn($c) => $c['state'] === 'progress')),
|
||||
'recent' => recentCourses($userId),
|
||||
'courses' => $courses,
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'library':
|
||||
$courses = courseCards($userId);
|
||||
echo json_encode([
|
||||
'courses' => $courses,
|
||||
'shelves' => shelveCourses($courses),
|
||||
'totals' => libraryTotals(),
|
||||
]);
|
||||
break;
|
||||
|
||||
@@ -76,11 +144,15 @@ switch ($view) {
|
||||
echo json_encode(['error' => 'Folder not found']);
|
||||
break;
|
||||
}
|
||||
$crumbs = crumbList($folder);
|
||||
echo json_encode([
|
||||
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
|
||||
'breadcrumbs' => crumbList($folder),
|
||||
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name'],
|
||||
'path' => $folder['path']],
|
||||
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'],
|
||||
'breadcrumbs' => $crumbs,
|
||||
'rollup' => folderRollup($userId, $folder['path']),
|
||||
'progress' => folderProgress($userId, $folder['path']),
|
||||
'subfolders' => array_map(fn($f) => folderSummary($f, $userId),
|
||||
'subfolders' => array_map(fn($f) => courseSummary($userId, $f),
|
||||
childFolders((int)$folder['id'])),
|
||||
'videos' => array_map('videoSummary',
|
||||
folderVideos((int)$folder['id'], $userId)),
|
||||
@@ -98,6 +170,7 @@ switch ($view) {
|
||||
}
|
||||
$folder = getFolder((int)$video['folder_id']);
|
||||
$playlist = folderVideos((int)$folder['id'], $userId);
|
||||
$crumbs = crumbList($folder);
|
||||
|
||||
$stmt = db()->prepare(
|
||||
'SELECT position_seconds, completed FROM video_progress
|
||||
@@ -118,10 +191,14 @@ switch ($view) {
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'video' => ['id' => (int)$video['id'], 'name' => $video['name']],
|
||||
'video' => ['id' => (int)$video['id'], 'name' => $video['name'],
|
||||
'duration' => $video['duration_seconds'] !== null
|
||||
? (float)$video['duration_seconds'] : null],
|
||||
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
|
||||
'breadcrumbs' => crumbList($folder),
|
||||
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'],
|
||||
'breadcrumbs' => $crumbs,
|
||||
'playlist' => array_map('videoSummary', $playlist),
|
||||
'quizzes' => array_map('quizSummary', folderQuizzes((int)$folder['id'], $userId)),
|
||||
'resume_at' => $resumeAt,
|
||||
'prev_id' => $prevId,
|
||||
'next_id' => $nextId,
|
||||
@@ -129,6 +206,48 @@ switch ($view) {
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'search':
|
||||
$q = trim((string)($_GET['q'] ?? ''));
|
||||
if ($q === '') {
|
||||
echo json_encode(['query' => '', 'folders' => [], 'videos' => [], 'quizzes' => []]);
|
||||
break;
|
||||
}
|
||||
$withPath = fn(array $row, array $summary) =>
|
||||
$summary + ['folder_path' => $row['folder_path']];
|
||||
|
||||
echo json_encode([
|
||||
'query' => $q,
|
||||
'folders' => array_map(
|
||||
fn($f) => courseSummary($userId, $f),
|
||||
searchFolders($q)
|
||||
),
|
||||
'videos' => array_map(
|
||||
fn($v) => $withPath($v, videoSummary($v)),
|
||||
searchVideos($userId, $q)
|
||||
),
|
||||
'quizzes' => array_map(
|
||||
fn($q2) => $withPath($q2, quizSummary($q2)),
|
||||
searchQuizzes($userId, $q)
|
||||
),
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'settings':
|
||||
$courses = courseCards($userId);
|
||||
$withSome = array_values(array_filter($courses, fn($c) => $c['progress']['percent'] > 0));
|
||||
echo json_encode([
|
||||
'root' => rtrim(VIDEOS_ROOT, '/'),
|
||||
'root_exists' => is_dir(VIDEOS_ROOT),
|
||||
'totals' => libraryTotals(),
|
||||
'last_scan' => appMetaGet('last_scan_at'),
|
||||
'complete_threshold' => (int)round(100 * COMPLETE_THRESHOLD),
|
||||
'registration_open' => (bool)ALLOW_REGISTRATION,
|
||||
'username' => currentUsername(),
|
||||
'progress_courses' => count($withSome),
|
||||
'courses' => count($courses),
|
||||
]);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Unknown view']);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
// POST api/reset.php -> delete the logged-in user's progress for a scope.
|
||||
// JSON body: {scope: all|folder|video|quiz, id?}
|
||||
// Only ever touches the current user's rows; the library itself is untouched.
|
||||
require_once __DIR__ . '/../includes/auth.php';
|
||||
require_once __DIR__ . '/../includes/functions.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
$userId = requireLoginApi();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$scope = (string)($data['scope'] ?? '');
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
try {
|
||||
$counts = resetProgress($userId, $scope, $id);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => true, 'reset' => $counts]);
|
||||
+1176
-304
File diff suppressed because it is too large
Load Diff
+1176
-21
File diff suppressed because it is too large
Load Diff
@@ -18,3 +18,10 @@ define('COMPLETE_THRESHOLD', 0.95);
|
||||
|
||||
// Allow new users to self-register from the login page.
|
||||
define('ALLOW_REGISTRATION', true);
|
||||
|
||||
// Where generated course thumbnails are cached. Must be writable by the PHP
|
||||
// process; created automatically on first scan if missing.
|
||||
define('THUMBNAILS_DIR', __DIR__ . '/cache/thumbnails');
|
||||
|
||||
// Capture each course's thumbnail this far into its first video (0.0-1.0).
|
||||
define('THUMBNAIL_SEEK_PERCENT', 0.4);
|
||||
|
||||
+442
-10
@@ -147,6 +147,7 @@ function scanLibrary(): array
|
||||
$seenFolderIds = [];
|
||||
$seenVideoIds = [];
|
||||
$seenQuizIds = [];
|
||||
$firstVideo = []; // courseFolderId => ['id' => videoId, 'path' => relPath]
|
||||
|
||||
$upsertFolder = $db->prepare(
|
||||
'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing)
|
||||
@@ -172,9 +173,14 @@ function scanLibrary(): array
|
||||
id = LAST_INSERT_ID(id)'
|
||||
);
|
||||
|
||||
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth)
|
||||
// $courseId identifies which top-level course folder the current directory
|
||||
// sits under (null while walking VIDEOS_ROOT itself); it's set the moment
|
||||
// we descend into a depth-0 folder and passed unchanged to every
|
||||
// descendant call, so the first mp4 found anywhere in a course's subtree
|
||||
// — in true directory-listing order — is recorded exactly once.
|
||||
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth, ?int $courseId)
|
||||
use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz,
|
||||
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void {
|
||||
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds, &$firstVideo): void {
|
||||
|
||||
$entries = scandir($dir) ?: [];
|
||||
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
|
||||
@@ -192,15 +198,19 @@ function scanLibrary(): array
|
||||
$folderId = (int)$db->lastInsertId();
|
||||
$seenFolderIds[] = $folderId;
|
||||
$stats['folders']++;
|
||||
$walk($full, $folderId, $rel, $depth + 1);
|
||||
$walk($full, $folderId, $rel, $depth + 1, $courseId ?? $folderId);
|
||||
} elseif (is_file($full) && strtolower(pathinfo($entry, PATHINFO_EXTENSION)) === 'mp4') {
|
||||
if ($parentId === null) {
|
||||
continue; // mp4 directly in the root is skipped; use course folders
|
||||
}
|
||||
$name = pathinfo($entry, PATHINFO_FILENAME);
|
||||
$upsertVideo->execute([$parentId, $name, $entry, $rel, filesize($full) ?: 0, $videoSort++]);
|
||||
$seenVideoIds[] = (int)$db->lastInsertId();
|
||||
$videoId = (int)$db->lastInsertId();
|
||||
$seenVideoIds[] = $videoId;
|
||||
$stats['videos']++;
|
||||
if ($courseId !== null && !isset($firstVideo[$courseId])) {
|
||||
$firstVideo[$courseId] = ['id' => $videoId, 'path' => $rel];
|
||||
}
|
||||
} elseif (is_file($full) && preg_match('/_quiz_solved\.txt$/i', $entry)) {
|
||||
if ($parentId === null) {
|
||||
continue;
|
||||
@@ -219,7 +229,7 @@ function scanLibrary(): array
|
||||
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$walk($root, null, '', 0);
|
||||
$walk($root, null, '', 0, null);
|
||||
|
||||
// Anything not seen this pass no longer exists on disk.
|
||||
$flagMissing = function (string $table, array $seenIds) use ($db): int {
|
||||
@@ -257,24 +267,152 @@ function scanLibrary(): array
|
||||
}
|
||||
}
|
||||
|
||||
// One thumbnail per course, captured from its first video (in the same
|
||||
// order $firstVideo was built above) at THUMBNAIL_SEEK_PERCENT. Courses
|
||||
// whose first video hasn't changed since last time are skipped.
|
||||
$thumbStats = generateThumbnails($firstVideo);
|
||||
$stats['thumbnails_generated'] = $thumbStats['generated'];
|
||||
$stats['thumbnails_failed'] = $thumbStats['failed'];
|
||||
|
||||
appMetaSet('last_scan_at', gmdate('Y-m-d H:i:s'));
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Course thumbnails — one frame captured from each course's first video (in
|
||||
* scan order) at THUMBNAIL_SEEK_PERCENT of that video's duration, cached as a
|
||||
* jpg named by folder id and served by thumb.php.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* folders.thumbnail_video_id/thumbnail_generated_at may not exist yet on a
|
||||
* database created before this feature — self-heal the same way appMetaSet()
|
||||
* lazily creates app_meta, so a git pull doesn't require a manual migration.
|
||||
*/
|
||||
function ensureThumbnailColumns(PDO $db): void
|
||||
{
|
||||
static $checked = false;
|
||||
if ($checked) return;
|
||||
try {
|
||||
$db->query('SELECT thumbnail_video_id, thumbnail_generated_at FROM folders LIMIT 1');
|
||||
} catch (Throwable $e) {
|
||||
$db->exec('ALTER TABLE folders
|
||||
ADD COLUMN thumbnail_video_id INT UNSIGNED NULL,
|
||||
ADD COLUMN thumbnail_generated_at TIMESTAMP NULL');
|
||||
}
|
||||
$checked = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate (or refresh) each course's thumbnail from $firstVideo, the
|
||||
* courseFolderId => ['id' => videoId, 'path' => relPath] map scanLibrary()
|
||||
* builds while walking the tree. Returns ['generated' => n, 'skipped' => n,
|
||||
* 'failed' => n].
|
||||
*/
|
||||
function generateThumbnails(array $firstVideo): array
|
||||
{
|
||||
$stats = ['generated' => 0, 'skipped' => 0, 'failed' => 0];
|
||||
if (!$firstVideo) return $stats;
|
||||
|
||||
$dir = rtrim(THUMBNAILS_DIR, '/');
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
error_log("generateThumbnails: cannot create THUMBNAILS_DIR ($dir) — skipping");
|
||||
$stats['failed'] = count($firstVideo);
|
||||
return $stats;
|
||||
}
|
||||
|
||||
$db = db();
|
||||
ensureThumbnailColumns($db);
|
||||
$upd = $db->prepare(
|
||||
'UPDATE folders SET thumbnail_video_id = ?, thumbnail_generated_at = UTC_TIMESTAMP() WHERE id = ?'
|
||||
);
|
||||
|
||||
foreach ($firstVideo as $courseId => $pick) {
|
||||
$course = getFolder($courseId);
|
||||
if (!$course) continue; // course itself went missing this scan
|
||||
|
||||
$thumbFile = "$dir/$courseId.jpg";
|
||||
$alreadyCurrent = (int)($course['thumbnail_video_id'] ?? 0) === $pick['id'] && is_file($thumbFile);
|
||||
if ($alreadyCurrent) {
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$abs = realpath(rtrim(VIDEOS_ROOT, '/') . '/' . $pick['path']);
|
||||
$videoRoot = realpath(VIDEOS_ROOT);
|
||||
if ($abs === false || $videoRoot === false
|
||||
|| !str_starts_with($abs, $videoRoot . DIRECTORY_SEPARATOR)) {
|
||||
$stats['failed']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$duration = probeDuration($abs);
|
||||
$seekAt = ($duration !== null && $duration > 0) ? $duration * THUMBNAIL_SEEK_PERCENT : 1.0;
|
||||
|
||||
if (captureThumbnail($abs, $seekAt, $thumbFile)) {
|
||||
$upd->execute([$pick['id'], $courseId]);
|
||||
$stats['generated']++;
|
||||
} else {
|
||||
$stats['failed']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grab a single frame from $videoPath at $seekSeconds via ffmpeg, cropped to
|
||||
* a fixed 640x360 cover, and write it to $outFile. Written to a temp file
|
||||
* first and renamed into place, so a request racing the write never sees a
|
||||
* partial jpg. Returns false (leaving any existing thumbnail untouched) if
|
||||
* ffmpeg is unavailable or the capture fails.
|
||||
*/
|
||||
function captureThumbnail(string $videoPath, float $seekSeconds, string $outFile): bool
|
||||
{
|
||||
if (!function_exists('shell_exec')) return false;
|
||||
|
||||
// The temp file has no recognizable extension, so the output format is
|
||||
// forced explicitly with -f mjpeg rather than left for ffmpeg to guess
|
||||
// from the filename (it can't, and fails outright without this).
|
||||
$tmp = $outFile . '.tmp-' . bin2hex(random_bytes(4));
|
||||
$cmd = sprintf(
|
||||
'ffmpeg -y -ss %s -i %s -frames:v 1 -f mjpeg -vf %s -q:v 3 %s 2>&1',
|
||||
escapeshellarg(number_format(max(0, $seekSeconds), 2, '.', '')),
|
||||
escapeshellarg($videoPath),
|
||||
escapeshellarg('scale=640:360:force_original_aspect_ratio=increase,crop=640:360'),
|
||||
escapeshellarg($tmp)
|
||||
);
|
||||
$out = @shell_exec($cmd);
|
||||
|
||||
if (!is_file($tmp) || filesize($tmp) < 512) {
|
||||
if (is_file($tmp)) @unlink($tmp);
|
||||
if ($out !== null) {
|
||||
error_log("captureThumbnail: ffmpeg produced no usable output for $videoPath\n$out");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return @rename($tmp, $outFile);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Progress helpers
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Progress for one folder's whole subtree (videos + quizzes), matched by
|
||||
* path prefix. Returns ['total' => n, 'completed' => n, 'percent' => 0-100].
|
||||
* Percent gives partial credit for half-watched videos and partial scores.
|
||||
* One pass over a folder's whole subtree (videos + quizzes), matched by path
|
||||
* prefix: how much is in there, how big, how long, and how far the user got.
|
||||
* Course cards need all of it, so it is gathered in two queries rather than
|
||||
* one per fact.
|
||||
*/
|
||||
function folderProgress(int $userId, string $folderPath): array
|
||||
function folderRollup(int $userId, string $folderPath): array
|
||||
{
|
||||
$args = [$userId, $folderPath, likePrefix($folderPath) . '/%'];
|
||||
|
||||
$stmt = db()->prepare(
|
||||
"SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(COALESCE(v.duration_seconds, vp.duration_seconds)), 0) AS duration,
|
||||
COALESCE(SUM(v.size_bytes), 0) AS bytes,
|
||||
COALESCE(SUM(vp.completed), 0) AS completed,
|
||||
COALESCE(SUM(
|
||||
CASE
|
||||
@@ -315,19 +453,306 @@ function folderProgress(int $userId, string $folderPath): array
|
||||
|
||||
$total = (int)$videos['total'] + (int)$quizzes['total'];
|
||||
$credit = $videos['credit'] + $quizzes['credit'];
|
||||
|
||||
return [
|
||||
'videos' => (int)$videos['total'],
|
||||
'quizzes' => (int)$quizzes['total'],
|
||||
'duration' => (float)$videos['duration'],
|
||||
'bytes' => (int)$videos['bytes'],
|
||||
'total' => $total,
|
||||
'completed' => (int)$videos['completed'] + (int)$quizzes['completed'],
|
||||
'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress for one folder's whole subtree.
|
||||
* Returns ['total' => n, 'completed' => n, 'percent' => 0-100].
|
||||
* Percent gives partial credit for half-watched videos and partial scores.
|
||||
*/
|
||||
function folderProgress(int $userId, string $folderPath): array
|
||||
{
|
||||
$r = folderRollup($userId, $folderPath);
|
||||
return ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $r['percent']];
|
||||
}
|
||||
|
||||
/** A course (or section) as the library screens show it: contents + progress. */
|
||||
function courseSummary(int $userId, array $folder): array
|
||||
{
|
||||
$r = folderRollup($userId, $folder['path']);
|
||||
$percent = $r['percent'];
|
||||
|
||||
return [
|
||||
'id' => (int)$folder['id'],
|
||||
'name' => $folder['name'],
|
||||
'path' => $folder['path'],
|
||||
'videos' => $r['videos'],
|
||||
'quizzes' => $r['quizzes'],
|
||||
'duration' => $r['duration'],
|
||||
'bytes' => $r['bytes'],
|
||||
'added_at' => $folder['created_at'] ?? null,
|
||||
'progress' => ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $percent],
|
||||
// The shelves the library groups by — the app has no course categories,
|
||||
// so progress is what separates one shelf from another.
|
||||
'state' => $percent >= 100 ? 'finished' : ($percent > 0 ? 'progress' : 'fresh'),
|
||||
];
|
||||
}
|
||||
|
||||
/** Every top-level folder as a course card. */
|
||||
function courseCards(int $userId): array
|
||||
{
|
||||
return array_map(fn($f) => courseSummary($userId, $f), childFolders(null));
|
||||
}
|
||||
|
||||
/** Headline numbers for the home screen, computed from the course cards. */
|
||||
function userStats(int $userId, array $courses): array
|
||||
{
|
||||
$inProgress = 0;
|
||||
$finished = 0;
|
||||
foreach ($courses as $c) {
|
||||
if ($c['state'] === 'finished') $finished++;
|
||||
elseif ($c['state'] === 'progress') $inProgress++;
|
||||
}
|
||||
|
||||
// Watched time: full duration for completed videos, position for the rest.
|
||||
$stmt = db()->prepare(
|
||||
'SELECT COALESCE(SUM(
|
||||
CASE WHEN vp.completed = 1
|
||||
THEN COALESCE(v.duration_seconds, vp.duration_seconds, vp.position_seconds)
|
||||
ELSE vp.position_seconds END
|
||||
), 0) AS watched
|
||||
FROM video_progress vp
|
||||
JOIN videos v ON v.id = vp.video_id AND v.is_missing = 0
|
||||
WHERE vp.user_id = ?'
|
||||
);
|
||||
$stmt->execute([$userId]);
|
||||
$watched = (float)$stmt->fetchColumn();
|
||||
|
||||
$stmt = db()->prepare(
|
||||
'SELECT COUNT(*) AS taken,
|
||||
COALESCE(AVG(qp.best_score / NULLIF(q.num_questions, 0)), 0) AS avg_score
|
||||
FROM quiz_progress qp
|
||||
JOIN quizzes q ON q.id = qp.quiz_id AND q.is_missing = 0
|
||||
WHERE qp.user_id = ? AND qp.attempts > 0'
|
||||
);
|
||||
$stmt->execute([$userId]);
|
||||
$quiz = $stmt->fetch();
|
||||
|
||||
return [
|
||||
'in_progress' => $inProgress,
|
||||
'finished' => $finished,
|
||||
'courses' => count($courses),
|
||||
'watched_seconds' => $watched,
|
||||
'quizzes_taken' => (int)$quiz['taken'],
|
||||
'quiz_average' => (int)round(100 * (float)$quiz['avg_score']),
|
||||
];
|
||||
}
|
||||
|
||||
/** Courses the scanner indexed most recently — the "found in the last scan" strip. */
|
||||
function recentCourses(int $userId, int $limit = 3): array
|
||||
{
|
||||
$stmt = db()->prepare(
|
||||
'SELECT * FROM folders
|
||||
WHERE parent_id IS NULL AND is_missing = 0
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ' . (int)$limit
|
||||
);
|
||||
$stmt->execute();
|
||||
|
||||
$week = time() - 7 * 86400;
|
||||
return array_map(function ($f) use ($userId, $week) {
|
||||
$c = courseSummary($userId, $f);
|
||||
$c['is_new'] = $f['created_at'] !== null && strtotime($f['created_at']) >= $week;
|
||||
return $c;
|
||||
}, $stmt->fetchAll());
|
||||
}
|
||||
|
||||
/** Library-wide counts for the sidebar and header. */
|
||||
function libraryTotals(): array
|
||||
{
|
||||
$row = db()->query(
|
||||
'SELECT (SELECT COUNT(*) FROM folders WHERE parent_id IS NULL AND is_missing = 0) AS courses,
|
||||
(SELECT COUNT(*) FROM videos WHERE is_missing = 0) AS videos,
|
||||
(SELECT COUNT(*) FROM quizzes WHERE is_missing = 0) AS quizzes,
|
||||
(SELECT COALESCE(SUM(size_bytes), 0) FROM videos WHERE is_missing = 0) AS bytes,
|
||||
(SELECT COALESCE(SUM(duration_seconds), 0) FROM videos WHERE is_missing = 0) AS duration'
|
||||
)->fetch();
|
||||
|
||||
return [
|
||||
'courses' => (int)$row['courses'],
|
||||
'videos' => (int)$row['videos'],
|
||||
'quizzes' => (int)$row['quizzes'],
|
||||
'bytes' => (int)$row['bytes'],
|
||||
'duration' => (float)$row['duration'],
|
||||
];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* app_meta — small key/value store (currently just the last scan time).
|
||||
* Reads tolerate the table not existing yet, so an install created before this
|
||||
* table was added keeps working until its next scan creates it.
|
||||
* ------------------------------------------------------------------------- */
|
||||
function appMetaGet(string $key): ?string
|
||||
{
|
||||
try {
|
||||
$stmt = db()->prepare('SELECT v FROM app_meta WHERE k = ?');
|
||||
$stmt->execute([$key]);
|
||||
$value = $stmt->fetchColumn();
|
||||
return $value === false ? null : (string)$value;
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appMetaSet(string $key, string $value): void
|
||||
{
|
||||
$db = db();
|
||||
$db->exec(
|
||||
'CREATE TABLE IF NOT EXISTS app_meta (
|
||||
k VARCHAR(64) PRIMARY KEY,
|
||||
v VARCHAR(255) NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB'
|
||||
);
|
||||
$stmt = $db->prepare('INSERT INTO app_meta (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)');
|
||||
$stmt->execute([$key, $value]);
|
||||
}
|
||||
|
||||
/** Escape LIKE wildcards in a path used as a prefix. */
|
||||
function likePrefix(string $path): string
|
||||
{
|
||||
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $path);
|
||||
}
|
||||
|
||||
/** Escaped "contains" pattern for LIKE. */
|
||||
function likeContains(string $term): string
|
||||
{
|
||||
return '%' . likePrefix($term) . '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the user's progress for a scope:
|
||||
* all — everything the user has watched/answered
|
||||
* folder — the folder's whole subtree (id = folder id)
|
||||
* video — one video (id = video id)
|
||||
* quiz — one quiz (id = quiz id)
|
||||
* Returns ['videos' => n, 'quizzes' => n] rows removed.
|
||||
* Rows for files that went missing are cleared too, so a reset is complete.
|
||||
*/
|
||||
function resetProgress(int $userId, string $scope, int $id = 0): array
|
||||
{
|
||||
$db = db();
|
||||
|
||||
switch ($scope) {
|
||||
case 'all':
|
||||
$v = $db->prepare('DELETE FROM video_progress WHERE user_id = ?');
|
||||
$v->execute([$userId]);
|
||||
$q = $db->prepare('DELETE FROM quiz_progress WHERE user_id = ?');
|
||||
$q->execute([$userId]);
|
||||
break;
|
||||
|
||||
case 'folder':
|
||||
$folder = getFolder($id);
|
||||
if (!$folder) {
|
||||
throw new InvalidArgumentException('Folder not found');
|
||||
}
|
||||
$args = [$userId, $folder['path'], likePrefix($folder['path']) . '/%'];
|
||||
$v = $db->prepare(
|
||||
'DELETE vp FROM video_progress vp
|
||||
JOIN videos v ON v.id = vp.video_id
|
||||
JOIN folders f ON f.id = v.folder_id
|
||||
WHERE vp.user_id = ? AND (f.path = ? OR f.path LIKE ?)'
|
||||
);
|
||||
$v->execute($args);
|
||||
$q = $db->prepare(
|
||||
'DELETE qp FROM quiz_progress qp
|
||||
JOIN quizzes q ON q.id = qp.quiz_id
|
||||
JOIN folders f ON f.id = q.folder_id
|
||||
WHERE qp.user_id = ? AND (f.path = ? OR f.path LIKE ?)'
|
||||
);
|
||||
$q->execute($args);
|
||||
break;
|
||||
|
||||
case 'video':
|
||||
if ($id <= 0) {
|
||||
throw new InvalidArgumentException('Video not found');
|
||||
}
|
||||
$v = $db->prepare('DELETE FROM video_progress WHERE user_id = ? AND video_id = ?');
|
||||
$v->execute([$userId, $id]);
|
||||
$q = null;
|
||||
break;
|
||||
|
||||
case 'quiz':
|
||||
if ($id <= 0) {
|
||||
throw new InvalidArgumentException('Quiz not found');
|
||||
}
|
||||
$q = $db->prepare('DELETE FROM quiz_progress WHERE user_id = ? AND quiz_id = ?');
|
||||
$q->execute([$userId, $id]);
|
||||
$v = null;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidArgumentException('Unknown reset scope');
|
||||
}
|
||||
|
||||
return [
|
||||
'videos' => $v ? $v->rowCount() : 0,
|
||||
'quizzes' => $q ? $q->rowCount() : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Search — substring match on names (and on the relative path, so
|
||||
* "Course 1/Intro" finds everything under it).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
function searchFolders(string $term, int $limit = 25): array
|
||||
{
|
||||
$like = likeContains($term);
|
||||
$stmt = db()->prepare(
|
||||
'SELECT * FROM folders
|
||||
WHERE is_missing = 0 AND (name LIKE ? OR path LIKE ?)
|
||||
ORDER BY depth, path
|
||||
LIMIT ' . (int)$limit
|
||||
);
|
||||
$stmt->execute([$like, $like]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function searchVideos(int $userId, string $term, int $limit = 50): array
|
||||
{
|
||||
$like = likeContains($term);
|
||||
$stmt = db()->prepare(
|
||||
'SELECT v.*, f.path AS folder_path,
|
||||
vp.position_seconds, vp.duration_seconds AS watched_duration, vp.completed
|
||||
FROM videos v
|
||||
JOIN folders f ON f.id = v.folder_id
|
||||
LEFT JOIN video_progress vp ON vp.video_id = v.id AND vp.user_id = ?
|
||||
WHERE v.is_missing = 0 AND f.is_missing = 0 AND (v.name LIKE ? OR v.path LIKE ?)
|
||||
ORDER BY f.path, v.sort_order, v.name
|
||||
LIMIT ' . (int)$limit
|
||||
);
|
||||
$stmt->execute([$userId, $like, $like]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function searchQuizzes(int $userId, string $term, int $limit = 25): array
|
||||
{
|
||||
$like = likeContains($term);
|
||||
$stmt = db()->prepare(
|
||||
'SELECT q.*, f.path AS folder_path,
|
||||
qp.best_score, qp.last_score, qp.attempts, qp.completed
|
||||
FROM quizzes q
|
||||
JOIN folders f ON f.id = q.folder_id
|
||||
LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ?
|
||||
WHERE q.is_missing = 0 AND f.is_missing = 0 AND (q.name LIKE ? OR q.path LIKE ?)
|
||||
ORDER BY f.path, q.sort_order, q.name
|
||||
LIMIT ' . (int)$limit
|
||||
);
|
||||
$stmt->execute([$userId, $like, $like]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function getFolder(int $id): ?array
|
||||
{
|
||||
$stmt = db()->prepare('SELECT * FROM folders WHERE id = ? AND is_missing = 0');
|
||||
@@ -423,7 +848,7 @@ function folderBreadcrumbs(array $folder): array
|
||||
function continueWatching(int $userId, int $limit = 6): array
|
||||
{
|
||||
$stmt = db()->prepare(
|
||||
'SELECT v.id, v.name, f.path AS folder_path,
|
||||
'SELECT v.id, v.name, v.folder_id, f.path AS folder_path,
|
||||
vp.position_seconds, vp.duration_seconds, vp.updated_at
|
||||
FROM video_progress vp
|
||||
JOIN videos v ON v.id = vp.video_id AND v.is_missing = 0
|
||||
@@ -436,6 +861,13 @@ function continueWatching(int $userId, int $limit = 6): array
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/** The top-level course a folder belongs to (itself, if it's already top-level). */
|
||||
function courseIdOf(array $folder): int
|
||||
{
|
||||
$crumbs = folderBreadcrumbs($folder);
|
||||
return (int)$crumbs[0]['id'];
|
||||
}
|
||||
|
||||
function formatDuration(?float $seconds): string
|
||||
{
|
||||
if ($seconds === null || $seconds <= 0) return '';
|
||||
|
||||
@@ -1,46 +1,85 @@
|
||||
<?php /* CourseHub SPA shell — all data flows through api/ via fetch. */ ?>
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#F2F0EA">
|
||||
<title>CourseHub</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.css" rel="stylesheet">
|
||||
<link href="assets/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-bold" href="#/">
|
||||
<i class="bi bi-collection-play-fill text-primary me-1"></i>CourseHub
|
||||
</a>
|
||||
<div id="nav-user" class="d-none d-flex align-items-center gap-3">
|
||||
<button id="btn-rescan" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Rescan library
|
||||
</button>
|
||||
<span class="text-secondary small">
|
||||
<i class="bi bi-person-circle me-1"></i><span id="nav-username"></span>
|
||||
<div class="app" id="app-shell" hidden>
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="#/">
|
||||
<span class="brand-mark">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
||||
</span>
|
||||
<button id="btn-logout" class="btn btn-sm btn-outline-danger">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<span class="brand-name">CourseHub</span>
|
||||
</a>
|
||||
|
||||
<main class="container pb-5">
|
||||
<div id="app">
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<nav class="nav" id="nav-main"></nav>
|
||||
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toasts"></div>
|
||||
<div class="sidebar-rule"></div>
|
||||
<div class="sidebar-label">Shelves</div>
|
||||
<div class="shelf-list" id="nav-shelves"></div>
|
||||
|
||||
<div class="sidebar-spacer"></div>
|
||||
<div class="scan-card">
|
||||
<div class="scan-card-head">
|
||||
<span>Library scanned</span>
|
||||
<span class="mono" id="scan-when">—</span>
|
||||
</div>
|
||||
<button class="btn btn-accent btn-block" id="btn-scan" data-scan>
|
||||
<span class="scan-spin" id="scan-spin">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-3-6.7"></path><path d="M21 4v5h-5"></path></svg>
|
||||
</span><span id="scan-label">Scan library</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<label class="search">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#807A70" stroke-width="1.7" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.2-3.2"></path></svg>
|
||||
<input id="search-input" type="search" autocomplete="off" placeholder="Search courses and videos" aria-label="Search the library">
|
||||
<button id="btn-search-clear" class="icon-btn" type="button" title="Clear search" hidden>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
</label>
|
||||
<button class="icon-btn scan-mobile" id="btn-scan-mobile" data-scan title="Scan library">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-3-6.7"></path><path d="M21 4v5h-5"></path></svg>
|
||||
</button>
|
||||
<div class="topbar-end">
|
||||
<span class="mono topbar-totals" id="topbar-totals"></span>
|
||||
<button class="avatar" id="btn-account" title="Account"></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="view" id="view">
|
||||
<div class="loading"><span class="spinner"></span></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<nav class="tabbar" id="nav-tabs"></nav>
|
||||
</div>
|
||||
|
||||
<!-- Holds the boot spinner until we know whether there is a session, then
|
||||
becomes either the sign-in card or is hidden in favour of the shell. -->
|
||||
<div class="auth" id="auth">
|
||||
<div class="loading"><span class="spinner"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="toasts" id="toasts"></div>
|
||||
<div id="overlay-root"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/animejs@3.2.2/lib/anime.min.js"></script>
|
||||
<script src="assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,4 +11,5 @@ if (PHP_SAPI !== 'cli') {
|
||||
$stats = scanLibrary();
|
||||
echo "Scan complete: {$stats['folders']} folders, {$stats['videos']} videos, {$stats['quizzes']} quizzes"
|
||||
. " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos"
|
||||
. " / {$stats['missing_quizzes']} quizzes flagged missing)\n";
|
||||
. " / {$stats['missing_quizzes']} quizzes flagged missing)\n"
|
||||
. "Thumbnails: {$stats['thumbnails_generated']} generated, {$stats['thumbnails_failed']} failed\n";
|
||||
|
||||
+14
@@ -9,8 +9,20 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Small key/value store for app-wide facts (currently: when the last scan ran).
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
k VARCHAR(64) PRIMARY KEY,
|
||||
v VARCHAR(255) NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- One row per directory found under VIDEOS_ROOT.
|
||||
-- `path` is the relative path from VIDEOS_ROOT, e.g. "Course 1/Intro".
|
||||
-- thumbnail_video_id/thumbnail_generated_at are only ever set on top-level
|
||||
-- (course) folders — see generateThumbnails() — and have no FK to videos
|
||||
-- (that would make folders<->videos a circular reference); a stale pointer
|
||||
-- is harmless and self-corrects on the next scan.
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
parent_id INT UNSIGNED NULL,
|
||||
@@ -19,6 +31,8 @@ CREATE TABLE IF NOT EXISTS folders (
|
||||
depth INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
is_missing TINYINT(1) NOT NULL DEFAULT 0,
|
||||
thumbnail_video_id INT UNSIGNED NULL,
|
||||
thumbnail_generated_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_folders_parent FOREIGN KEY (parent_id)
|
||||
REFERENCES folders(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
// Serves a cached course thumbnail — a frame captured from the course's first
|
||||
// video at THUMBNAIL_SEEK_PERCENT, generated by the scanner (see
|
||||
// generateThumbnails() in includes/functions.php). Looked up by folder ID
|
||||
// only, mirroring stream.php's containment check; 404s (no thumbnail yet, or
|
||||
// the id isn't a course) are expected and handled by the client falling back
|
||||
// to its colour-block placeholder.
|
||||
require_once __DIR__ . '/includes/auth.php';
|
||||
require_once __DIR__ . '/includes/functions.php';
|
||||
|
||||
$userId = currentUserId();
|
||||
if ($userId === null) {
|
||||
http_response_code(403);
|
||||
exit('Forbidden');
|
||||
}
|
||||
|
||||
$courseId = (int)($_GET['course_id'] ?? 0);
|
||||
$dir = realpath(THUMBNAILS_DIR);
|
||||
$file = ($courseId > 0 && $dir !== false)
|
||||
? realpath($dir . '/' . $courseId . '.jpg')
|
||||
: false;
|
||||
|
||||
if ($dir === false || $file === false || !str_starts_with($file, $dir . DIRECTORY_SEPARATOR)
|
||||
|| !is_file($file)) {
|
||||
http_response_code(404);
|
||||
exit('No thumbnail');
|
||||
}
|
||||
|
||||
$mtime = filemtime($file);
|
||||
$etag = '"' . md5($courseId . '-' . $mtime) . '"';
|
||||
|
||||
header('Cache-Control: private, max-age=86400');
|
||||
header('ETag: ' . $etag);
|
||||
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||
|
||||
if (($_SERVER['HTTP_IF_NONE_MATCH'] ?? null) === $etag) {
|
||||
http_response_code(304);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: image/jpeg');
|
||||
header('Content-Length: ' . filesize($file));
|
||||
readfile($file);
|
||||
Reference in New Issue
Block a user