From c4c88e52daa045939bcb43103fdaa3140f5d5969 Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Mon, 27 Jul 2026 13:58:47 -0400 Subject: [PATCH] UI improved --- README.md | 148 +++- api/library.php | 141 +++- api/reset.php | 29 + assets/app.js | 1530 +++++++++++++++++++++++++++++++--------- assets/style.css | 1197 ++++++++++++++++++++++++++++++- config.example.php | 7 + includes/functions.php | 452 +++++++++++- index.php | 89 ++- scan.php | 3 +- schema.sql | 30 +- thumb.php | 43 ++ 11 files changed, 3257 insertions(+), 412 deletions(-) create mode 100644 api/reset.php create mode 100644 thumb.php diff --git a/README.md b/README.md index fa54848..a47e711 100644 --- a/README.md +++ b/README.md @@ -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/` | 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/.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 + `` 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/`, 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` diff --git a/api/library.php b/api/library.php index 0910f87..db94e2d 100644 --- a/api/library.php +++ b/api/library.php @@ -1,7 +1,11 @@ courses + continue-watching -// 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=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,19 +57,83 @@ 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)), - 'continue' => array_map(fn($r) => [ + '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'], 'folder_path' => $r['folder_path'], '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']); diff --git a/api/reset.php b/api/reset.php new file mode 100644 index 0000000..37d0f71 --- /dev/null +++ b/api/reset.php @@ -0,0 +1,29 @@ + 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]); diff --git a/assets/app.js b/assets/app.js index 0d2d0e5..51bc233 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,20 +1,78 @@ -/* CourseHub SPA — hash router + fetch API + Plyr player. - * Routes: #/ home (courses, continue watching) - * #/folder/N browse a folder - * #/watch/N video player +/* CourseHub SPA — hash router + fetch API + Plyr player, on the + * "Course Library" design system. + * + * Routes: #/ home (resume, stats, in progress, last scan) + * #/library[/f] all courses, optionally filtered to one shelf + * #/folder/N browse a course or section + * #/watch/N video player + * #/quiz/N quiz + * #/search/Q search results (Q is URI-encoded) + * #/settings library, preferences, reset */ (function () { 'use strict'; - const app = document.getElementById('app'); - const state = { user: null }; - let cleanupView = null; // teardown hook for the active view (player etc.) + const shell = document.getElementById('app-shell'); + const authEl = document.getElementById('auth'); + const view = document.getElementById('view'); + const state = { + user: null, + meta: null, // totals / shelves / last scan, for the chrome + suppressSave: false, // set while a reset lands, so teardown doesn't re-save + searchSeq: 0, + scanning: false, + }; + let cleanupView = null; + + /* ---------- design tokens mirrored in JS ---------- */ + + // A course has no cover art, so it gets one of the palette's hues, picked + // from its id — stable across renders and reloads. + const COURSE_HUES = ['#B85A2E', '#3F7A50', '#374B5C', '#96691C', '#7A4560', '#2E6A8C']; + const hueOf = (id) => COURSE_HUES[Math.abs(id | 0) % COURSE_HUES.length]; + + 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' }; + + const ICONS = { + home: '', + grid: '', + list: '', + search: '', + settings: '', + scan: '', + reset: '', + back: '', + folder: '', + quiz: '', + check: '', + alert: '', + clock: '', + prev: '', + next: '', + logout: '', + close: '', + play: '', + list_ol: '', + }; + + // Sizes are the design's own; GLYPH_SCALE keeps glyphs in step with the type + // scale in style.css, which runs larger than the fixed-width design canvas. + const GLYPH_SCALE = 1.15; + const glyph = (size) => Math.round(size * GLYPH_SCALE); + + const icon = (name, size = 17, width = 1.6) => + `${ICONS[name]}`; + const iconFill = (name, size = 12) => + `${ICONS[name]}`; /* ---------- helpers ---------- */ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + /** m:ss / h:mm:ss — for a single video. */ function fmt(seconds) { if (!seconds || seconds <= 0) return ''; const s = Math.round(seconds); @@ -23,22 +81,70 @@ : `${m}:${String(sec).padStart(2, '0')}`; } - function toast(message, kind = 'success') { - const wrap = document.createElement('div'); - wrap.innerHTML = ` - `; - const el = wrap.firstElementChild; - document.getElementById('toasts').appendChild(el); - const t = new bootstrap.Toast(el, { delay: 4000 }); - el.addEventListener('hidden.bs.toast', () => el.remove()); - t.show(); + /** 6h 10m / 45m — for a course's total running time. */ + function fmtLong(seconds) { + if (!seconds || seconds <= 0) return ''; + const m = Math.round(seconds / 60); + const h = Math.floor(m / 60); + return h ? `${h}h ${String(m % 60).padStart(2, '0')}m` : `${m}m`; } + const fmtHours = (seconds) => { + if (!seconds || seconds < 60) return '0h'; + const h = seconds / 3600; + return h < 1 ? `${Math.round(seconds / 60)}m` : `${h < 10 ? h.toFixed(1) : Math.round(h)}h`; + }; + + function fmtBytes(bytes) { + if (!bytes) return ''; + const gb = bytes / 1e9; + return gb >= 1 ? `${gb.toFixed(1)} gb` : `${Math.max(1, Math.round(bytes / 1e6))} mb`; + } + + /** "4h ago" — server timestamps are UTC. */ + function relTime(iso) { + if (!iso) return 'never'; + const then = new Date(String(iso).replace(' ', 'T') + 'Z').getTime(); + if (!then) return 'never'; + const mins = Math.max(0, Math.round((Date.now() - then) / 60000)); + if (mins < 2) return 'now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.round(hrs / 24); + return days < 30 ? `${days}d ago` : `${Math.round(days / 30)}mo ago`; + } + + const courseMeta = (c) => { + const bits = []; + if (c.videos) bits.push(`${c.videos} video${c.videos === 1 ? '' : 's'}`); + if (c.quizzes) bits.push(`${c.quizzes} quiz${c.quizzes === 1 ? '' : 'zes'}`); + if (c.duration) bits.push(fmtLong(c.duration)); + return bits.join(' · '); + }; + + const initialOf = (name) => (String(name || '?').trim()[0] || '?').toUpperCase(); + + /* ---------- preferences ---------- */ + + const PREF_DEFAULTS = { view: 'grid', layout: 'shelves', autoplay: true, motion: true }; + + function pref(key) { + try { + const raw = localStorage.getItem('coursehub.' + key); + if (raw === null) return PREF_DEFAULTS[key]; + return raw === 'true' ? true : raw === 'false' ? false : raw; + } catch (e) { + return PREF_DEFAULTS[key]; // storage blocked (private mode) + } + } + + function setPref(key, value) { + try { localStorage.setItem('coursehub.' + key, String(value)); } catch (e) { /* not persisted */ } + } + + /* ---------- api ---------- */ + async function api(url, options = {}) { if (options.json) { options.method = options.method || 'POST'; @@ -50,76 +156,351 @@ const data = await res.json().catch(() => ({})); if (res.status === 401) { state.user = null; - updateNav(); - renderLogin(); + showAuth(); throw new Error(data.error || 'Not logged in'); } if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`); return data; } - function spinner() { - app.innerHTML = '
'; + const spinner = () => { view.innerHTML = '
'; }; + + /* ---------- motion (anime.js) ---------- + * Skipped when anime.js is unavailable, when the OS asks for reduced motion, + * or when the user turned animations off in settings. */ + + const canAnimate = () => + typeof anime !== 'undefined' && pref('motion') !== false && + !window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + const clearInline = (els) => els.forEach((el) => { el.style.opacity = ''; el.style.transform = ''; }); + + function reveal(root = view) { + if (!canAnimate()) return; + const items = [...root.querySelectorAll('.course-card, .row, .row-card, .card, .q-card, .hero, .settings-section')]; + items.forEach((el) => { el.style.opacity = '0'; }); // before the frame paints + + anime({ targets: root, opacity: [0, 1], translateY: [8, 0], duration: 260, + easing: 'easeOutQuad', complete: () => clearInline([root]) }); + if (items.length) { + anime({ targets: items, opacity: [0, 1], translateY: [10, 0], duration: 420, + delay: anime.stagger(26, { start: 70 }), easing: 'easeOutCubic', + complete: () => clearInline(items) }); + } + growBars(root); } - function progressBar(percent, cls = 'bg-success') { - return `
-
-
`; + /** Progress bars grow from zero, so a reset is visible as movement. */ + function growBars(root = view) { + if (!canAnimate()) return; + const bars = [...root.querySelectorAll('.bar > span, .thumb-bar > span')]; + if (!bars.length) return; + bars.forEach((b) => { b.dataset.pct = b.style.width || '0%'; b.style.width = '0%'; }); + anime({ targets: bars, width: (el) => el.dataset.pct, duration: 700, + delay: anime.stagger(28, { start: 110 }), easing: 'easeOutQuart' }); } - function breadcrumbs(crumbs, tail = '') { - const items = crumbs.map((c, i) => - (i === crumbs.length - 1 && !tail) - ? `` - : `` - ).join(''); - return ``; + /* ---------- toast / dialog / menu ---------- */ + + function toast(message, kind = 'ok') { + const el = document.createElement('div'); + el.className = 'toast' + (kind === 'error' ? ' is-error' : ''); + el.innerHTML = `${icon(kind === 'error' ? 'alert' : 'check', 16, 2)} + ${esc(message)}`; + document.getElementById('toasts').appendChild(el); + setTimeout(() => { + if (canAnimate()) { + anime({ targets: el, opacity: 0, translateY: 8, duration: 200, easing: 'easeInQuad', + complete: () => el.remove() }); + } else { + el.remove(); + } + }, 3200); } - /* ---------- nav / auth ---------- */ + /** Resolves true only if the user confirms. */ + function confirmDialog({ title, body, confirmLabel = 'Confirm', danger = true }) { + return new Promise((resolve) => { + const host = document.createElement('div'); + host.className = 'overlay'; + host.innerHTML = ` + `; + document.getElementById('overlay-root').appendChild(host); - function updateNav() { - const navUser = document.getElementById('nav-user'); - navUser.classList.toggle('d-none', !state.user); - document.getElementById('nav-username').textContent = state.user || ''; + const close = (result) => { + document.removeEventListener('keydown', onKey); + host.remove(); + resolve(result); + }; + const onKey = (e) => { if (e.key === 'Escape') close(false); }; + host.querySelector('[data-confirm]').addEventListener('click', () => close(true)); + host.querySelector('[data-cancel]').addEventListener('click', () => close(false)); + host.addEventListener('click', (e) => { if (e.target === host) close(false); }); + document.addEventListener('keydown', onKey); + host.querySelector('[data-confirm]').focus(); + }); + } + + function openMenu(anchor, html, onClick) { + closeMenu(); + const menu = document.createElement('div'); + menu.className = 'menu'; + menu.id = 'account-menu'; + menu.innerHTML = html; + document.body.appendChild(menu); + + const r = anchor.getBoundingClientRect(); + menu.style.top = `${Math.round(r.bottom + 8)}px`; + menu.style.left = `${Math.round(Math.min(r.right - menu.offsetWidth, window.innerWidth - menu.offsetWidth - 12))}px`; + + menu.addEventListener('click', (e) => { + const btn = e.target.closest('button[data-action]'); + if (!btn) return; + closeMenu(); + onClick(btn.dataset.action); + }); + setTimeout(() => { + document.addEventListener('click', onDocClick, { once: true }); + document.addEventListener('keydown', onMenuKey); + }, 0); + } + + const onDocClick = () => closeMenu(); + const onMenuKey = (e) => { if (e.key === 'Escape') closeMenu(); }; + + function closeMenu() { + const el = document.getElementById('account-menu'); + if (el) el.remove(); + document.removeEventListener('keydown', onMenuKey); + } + + /* ---------- chrome (sidebar, tab bar, header) ---------- */ + + const NAV = [ + { key: 'home', href: '#/', label: 'Home', glyph: 'home' }, + { key: 'library', href: '#/library', label: 'Library', glyph: 'grid' }, + { key: 'search', href: '#/search/', label: 'Search', glyph: 'search' }, + { key: 'settings', href: '#/settings', label: 'Settings', glyph: 'settings' }, + ]; + + function activeNav() { + const h = location.hash || '#/'; + if (h.startsWith('#/search')) return 'search'; + if (h.startsWith('#/settings')) return 'settings'; + if (h.startsWith('#/library') || h.startsWith('#/folder') || + h.startsWith('#/watch') || h.startsWith('#/quiz')) return 'library'; + return 'home'; + } + + function renderChrome() { + const active = activeNav(); + + document.getElementById('nav-main').innerHTML = NAV.map((n) => ` + + ${icon(n.glyph)}${n.label} + `).join(''); + + document.getElementById('nav-tabs').innerHTML = NAV.map((n) => ` + + ${icon(n.glyph, 20)}${n.label} + `).join(''); + + const shelves = state.meta ? state.meta.shelves : []; + document.getElementById('nav-shelves').innerHTML = shelves.map((s) => ` + + + ${esc(s.name)} + ${s.count} + `).join(''); + + const totals = state.meta && state.meta.totals; + document.getElementById('topbar-totals').textContent = totals + ? [`${totals.courses} course${totals.courses === 1 ? '' : 's'}`, + fmtBytes(totals.bytes)].filter(Boolean).join(' · ') + : ''; + + document.getElementById('scan-when').textContent = + state.scanning ? 'now' : relTime(state.meta && state.meta.last_scan); + + const avatar = document.getElementById('btn-account'); + avatar.textContent = (state.user || '?').slice(0, 2).toUpperCase(); + avatar.style.background = hueOf((state.user || '').length + 4); + } + + async function loadMeta() { + try { + state.meta = await api('api/library.php?view=meta'); + } catch (e) { + state.meta = null; // the view itself will surface the error + } + renderChrome(); + } + + /* ---------- shared pieces ---------- */ + + const bar = (percent, hue) => + `
`; + + // A course thumbnail is a real captured frame when the scanner made one + // (see generateThumbnails() server-side); until then, or if the request + // 404s, onerror drops the broken and the colour block underneath + // (already painted — cat/dur/play/percent all sit above it) shows through. + function thumb(hue, { cat, dur, play, percent, courseId } = {}) { + return `
+ ${courseId ? `` : ''} + ${cat ? `${esc(cat)}` : ''} + ${dur ? `${esc(dur)}` : ''} + ${play ? `${iconFill('play', 20)}` : ''} + ${percent !== undefined + ? `
` : ''} +
`; + } + + function courseCard(c) { + const hue = hueOf(c.id); + return ` + ${thumb(hue, { cat: SHELF_NAME[c.state], dur: fmtLong(c.duration), courseId: c.id })} +
+
${esc(c.name)}
+
${esc(courseMeta(c))}
+
+ ${bar(c.progress.percent, hue)} + ${c.progress.percent}% +
+
+
`; + } + + function courseRow(c) { + const hue = hueOf(c.id); + return ``; + } + + const courseList = (courses, mode) => mode === 'list' + ? `
${courses.map(courseRow).join('')}
` + : `
${courses.map(courseCard).join('')}
`; + + function viewToggle() { + const mode = pref('view'); + const btn = (m, glyph, label) => ` + `; + return `
${btn('grid', 'grid', 'Grid view')}${btn('list', 'list', 'List view')}
`; + } + + const resetBtn = (scope, id, label, text = 'Reset progress') => + ``; + + function crumbs(list, tail) { + const parts = list.map((c, i) => + (i === list.length - 1 && !tail) + ? `${esc(c.name)}` + : `${esc(c.name)}`); + return ``; + } + + const videoRow = (v, extra = '') => { + const cls = v.completed ? ' is-done' : (v.position > 5 ? ' is-partial' : ''); + return ` + ${iconFill('play', 12)} +
+
${esc(v.name)}
+ ${extra ? `
${esc(extra)}
` : ''} + ${!v.completed && v.percent > 0 ? `
${bar(v.percent)}
` : ''} +
+ ${esc(fmt(v.duration))} +
`; + }; + + const quizRow = (q, extra = '') => ` + + ${icon('quiz', 14, 1.7)} +
+
${esc(q.name)}
+
${extra ? esc(extra) + ' · ' : ''}Quiz · ${q.questions} question${q.questions === 1 ? '' : 's'}
+
+ ${q.attempts ? `${q.best_score}/${q.questions}` : ''} +
`; + + /* ---------- auth ---------- */ + + function showAuth() { + if (cleanupView) { cleanupView(); cleanupView = null; } + shell.hidden = true; + authEl.hidden = false; + renderLogin(); + } + + function showShell() { + authEl.hidden = true; + shell.hidden = false; } function renderLogin(error = '') { - if (cleanupView) { cleanupView(); cleanupView = null; } - app.innerHTML = ` -
-
-
-
-

CourseHub

-
${error ? `
${esc(error)}
` : ''}
-
-
- - -
-
- - -
-
- - -
-
-
-
+ authEl.innerHTML = ` +
+
+ ${iconFill('play', 15)} + CourseHub
+

Your course library.

+

Sign in to pick up where you stopped.

+
${error ? `
${esc(error)}
` : ''}
+
+
+ + +
+
+ + +
+
+ + +
+
`; const form = document.getElementById('login-form'); let action = 'login'; form.querySelectorAll('button[data-action]').forEach((b) => - b.addEventListener('click', () => { action = b.dataset.action; })); + b.addEventListener('click', () => { + action = b.dataset.action; + if (action === 'register' && form.reportValidity()) form.requestSubmit(); + })); + form.addEventListener('submit', async (e) => { e.preventDefault(); const fd = new FormData(form); @@ -128,278 +509,591 @@ json: { action, username: fd.get('username'), password: fd.get('password') }, }); state.user = data.username; - updateNav(); - toast(action === 'register' ? 'Account created — welcome!' : `Welcome back, ${data.username}!`); + showShell(); + toast(action === 'register' ? 'Account created — welcome.' : `Welcome back, ${data.username}.`); + await loadMeta(); route(); } catch (err) { - document.getElementById('login-error').innerHTML = - `
${esc(err.message)}
`; + document.getElementById('login-error').innerHTML = `
${esc(err.message)}
`; } }); } - /* ---------- views ---------- */ + /* ---------- home ---------- */ + + function todayLine() { + const d = new Date(); + const weekday = d.toLocaleDateString(undefined, { weekday: 'long' }); + const month = d.toLocaleDateString(undefined, { month: 'long' }); + return `${weekday}, ${d.getDate()} ${month}`; + } + + function heroBlock(resume) { + if (!resume) { + return `
+

Nothing in progress

+

Open a course and start a video — it will show up here with your place kept.

+ Browse the library +
`; + } + const pct = resume.duration ? Math.min(100, Math.round(100 * resume.position / resume.duration)) : 0; + const left = resume.duration ? Math.max(0, resume.duration - resume.position) : 0; + const hue = hueOf(resume.course_id ?? resume.id); + return `
+
${thumb(hue, { play: true, percent: pct, courseId: resume.course_id })}
+
+ Continue watching +
${esc(resume.name)}
+
${esc(resume.folder_path)}
+
+ ${bar(pct)} + ${pct}%${left ? ` · ${fmt(left)} left` : ''} +
+ +
+
`; + } + + function statCards(stats) { + const cards = [ + { label: 'In progress', value: String(stats.in_progress), sub: `of ${stats.courses} course${stats.courses === 1 ? '' : 's'}`, hue: '#B85A2E' }, + { label: 'Finished', value: String(stats.finished), sub: stats.finished ? 'courses complete' : 'nothing finished yet', hue: '#3F7A50' }, + { label: 'Watched', value: fmtHours(stats.watched_seconds), sub: 'across the library', hue: '#2E6A8C' }, + stats.quizzes_taken + ? { label: 'Quiz average', value: `${stats.quiz_average}%`, sub: `${stats.quizzes_taken} quiz${stats.quizzes_taken === 1 ? '' : 'zes'} taken`, hue: '#7A4560' } + : { label: 'Quiz average', value: '—', sub: 'no quizzes taken yet', hue: '#7A4560' }, + ]; + return `
${cards.map((s) => ` +
+
${s.label}
+
${esc(s.value)}
+
${esc(s.sub)}
+
`).join('')}
`; + } async function renderHome() { spinner(); const data = await api('api/library.php?view=home'); - const cont = data.continue.length ? ` -

Continue watching

-
- ${data.continue.map((r) => { - const pct = r.duration ? Math.min(100, Math.round(100 * r.position / r.duration)) : 0; - return ` - `; - }).join('')} + const inProgress = data.in_progress.slice(0, 4); + const progressBlock = inProgress.length + ? `
${inProgress.map(courseCard).join('')}
` + : `
+

No course started

+

Everything in the library is still untouched.

+ Open the library +
`; + + const recentBlock = data.recent.length ? ` +

Found in the last scan

+ ` : ''; - const courses = data.courses.length ? data.courses.map((c) => ` - `).join('') - : `
- No courses found yet. Add course folders to the videos directory and click - Rescan library.
`; + view.innerHTML = ` +
+
${esc(todayLine())}
+

Pick up where you stopped.

+ ${heroBlock(data.resume)} + ${statCards(data.stats)} +
+

In progress

+ All courses +
+ ${progressBlock} + ${recentBlock} +
`; - app.innerHTML = `${cont} -

Courses

-
${courses}
`; + reveal(); } + /* ---------- library ---------- */ + + async function renderLibrary(filter) { + spinner(); + const data = await api('api/library.php?view=library'); + + const layout = filter && filter !== 'all' ? 'catalog' : pref('layout'); + const mode = pref('view'); + const active = filter || 'all'; + const shown = active === 'all' ? data.courses : data.courses.filter((c) => c.state === active); + + const chips = ['all', 'progress', 'finished', 'fresh'].map((k) => ` + + ${esc(SHELF_NAME[k])} + `).join(''); + + let body; + if (!data.courses.length) { + body = `
+

The library is empty

+

Add course folders to the videos directory, then scan.

+ +
`; + } else if (layout === 'shelves') { + body = data.shelves.map((s) => ` +
+
+ +

${esc(s.name)}

+ ${s.courses.length} course${s.courses.length === 1 ? '' : 's'} +
+
+ ${courseList(s.courses, mode)} +
`).join(''); + } else { + body = `
${chips}
` + (shown.length + ? courseList(shown, mode) + : `

Nothing on this shelf

+

No course is ${esc(SHELF_NAME[active].toLowerCase())} right now.

+ Show every course
`); + } + + const totalVideos = data.courses.reduce((n, c) => n + c.videos, 0); + + view.innerHTML = ` +
+
+
+

Library

+

${data.courses.length} course${data.courses.length === 1 ? '' : 's'} · + ${totalVideos} video${totalVideos === 1 ? '' : 's'} on this machine

+
+
+
+ + +
+ ${viewToggle()} +
+
+ ${body} +
`; + + reveal(); + } + + /* ---------- folder ---------- */ + async function renderFolder(id) { spinner(); const data = await api(`api/library.php?view=folder&id=${id}`); + const mode = pref('view'); + const hue = hueOf(data.course_id); - const subfolders = data.subfolders.length ? ` -

Sections

- ` : ''; + const sections = data.subfolders.length ? ` +
+

Sections

+ ${viewToggle()} +
+
${courseList(data.subfolders, mode)}
` : ''; 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' })); + ].sort((a, b) => a.filename.localeCompare(b.filename, undefined, { numeric: true, sensitivity: 'base' })); const content = items.length ? ` -

Content

-
- ${items.map((it) => { - if (it.kind === 'quiz') { - const icon = it.completed - ? '' - : ''; - return ` - - ${icon} -
-
${esc(it.name)}
-
- ${it.attempts ? ` - ${it.best_score}/${it.questions}` : ''} - Quiz · ${it.questions} questions -
`; - } - const icon = it.completed - ? '' - : (it.position > 5 - ? '' - : ''); - return ` - - ${icon} -
-
${esc(it.name)}
- ${!it.completed && it.percent > 0 ? `
${progressBar(it.percent, '')}
` : ''} -
- ${fmt(it.duration)} -
`; - }).join('')} -
` : (!data.subfolders.length ? '
This folder is empty.
' : ''); +

Content

+
${items.map((it) => it.kind === 'quiz' ? quizRow(it) : videoRow(it)).join('')}
` + : (!data.subfolders.length + ? `

This folder is empty

+

No videos or quizzes were found here in the last scan.

` : ''); - app.innerHTML = ` - ${breadcrumbs(data.breadcrumbs)} -
-

${esc(data.folder.name)}

-
${data.progress.completed} / ${data.progress.total} completed
-
-
${progressBar(data.progress.percent)}
- ${subfolders}${content}`; - } + const meta = [ + 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), + fmtBytes(data.rollup.bytes), + ].filter(Boolean).join(' · '); - async function renderQuiz(id) { - spinner(); - const data = await api(`api/quiz.php?id=${id}`); - - const qHtml = data.questions.map((q, qi) => ` -
-
-
- ${qi + 1}. ${esc(q.text)} - ${q.multi ? 'multiple answers' : ''} + view.innerHTML = ` +
+ ${crumbs(data.breadcrumbs)} +
+
+

${esc(data.folder.name)}

+

${esc(meta || 'Empty folder')}

+
+
+ + ${data.progress.completed} / ${data.progress.total} done + + ${data.progress.total ? resetBtn('folder', data.folder.id, data.folder.name) : ''}
- ${q.options.map((o, oi) => ` -
- - - -
`).join('')} -
-
`).join(''); - - const best = data.progress.attempts - ? `Best score: ${data.progress.best_score}/${data.progress.total}` + - (data.progress.completed ? ' ' : '') - : 'Not attempted yet'; - - app.innerHTML = ` - ${breadcrumbs(data.breadcrumbs, data.quiz.name)} -
-

${esc(data.quiz.name)}

-
${best}
-
-
- ${qHtml} -
- - - Back to ${esc(data.folder.name)} - - +
+ ${bar(data.progress.percent, hue)} + ${data.progress.percent}%
- `; + ${sections}${content} +
`; - const form = document.getElementById('quiz-form'); - let graded = false; - - form.addEventListener('submit', async (e) => { - e.preventDefault(); - if (graded) { renderQuiz(id); return; } // "Try again" re-renders fresh - - const answers = data.questions.map((q, qi) => - [...form.querySelectorAll(`input[name="q${qi}"]:checked`)].map((i) => +i.value)); - - const res = await api('api/quiz.php', { json: { quiz_id: id, answers } }); - graded = true; - - res.results.forEach((r, qi) => { - const card = form.querySelector(`[data-qi="${qi}"]`); - r.correct_options.forEach((oi) => { - const row = card.querySelector(`[data-oi="${oi}"]`); - row.querySelector('label').classList.add('text-success', 'fw-semibold'); - row.querySelector('.quiz-mark').innerHTML = ''; - }); - r.selected.filter((oi) => !r.correct_options.includes(oi)).forEach((oi) => { - const row = card.querySelector(`[data-oi="${oi}"]`); - row.querySelector('label').classList.add('text-danger'); - row.querySelector('.quiz-mark').innerHTML = ''; - }); - const fb = card.querySelector('.quiz-feedback'); - fb.innerHTML = r.correct - ? 'Correct' - : 'Incorrect'; - card.classList.add('border', r.correct ? 'border-success' : 'border-danger'); - }); - - form.querySelectorAll('input').forEach((i) => { i.disabled = true; }); - document.getElementById('quiz-score').textContent = `Score: ${res.score}/${res.total}`; - const btn = document.getElementById('quiz-submit'); - btn.innerHTML = 'Try again'; - btn.classList.replace('btn-primary', 'btn-outline-primary'); - toast(res.completed - ? `Perfect score — ${res.score}/${res.total}!` - : `Scored ${res.score}/${res.total}.`, - res.completed ? 'success' : 'secondary'); - }); + reveal(); } + /* ---------- search ---------- */ + + async function renderSearch(query) { + const seq = ++state.searchSeq; + + if (!query) { + view.innerHTML = ` +
+

Search the library

+

Type to match course titles, folder paths, and individual video names.

+
`; + reveal(); + document.getElementById('search-input').focus(); + return; + } + + spinner(); + const data = await api(`api/library.php?view=search&q=${encodeURIComponent(query)}`); + if (seq !== state.searchSeq) return; // a newer keystroke already won + + const total = data.folders.length + data.videos.length + data.quizzes.length; + + const section = (title, count, html) => !count ? '' : ` +
+
${title}
+ ${html} +
`; + + const courses = ``; + + const videos = `
${data.videos.map((v) => videoRow(v, v.folder_path)).join('')}
`; + const quizzes = `
${data.quizzes.map((q) => quizRow(q, q.folder_path)).join('')}
`; + + view.innerHTML = ` +
+

${total} result${total === 1 ? '' : 's'} for “${esc(query)}”

+

Matches across course titles, folder paths, and video names.

+ ${total === 0 ? ` +
+

No files match “${esc(query)}”

+

Try a different word, or scan the library for new files.

+ +
` + : section('Courses and sections', data.folders.length, courses) + + section('Videos', data.videos.length, videos) + + section('Quizzes', data.quizzes.length, quizzes)} +
`; + + reveal(); + } + + /* ---------- watch ---------- */ + async function renderWatch(id) { spinner(); const data = await api(`api/library.php?view=video&id=${id}`); const playlist = data.playlist.map((p) => { const active = p.id === data.video.id; - const icon = p.completed - ? `` - : (active ? '' : ''); - return ` - - ${icon} - ${esc(p.name)} - ${fmt(p.duration)} - `; + const cls = p.completed ? ' is-done' : (p.position > 5 ? ' is-partial' : ''); + return ` + ${iconFill('play', 11)} +
${esc(p.name)}
+ ${esc(fmt(p.duration))} +
`; }).join(''); - app.innerHTML = ` - ${breadcrumbs(data.breadcrumbs, data.video.name)} -
-
-
- -
-
-

${esc(data.video.name)}

-
- ${data.prev_id ? ` - Previous` : ''} - ${data.next_id ? ` - Next` : ''} + const quizzes = data.quizzes.length ? ` +
+
${icon('quiz', 15, 1.7)}Quizzes in this section
+
${data.quizzes.map((q) => quizRow(q)).join('')}
+
` : ''; + + view.innerHTML = ` +
+ ${crumbs(data.breadcrumbs, data.video.name)} +
+
+
+
+
+

${esc(data.video.name)}

+
+ ${data.prev_id ? `${icon('prev', 15, 1.7)}Previous` : ''} + ${data.next_id ? `Next${icon('next', 15, 1.7)}` : ''} + ${resetBtn('video', data.video.id, data.video.name, 'Reset')} +
+
+ ${data.resume_at > 0 + ? `
${icon('clock', 14)}Resuming from ${fmt(data.resume_at)}
` : ''} +
- ${data.resume_at > 0 - ? `
- Resuming from ${fmt(data.resume_at)}
` : ''} -
-
-
-
-
${esc(data.folder.name)}
-
${playlist}
+
+
+
${icon('list_ol', 15, 1.7)}${esc(data.folder.name)}
+
${playlist}
+
+ ${quizzes}
`; + reveal(); cleanupView = initPlayer(data); } + /* ---------- quiz ---------- */ + + async function renderQuiz(id) { + spinner(); + const data = await api(`api/quiz.php?id=${id}`); + + const answers = data.questions.map(() => []); + let graded = null; // server results once submitted + + function optionClass(qi, oi) { + const picked = answers[qi].includes(oi); + if (!graded) return picked ? ' is-picked' : ''; + const r = graded.results[qi]; + if (r.correct_options.includes(oi)) return ' is-correct'; + if (r.selected.includes(oi)) return ' is-missed'; + return ''; + } + + function draw() { + const questions = data.questions.map((q, qi) => { + const r = graded ? graded.results[qi] : null; + const cardCls = r ? (r.correct ? ' is-right' : ' is-wrong') : ''; + return `
+
+ ${qi + 1} +
+
${esc(q.text)}
+ ${q.multi && !r ? '
Select every answer that applies.
' : ''} + ${r ? `
${r.correct + ? 'Correct.' : 'Not quite — the right answer is highlighted below.'}
` : ''} +
+
+
+ ${q.options.map((o, oi) => { + const tag = graded + ? (graded.results[qi].correct_options.includes(oi) ? 'Correct' + : (graded.results[qi].selected.includes(oi) ? 'Your answer' : '')) + : ''; + return ``; + }).join('')} +
+
`; + }).join(''); + + const answered = answers.filter((a) => a.length).length; + const scoreBlock = graded ? (() => { + const pass = graded.completed; + return `
+
${graded.score}/${graded.total}
+
+
${pass ? 'Quiz passed' : 'Not passed yet'}
+
${pass + ? 'Every question answered correctly — this quiz counts as complete.' + : `${graded.total - graded.score} question${graded.total - graded.score === 1 ? '' : 's'} to get right for a complete pass.`}
+
+ +
`; + })() : ''; + + view.innerHTML = ` +
+ ${icon('back', 14, 1.8)}Back to ${esc(data.folder.name)} +
${esc(data.folder.name)}
+

${esc(data.quiz.name)}

+

+ ${data.questions.length} question${data.questions.length === 1 ? '' : 's'}. + Answer them all, then submit — nothing is graded until you do. + ${data.progress.attempts ? `Best so far ${data.progress.best_score}/${data.progress.total}.` : ''} +

+ ${scoreBlock} +
${questions}
+
+ + ${graded + ? `${graded.score} of ${graded.total} correct` + : `${answered} of ${data.questions.length} answered`} + ${data.progress.attempts ? resetBtn('quiz', data.quiz.id, data.quiz.name, 'Reset attempts') : ''} +
+
`; + reveal(); + } + + view.onclick = null; + draw(); + + // One delegated handler for the whole quiz view, re-bound on each draw. + const onClick = async (e) => { + const opt = e.target.closest('.q-option'); + if (opt && !graded) { + const qi = +opt.dataset.qi, oi = +opt.dataset.oi; + const multi = data.questions[qi].multi; + if (multi) { + const at = answers[qi].indexOf(oi); + if (at === -1) answers[qi].push(oi); else answers[qi].splice(at, 1); + } else { + answers[qi] = [oi]; + } + draw(); + return; + } + if (e.target.closest('[data-quiz-submit]') && !graded) { + try { + graded = await api('api/quiz.php', { json: { quiz_id: id, answers } }); + draw(); + markGraded(); + toast(graded.completed + ? `Perfect score — ${graded.score}/${graded.total}.` + : `Scored ${graded.score}/${graded.total}.`, graded.completed ? 'ok' : 'error'); + loadMeta(); + } catch (err) { + toast(err.message, 'error'); + } + return; + } + if (e.target.closest('[data-quiz-retry]')) { + graded = null; + answers.forEach((a, i) => { answers[i] = []; }); + draw(); + } + }; + view.addEventListener('click', onClick); + cleanupView = () => view.removeEventListener('click', onClick); + + /** Correct cards settle into place; wrong ones shake their heads. */ + function markGraded() { + if (!canAnimate()) return; + graded.results.forEach((r, qi) => { + const card = view.querySelector(`[data-qi="${qi}"].q-card`); + if (!card) return; + if (r.correct) { + anime({ targets: card, scale: [0.98, 1], duration: 420, delay: qi * 45, + easing: 'easeOutBack', complete: () => clearInline([card]) }); + } else { + anime({ targets: card, duration: 440, delay: qi * 45, easing: 'easeInOutSine', + keyframes: [{ translateX: -7 }, { translateX: 6 }, { translateX: -4 }, { translateX: 0 }], + complete: () => clearInline([card]) }); + } + }); + } + } + + /* ---------- settings ---------- */ + + async function renderSettings() { + spinner(); + const data = await api('api/library.php?view=settings'); + + const toggles = [ + { key: 'autoplay', label: 'Autoplay next video', + sub: 'Starts the next lesson a moment after one ends.' }, + { key: 'motion', label: 'Animate transitions', + sub: 'Fades views in and grows progress bars. Off is always honoured when your system asks for reduced motion.' }, + { key: 'list-default', label: 'Open the library in list view', + sub: 'Otherwise courses open as a grid of cards.' }, + ]; + + const isOn = (key) => key === 'list-default' ? pref('view') === 'list' : pref(key) !== false; + + view.innerHTML = ` +
+

Settings

+ +
+

Library folder

+

Scanned for courses, videos and quizzes on demand.

+
+
+ ${icon('folder', 16)} + ${esc(data.root)} + ${data.totals.courses} course${data.totals.courses === 1 ? '' : 's'} +
+
+
Indexed + ${data.totals.videos} videos · ${data.totals.quizzes} quizzes · ${esc(fmtBytes(data.totals.bytes) || '0 mb')}
+
Total running time + ${esc(fmtLong(data.totals.duration) || '—')}
+
A video counts as watched at + ${data.complete_threshold}%
+
Last scan + ${esc(relTime(data.last_scan))}
+ ${data.root_exists ? '' : `
+ This folder is not readable right now — scanning will fail until it is back.
`} +
+ +
+
+ +
+

Playback and display

+

Stored in this browser, for this device.

+ ${toggles.map((t) => ` +
+
+
${esc(t.label)}
+
${esc(t.sub)}
+
+ +
`).join('')} +
+ +
+

Reset progress

+

You have progress on ${data.progress_courses} of ${data.courses} course${data.courses === 1 ? '' : 's'}. + Resetting clears watched marks, resume positions and quiz scores. Video files are never touched.

+
+ ${resetBtn('all', 0, '', 'Reset all progress')} + + Or reset one course from its row in the library list. +
+
+ +
+

Account

+

Signed in as ${esc(data.username || '')}. + ${data.registration_open ? 'New accounts can be created from the sign-in page.' + : 'Registration is closed on this install.'}

+ +
+
`; + + reveal(); + } + /* ---------- player + progress tracking ---------- */ function initPlayer(data) { @@ -429,23 +1123,24 @@ duration: el.duration || 0, }); + const status = (html) => { if (statusEl) statusEl.innerHTML = html; }; + async function save() { const p = payload(); + if (state.suppressSave) return; // a reset just wiped this row — don't recreate it if (!p.duration || Math.abs(p.position - lastSaved) < 1) return; lastSaved = p.position; try { const res = await api('api/progress.php', { json: p, keepalive: true }); - if (statusEl) { - statusEl.innerHTML = res.completed - ? 'Completed' - : 'Progress saved'; - } - } catch (e) { /* transient — next heartbeat retries */ } + status(res.completed + ? `${icon('check', 14, 2)}Completed` + : `${icon('clock', 14)}Progress saved`); + } catch (e) { /* transient — the next heartbeat retries */ } } function saveBeacon() { const p = payload(); - if (!p.duration) return; + if (state.suppressSave || !p.duration) return; navigator.sendBeacon('api/progress.php', new URLSearchParams(Object.entries(p).map(([k, v]) => [k, String(v)]))); } @@ -455,8 +1150,8 @@ player.on('seeked', save); player.on('ended', () => { save(); - if (data.next_id) { - if (statusEl) statusEl.textContent = 'Playing next video…'; + if (data.next_id && pref('autoplay') !== false) { + status(`${icon('next', 14, 1.7)}Playing next video…`); setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500); } }); @@ -465,7 +1160,7 @@ document.addEventListener('visibilitychange', onHide); window.addEventListener('pagehide', saveBeacon); - return function cleanup() { // called when navigating away + return function cleanup() { saveBeacon(); if (saveTimer) clearInterval(saveTimer); document.removeEventListener('visibilitychange', onHide); @@ -474,52 +1169,221 @@ }; } + /* ---------- reset ---------- */ + + const RESET_COPY = { + all: () => ['Reset all progress?', + 'Every saved position, watched mark and quiz result on your account is deleted. ' + + 'Your video files stay where they are.'], + folder: (label) => [`Reset progress on “${label}”?`, + 'Saved positions, watched marks and quiz results inside this folder, including its ' + + 'subfolders, are deleted. Your video files stay where they are.'], + video: (label) => [`Reset progress on “${label}”?`, + 'The saved position and watched mark for this video are deleted.'], + quiz: (label) => [`Reset attempts on “${label}”?`, + 'Your attempts, best score and completion mark for this quiz are deleted.'], + }; + + async function doReset(scope, id, label) { + const copy = RESET_COPY[scope]; + if (!copy) return; + const [title, body] = copy(label); + if (!await confirmDialog({ title, body, confirmLabel: 'Reset' })) return; + + // The player's teardown would otherwise re-save the position just wiped. + state.suppressSave = true; + try { + const res = await api('api/reset.php', { json: { scope, id } }); + const n = res.reset.videos + res.reset.quizzes; + toast(n + ? `Progress reset — ${res.reset.videos} video${res.reset.videos === 1 ? '' : 's'}, ` + + `${res.reset.quizzes} quiz${res.reset.quizzes === 1 ? '' : 'zes'}.` + : 'Nothing to reset — no progress was saved here.'); + await loadMeta(); + route(); + } catch (err) { + state.suppressSave = false; + toast(err.message, 'error'); + } + } + + /* ---------- scan ---------- */ + + async function runScan() { + if (state.scanning) return; + state.scanning = true; + shell.classList.add('is-scanning'); + document.getElementById('scan-label').textContent = 'Scanning…'; + document.getElementById('scan-when').textContent = 'now'; + document.querySelectorAll('[data-scan]').forEach((b) => { b.disabled = true; }); + + 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.`); + await loadMeta(); + route(); + } catch (err) { + toast(err.message, 'error'); + } finally { + state.scanning = false; + shell.classList.remove('is-scanning'); + document.getElementById('scan-label').textContent = 'Scan library'; + document.querySelectorAll('[data-scan]').forEach((b) => { b.disabled = false; }); + renderChrome(); + } + } + + async function logout() { + try { await api('api/auth.php', { json: { action: 'logout' } }); } catch (e) { /* session already gone */ } + state.user = null; + state.meta = null; + location.hash = '#/'; + showAuth(); + } + /* ---------- router ---------- */ async function route() { if (cleanupView) { cleanupView(); cleanupView = null; } - if (!state.user) { renderLogin(); return; } + state.suppressSave = false; // the teardown above is the only thing it guarded + closeMenu(); + + if (!state.user) { showAuth(); return; } + showShell(); const hash = location.hash || '#/'; + const searchMatch = hash.match(/^#\/search\/(.*)$/); + syncSearchInput(searchMatch ? decodeURIComponent(searchMatch[1]) : ''); + renderChrome(); + let m; try { - if ((m = hash.match(/^#\/folder\/(\d+)$/))) await renderFolder(+m[1]); - else if ((m = hash.match(/^#\/watch\/(\d+)$/))) await renderWatch(+m[1]); - else if ((m = hash.match(/^#\/quiz\/(\d+)$/))) await renderQuiz(+m[1]); - else await renderHome(); + if ((m = hash.match(/^#\/folder\/(\d+)$/))) await renderFolder(+m[1]); + else if ((m = hash.match(/^#\/watch\/(\d+)$/))) await renderWatch(+m[1]); + else if ((m = hash.match(/^#\/quiz\/(\d+)$/))) await renderQuiz(+m[1]); + else if (searchMatch) await renderSearch(decodeURIComponent(searchMatch[1])); + else if ((m = hash.match(/^#\/library(?:\/(\w+))?$/))) await renderLibrary(m[1] || null); + else if (hash.startsWith('#/settings')) await renderSettings(); + else await renderHome(); } catch (err) { if (state.user) { - app.innerHTML = `
${esc(err.message)}
- Back to courses`; + view.innerHTML = `
+

That didn't load

+

${esc(err.message)}

+ Back to home +
`; } } } - /* ---------- global buttons ---------- */ + /* ---------- search box ---------- */ - document.getElementById('btn-logout').addEventListener('click', async () => { - try { await api('api/auth.php', { json: { action: 'logout' } }); } catch (e) { /* session already gone */ } - state.user = null; - updateNav(); - location.hash = '#/'; - renderLogin(); + const searchInput = document.getElementById('search-input'); + const searchClear = document.getElementById('btn-search-clear'); + let searchTimer = null; + + function syncSearchInput(query) { + if (document.activeElement !== searchInput && searchInput.value !== query) { + searchInput.value = query; + } + searchClear.hidden = !searchInput.value; + } + + /** Search routes replace each other, so Back doesn't walk through keystrokes. */ + function goSearch(query, replace) { + const target = query ? `#/search/${encodeURIComponent(query)}` : '#/search/'; + if (location.hash === target) { route(); return; } + if (replace) { + history.replaceState(null, '', target); + route(); // replaceState doesn't fire hashchange + } else { + location.hash = target; + } + } + + searchInput.addEventListener('input', () => { + searchClear.hidden = !searchInput.value; + clearTimeout(searchTimer); + const q = searchInput.value.trim(); + if (q && q.length < 2) return; // one character matches nearly everything + searchTimer = setTimeout(() => { + const onSearch = location.hash.startsWith('#/search/'); + if (!q && !onSearch) return; + goSearch(q, onSearch); // replace while refining a search + }, 350); }); - document.getElementById('btn-rescan').addEventListener('click', async (e) => { - const btn = e.currentTarget; - btn.disabled = true; - btn.innerHTML = 'Scanning…'; - try { - const res = await api('api/scan.php', { method: 'POST' }); - toast(`Scan complete: ${res.stats.folders} folders, ${res.stats.videos} videos, ` - + `${res.stats.quizzes} quizzes indexed.`); - route(); // refresh current view with new data - } catch (err) { - toast(err.message, 'danger'); - } finally { - btn.disabled = false; - btn.innerHTML = 'Rescan library'; + searchInput.addEventListener('keydown', (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + clearTimeout(searchTimer); + goSearch(searchInput.value.trim(), location.hash.startsWith('#/search/')); + }); + + searchClear.addEventListener('click', () => { + clearTimeout(searchTimer); + searchInput.value = ''; + searchClear.hidden = true; + searchInput.focus(); + if (location.hash.startsWith('#/search/')) goSearch('', true); + }); + + /* ---------- delegated actions ---------- */ + + document.addEventListener('click', (e) => { + const scan = e.target.closest('[data-scan]'); + if (scan) { e.preventDefault(); runScan(); return; } + + const layout = e.target.closest('[data-view-mode]'); + if (layout) { setPref('view', layout.dataset.viewMode); route(); return; } + + const shelvesOrCatalog = e.target.closest('[data-layout]'); + if (shelvesOrCatalog) { + setPref('layout', shelvesOrCatalog.dataset.layout); + if (location.hash.startsWith('#/library/')) { location.hash = '#/library'; return; } + route(); + return; } + + const toggle = e.target.closest('[data-toggle]'); + if (toggle) { + const key = toggle.dataset.toggle; + if (key === 'list-default') { + setPref('view', pref('view') === 'list' ? 'grid' : 'list'); + } else { + setPref(key, pref(key) === false); + } + route(); + return; + } + + const reset = e.target.closest('[data-reset]'); + if (reset) { + e.preventDefault(); + doReset(reset.dataset.reset, +reset.dataset.id || 0, reset.dataset.label || ''); + return; + } + + if (e.target.closest('[data-logout]')) { logout(); return; } + }); + + document.getElementById('btn-account').addEventListener('click', (e) => { + e.stopPropagation(); + if (document.getElementById('account-menu')) { closeMenu(); return; } + openMenu(e.currentTarget, ` + + + + `, + (action) => { + if (action === 'settings') location.hash = '#/settings'; + else if (action === 'reset') doReset('all', 0, ''); + else if (action === 'logout') logout(); + }); }); /* ---------- boot ---------- */ @@ -533,7 +1397,15 @@ } catch (e) { state.user = null; } - updateNav(); + + if (!state.user) { showAuth(); return; } + showShell(); + await loadMeta(); route(); + + if (canAnimate()) { + anime({ targets: '.brand-mark', scale: [0.5, 1], rotate: [-24, 0], opacity: [0, 1], + duration: 700, easing: 'easeOutElastic(1, 0.7)' }); + } })(); })(); diff --git a/assets/style.css b/assets/style.css index 9706b0c..5d61761 100644 --- a/assets/style.css +++ b/assets/style.css @@ -1,24 +1,1179 @@ -.card-hover { - transition: transform 0.12s ease, border-color 0.12s ease; -} -.card-hover:hover { - transform: translateY(-2px); - border-color: var(--bs-primary); -} - -.progress-thin { - height: 6px; -} - -.playlist-scroll { - max-height: 60vh; - overflow-y: auto; -} - -.min-w-0 { - min-width: 0; -} +/* CourseHub — "Course Library" design system. + * Light surface, near-black ink and chrome, orange primary accent, + * green success, red danger — all tuned to clear WCAG AA (4.5:1) as both + * text-on-white and white-text-on-fill, since each is used both ways. + * Tokens first, then layout, then components. No CSS framework underneath. + */ :root { - --plyr-color-main: var(--bs-primary); + /* Ground and surfaces — light and clean */ + --paper: #F2F0EA; + --surface: #FFFFFF; + --card: #FFFFFF; + + /* Dark chrome — sidebar + mobile tab bar only; the rest of the app stays light */ + --chrome-bg: #14110D; + --chrome-bg-2: #201A13; + --chrome-line: #322B21; + --chrome-ink: #F7F5F1; + --chrome-ink-dim: #A79E8F; + --chrome-hover: #221C15; + /* Brighter orange for accent TEXT directly on dark chrome (mobile active + tab) — --accent itself is tuned for use on white and is too dark there. */ + --chrome-accent: #FB923C; + + /* Lines, from strongest to faintest */ + --line-strong: #C7C0B5; + --line: #E1DCD2; + --line-soft: #EEEAE3; + + /* Ink — near-black, each step still clears 4.5:1 on white */ + --ink: #100F0D; + --ink-2: #3A3630; + --ink-3: #5C564D; + --ink-4: #756D5D; + + /* Accent — orange, primary actions/links/selection/in-progress */ + --accent: #C2410C; + --accent-hover: #9A3209; + --accent-soft: #FEF0E4; + --accent-tint: #FDF0E3; + --accent-line: #EFAE79; + + /* Success — green, completed/correct/passed */ + --success: #15803D; + --success-hover: #116430; + --success-soft: #E9F7EC; + --success-tint: #EBF7EE; + --success-line: #9AD6AC; + + /* Danger — red, errors/reset/wrong */ + --danger: #C7222A; + --danger-hover: #A11C22; + --danger-soft: #FCE6E5; + --danger-line: #F0AAAA; + + /* Hovers */ + --hover: #E9E4DA; + --row-hover: #F5F2EC; + + /* Course colours — one is picked per course from its id. Darkened from the + original mutes so white overlay text on a thumbnail clears 4.5:1. */ + --clay: #B85A2E; + --sage: #3F7A50; + --slate: #374B5C; + --sand: #96691C; + --plum: #7A4560; + --sky: #2E6A8C; + + --shadow-card: 0 1px 2px rgba(16, 15, 13, .08); + --shadow-frame: 0 4px 16px rgba(16, 15, 13, .12); + --shadow-pop: 0 8px 24px rgba(16, 15, 13, .28); + + /* One typeface everywhere — headings, body and numerals/paths all render + in Inter. --font-display/--font-mono are kept as separate variables (not + collapsed into --font-ui) so weight/size rules that reference them stay + meaningful call sites, even though all three resolve to the same family. */ + --font-ui: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-display: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + + --plyr-color-main: var(--accent); + --plyr-video-background: #100F0D; +} + +*, *::before, *::after { box-sizing: border-box; } + +/* Layout classes below set `display`, which would otherwise beat the UA's + `[hidden] { display: none }`. */ +[hidden] { display: none !important; } + +html, body { height: 100%; } + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: var(--font-ui); + font-size: 16px; +} + +h1, h2, h3 { font-family: var(--font-display); font-weight: 700; letter-spacing: -0.015em; margin: 0; } +h1 { font-size: 36px; line-height: 1.1; } +h2 { font-size: 24px; line-height: 1.2; letter-spacing: -0.01em; } +h3 { font-family: var(--font-ui); font-size: 17px; line-height: 1.3; letter-spacing: 0; } +p { margin: 0; } + +a { color: var(--accent); text-decoration-color: var(--accent-line); text-underline-offset: 2px; } +a:hover { color: var(--accent-hover); } + +button, input { font-family: inherit; font-size: inherit; } + +.mono { font-family: var(--font-mono); font-weight: 500; } + +/* Small text utilities, so views don't hand-roll font sizes inline and drift + out of the scale above. */ +.mono-sm { font: 500 14px/1 var(--font-mono); color: var(--ink-3); } +.hint-inline { font: 500 15px/1.4 var(--font-ui); color: var(--ink-3); } + +/* --font-mono no longer points at an actual monospace face (everything is + Inter now), so durations/counts/paths would drift out of alignment as + digits change width — tabular-nums keeps every digit the same width + without needing a separate font. */ +.mono, .mono-sm, .shelf-count, .thumb-dur, .row-num, .q-num, .path-row .path { + font-variant-numeric: tabular-nums; +} +.h-section { font-size: 22px; } + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 999px; border: 3px solid var(--surface); } +::-webkit-scrollbar-track { background: transparent; } + +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } + +/* =========================================================================== + Shell: sidebar + main column (+ mobile tab bar) + ========================================================================= */ + +.app { + display: flex; + align-items: stretch; + min-height: 100vh; + background: var(--surface); +} + +.sidebar { + width: 256px; + flex: none; + background: var(--chrome-bg); + border-right: 1px solid var(--chrome-line); + display: flex; + flex-direction: column; + padding: 16px 12px; + position: sticky; + top: 0; + height: 100vh; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px 18px; + text-decoration: none; + color: var(--chrome-ink); +} +.brand-mark { + width: 32px; height: 32px; + border-radius: 8px; + background: var(--accent); + color: var(--chrome-ink); + display: flex; align-items: center; justify-content: center; + flex: none; +} +.brand-name { font: 700 20px/1 var(--font-display); letter-spacing: -0.01em; } + +.nav { display: flex; flex-direction: column; gap: 2px; } + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--chrome-ink-dim); + text-align: left; + font: 600 16px/1 var(--font-ui); + cursor: pointer; + text-decoration: none; +} +.nav-item:hover { background: var(--chrome-hover); color: var(--chrome-ink); } +.nav-item.is-active { background: var(--accent); color: var(--chrome-ink); } +.nav-item.is-active:hover { background: var(--accent-hover); color: var(--chrome-ink); } + +.sidebar-rule { height: 1px; background: var(--chrome-line); margin: 16px 4px; } +.sidebar-label { + font: 600 13px/1 var(--font-ui); + letter-spacing: .08em; + text-transform: uppercase; + color: var(--chrome-ink-dim); + padding: 4px 10px 10px; +} + +.shelf-list { display: flex; flex-direction: column; gap: 2px; } +.shelf-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 10px; + border: 0; + border-radius: 8px; + background: transparent; + text-align: left; + font: 500 16px/1 var(--font-ui); + color: var(--chrome-ink-dim); + cursor: pointer; + text-decoration: none; +} +.shelf-item:hover { background: var(--chrome-hover); color: var(--chrome-ink); } +.shelf-dot { width: 9px; height: 9px; border-radius: 3px; flex: none; background: var(--c, var(--chrome-ink-dim)); } +.shelf-item span:nth-child(2) { flex: 1; } +.shelf-count { font: 500 14px/1 var(--font-mono); color: var(--chrome-ink-dim); } + +.sidebar-spacer { flex: 1; } + +.scan-card { + border: 1px solid var(--chrome-line); + background: var(--chrome-bg-2); + border-radius: 12px; + padding: 12px; +} +.scan-card-head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 8px; + font: 600 14px/1 var(--font-ui); + color: var(--chrome-ink); +} +.scan-card-head .mono { font-size: 13px; color: var(--chrome-ink-dim); } +.scan-spin { display: flex; } +.is-scanning .scan-spin { animation: spin 1.4s linear infinite; } + +.main { flex: 1; min-width: 0; display: flex; flex-direction: column; } + +.topbar { + height: 72px; + flex: none; + border-bottom: 1px solid var(--line); + display: flex; + align-items: center; + gap: 16px; + padding: 0 32px; + background: rgba(255, 255, 255, .85); + backdrop-filter: blur(12px); + position: sticky; + top: 0; + z-index: 20; +} + +.search { + flex: 1; + max-width: 520px; + display: flex; + align-items: center; + gap: 10px; + height: 44px; + padding: 0 14px; + background: var(--card); + border: 1px solid var(--line-strong); + border-radius: 8px; +} +.search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.search input { + flex: 1; + min-width: 0; + border: 0; + outline: none; + background: transparent; + font: 500 16px/1 var(--font-ui); + color: var(--ink); +} +.search input::-webkit-search-cancel-button { display: none; } + +.topbar-end { margin-left: auto; display: flex; align-items: center; gap: 10px; } +.topbar-totals { font-size: 14px; color: var(--ink-3); } + +.avatar { + width: 34px; height: 34px; + border: 0; + border-radius: 999px; + background: var(--plum); + color: var(--surface); + font: 700 14px/1 var(--font-ui); + cursor: pointer; + flex: none; +} +.avatar:hover { filter: brightness(1.06); } + +.view { flex: 1; padding: 32px; } +.loading { display: flex; justify-content: center; padding: 64px 0; } +.spinner { + width: 22px; height: 22px; + border: 2px solid var(--line-strong); + border-top-color: var(--accent); + border-radius: 999px; + display: block; + animation: spin .8s linear infinite; +} + +.tabbar { display: none; } +.scan-mobile { display: none; } + +/* =========================================================================== + Buttons, chips, segmented controls + ========================================================================= */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 9px 14px; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: var(--card); + color: var(--ink); + font: 600 15px/1 var(--font-ui); + cursor: pointer; + text-decoration: none; + white-space: nowrap; +} +.btn:hover { background: var(--row-hover); color: var(--ink); } +.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.btn-accent { background: var(--accent); border-color: var(--accent); color: var(--surface); } +.btn-accent:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--surface); } + +.btn-danger { background: var(--card); border-color: var(--danger); color: var(--danger); } +.btn-danger:hover { background: var(--danger-soft); color: var(--danger); } + +.btn-solid-danger { background: var(--danger); border-color: var(--danger); color: var(--surface); } +.btn-solid-danger:hover { background: var(--danger-hover); border-color: var(--danger-hover); color: var(--surface); } + +.btn-light { background: var(--surface); border-color: transparent; color: var(--ink); } +.btn-light:hover { background: var(--line); } + +.btn-ghost { background: transparent; border-color: rgba(250, 248, 245, .3); color: var(--surface); } +.btn-ghost:hover { background: rgba(250, 248, 245, .1); color: var(--surface); } + +/* Text-only action (used on its own, not on top of .btn). */ +.btn-quiet { + border: 0; + background: transparent; + padding: 0; + font: 600 15px/1 var(--font-ui); + color: var(--accent); + cursor: pointer; + text-decoration: none; +} +.btn-quiet:hover { color: var(--accent-hover); } + +.btn-block { width: 100%; } +.btn-lg { padding: 12px 20px; font-size: 16px; } +.btn:disabled { opacity: .6; cursor: default; } + +.icon-btn { + width: 34px; height: 34px; + flex: none; + display: flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--ink-3); + cursor: pointer; + padding: 0; +} +.icon-btn:hover { background: var(--line); color: var(--ink); } + +.seg { + display: flex; + padding: 3px; + background: var(--paper); + border-radius: 9px; + gap: 2px; +} +.seg button { + padding: 7px 12px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--ink-3); + font: 600 15px/1 var(--font-ui); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.seg button.is-on { background: var(--card); color: var(--ink); box-shadow: var(--shadow-card); } +.seg .seg-icon { width: 38px; height: 34px; padding: 0; } + +.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 22px; } +.chip { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 13px; + border: 1px solid var(--line-strong); + border-radius: 999px; + background: var(--card); + color: var(--ink-2); + font: 600 15px/1 var(--font-ui); + cursor: pointer; + text-decoration: none; +} +.chip:hover { background: var(--row-hover); color: var(--ink); } +.chip.is-on:hover { background: var(--ink); color: var(--surface); } +.chip .dot { width: 7px; height: 7px; border-radius: 999px; background: var(--c, var(--accent)); } +.chip.is-on { background: var(--ink); border-color: var(--ink); color: var(--surface); } + +.pill { + font: 600 13px/1 var(--font-ui); + padding: 5px 9px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + white-space: nowrap; +} +.pill-quiet { background: var(--line-soft); color: var(--ink-3); } + +/* =========================================================================== + Page furniture + ========================================================================= */ + +.page { max-width: 1120px; } +.page-narrow { max-width: 760px; } +.page-mid { max-width: 900px; } + +.eyebrow { + font: 600 14px/1 var(--font-ui); + letter-spacing: .08em; + text-transform: uppercase; + color: var(--ink-3); +} +.eyebrow-sm { font-size: 13px; letter-spacing: .09em; } + +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + margin-bottom: 20px; + flex-wrap: wrap; +} +.page-sub { font: 500 16px/1.5 var(--font-ui); color: var(--ink-2); margin-top: 6px; } + +.section-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.shelf-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} +.shelf-head .swatch { width: 10px; height: 10px; border-radius: 3px; background: var(--c, var(--accent)); } +.shelf-head h2 { margin: 0; font-size: 22px; } +.shelf-head .rule { flex: 1; height: 1px; background: var(--line); } +.shelf-head .mono { font-size: 14px; color: var(--ink-3); } + +.crumbs { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font: 500 15px/1 var(--font-ui); + color: var(--ink-3); + margin-bottom: 14px; +} +.crumbs a { color: var(--ink-2); text-decoration: none; } +.crumbs a:hover { color: var(--accent); } +.crumbs .sep { color: var(--ink-4); } +.crumbs .here { color: var(--ink); font-weight: 600; } + +.back-link { + display: inline-flex; + align-items: center; + gap: 6px; + border: 0; + background: transparent; + padding: 0; + margin-bottom: 16px; + font: 600 15px/1 var(--font-ui); + color: var(--ink-2); + cursor: pointer; + text-decoration: none; +} +.back-link:hover { color: var(--accent); } + +.empty { + border: 1px dashed var(--line-strong); + border-radius: 12px; + padding: 56px 24px; + text-align: center; +} +.empty h2 { font-size: 22px; margin-bottom: 8px; } +.empty p { font: 500 16px/1.5 var(--font-ui); color: var(--ink-2); margin: 0 0 16px; } + +/* =========================================================================== + Course cards, thumbnails, progress + ========================================================================= */ + +.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } +.grid-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 32px; } +.stack { display: flex; flex-direction: column; gap: 8px; } + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + padding: 16px; +} + +.stat-label { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } +.stat-label .swatch { width: 8px; height: 8px; border-radius: 3px; background: var(--c, var(--accent)); } +.stat-label span { font: 600 14px/1 var(--font-ui); color: var(--ink-2); } +.stat-value { font: 700 32px/1 var(--font-display); letter-spacing: -0.01em; } +.stat-sub { font: 500 14px/1.4 var(--font-ui); color: var(--ink-3); margin-top: 6px; } + +.course-card { + display: block; + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + padding: 10px; + cursor: pointer; + text-decoration: none; + color: inherit; +} +.course-card:hover { box-shadow: var(--shadow-card); border-color: var(--line-strong); color: inherit; } +.course-card-body { padding: 10px 4px 4px; } +.course-title { font: 700 16px/1.3 var(--font-ui); } +.course-meta { font: 500 14px/1.4 var(--font-ui); color: var(--ink-3); margin-top: 3px; } + +/* Colour block standing in for cover art: two soft blobs over a course hue. */ +.thumb { + position: relative; + aspect-ratio: 16 / 10; + border-radius: 8px; + overflow: hidden; + background: var(--c, var(--slate)); +} +.thumb::before, .thumb::after { + content: ''; + position: absolute; + aspect-ratio: 1; + border-radius: 999px; + z-index: 0; +} +.thumb::before { top: -30%; right: -12%; width: 62%; background: rgba(255, 255, 255, .2); } +.thumb::after { left: -8%; bottom: -32%; width: 48%; background: rgba(16, 15, 13, .14); } +/* A captured frame sits above the decorative blobs (opaque, so it fully + hides them once loaded) but below the text/icon overlays. onerror removes + this element on a 404, leaving the colour-block placeholder untouched. */ +.thumb img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: 1; +} +.thumb-cat { + position: absolute; + left: 10px; top: 8px; + font: 700 13px/1 var(--font-ui); + letter-spacing: .06em; + text-transform: uppercase; + color: rgba(255, 255, 255, .85); + text-shadow: 0 1px 3px rgba(16, 15, 13, .55); + z-index: 2; +} +.thumb-dur { + position: absolute; + right: 8px; bottom: 8px; + font: 500 13px/1 var(--font-mono); + color: var(--surface); + background: rgba(16, 15, 13, .55); + padding: 4px 7px; + border-radius: 999px; + z-index: 2; +} +.thumb-play { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; +} +.thumb-play span { + width: 58px; height: 58px; + border-radius: 999px; + background: rgba(250, 248, 245, .92); + color: var(--ink); + display: flex; + align-items: center; + justify-content: center; +} +.thumb-bar { + position: absolute; + inset: auto 0 0 0; + height: 4px; + background: rgba(255, 255, 255, .28); + z-index: 2; +} +.thumb-bar span { display: block; height: 100%; background: var(--surface); } + +.initial { + width: 42px; height: 42px; + flex: none; + border-radius: 8px; + background: var(--c, var(--slate)); + color: var(--surface); + display: flex; + align-items: center; + justify-content: center; + font: 700 17px/1 var(--font-display); +} +.initial-sm { width: 38px; height: 38px; font-size: 16px; } +.initial-lg { width: 50px; height: 50px; font-size: 20px; } + +.bar { flex: 1; height: 5px; border-radius: 999px; background: var(--line); overflow: hidden; } +.bar span { display: block; height: 100%; border-radius: 999px; background: var(--c, var(--accent)); } +.bar-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; } +.bar-row .mono { font-size: 13px; color: var(--ink-3); } +.bar-lg { height: 6px; } + +/* =========================================================================== + Rows and lists + ========================================================================= */ + +.rows { + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + overflow: hidden; +} +.row { + display: flex; + align-items: center; + gap: 14px; + padding: 0 16px; + min-height: 62px; + border-bottom: 1px solid var(--line-soft); + text-decoration: none; + color: inherit; + cursor: pointer; +} +.row:last-child { border-bottom: 0; } +.row:hover { background: var(--row-hover); color: inherit; } +.row-main { flex: 1; min-width: 0; } +.row-title { + font: 600 16px/1.3 var(--font-ui); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.row-sub { + font: 500 14px/1.3 var(--font-ui); + color: var(--ink-3); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.row-fixed { width: 320px; flex: none; min-width: 0; } +.row-col { font: 500 14px/1 var(--font-ui); color: var(--ink-2); } +.row-num { font: 500 14px/1 var(--font-mono); color: var(--ink-3); } +.row-bar { flex: 1; display: flex; align-items: center; gap: 10px; min-width: 120px; } +.row-bar .mono { width: 38px; text-align: right; font-size: 13px; color: var(--ink-3); } + +/* A row whose trailing control (reset) sits outside the link target. */ +.row-link { + display: flex; + align-items: center; + gap: 14px; + flex: 1; + min-width: 0; + padding: 9px 0; + text-decoration: none; + color: inherit; +} +.row-link:hover { color: inherit; } + +/* Free-standing rows (search hits, recent additions) sit apart from each other. */ +.row-card { + display: flex; + align-items: center; + gap: 14px; + background: var(--card); + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px 14px; + text-decoration: none; + color: inherit; + cursor: pointer; +} +.row-card:hover { background: var(--row-hover); color: inherit; } + +.play-dot { + width: 30px; height: 30px; + flex: none; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + background: var(--line-soft); + color: var(--ink-3); +} +.play-dot.is-done { background: var(--success-soft); color: var(--success); } +.play-dot.is-partial { background: var(--accent-tint); color: var(--accent); } + +/* =========================================================================== + Home hero + ========================================================================= */ + +.hero { + display: flex; + gap: 20px; + background: var(--ink); + border-radius: 16px; + padding: 20px; + color: var(--surface); + margin-bottom: 14px; + text-decoration: none; +} +.hero:hover { color: var(--surface); } +.hero-art { width: 280px; flex: none; position: relative; border-radius: 10px; overflow: hidden; } +.hero-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; +} +.hero-eyebrow { + font: 600 13px/1 var(--font-ui); + letter-spacing: .1em; + text-transform: uppercase; + /* The hero card has a dark (var(--ink)) background, so its dim text needs + the light-on-dark token, not --ink-4 (tuned for dark-on-light). */ + color: var(--chrome-ink-dim); +} +.hero-title { font: 700 28px/1.15 var(--font-display); letter-spacing: -0.01em; } +.hero-sub { font: 500 16px/1.5 var(--font-ui); color: var(--line-strong); } +.hero-progress { display: flex; align-items: center; gap: 12px; margin-top: 4px; } +.hero-progress .bar { max-width: 320px; height: 6px; background: rgba(250, 248, 245, .18); } +.hero-progress .bar span { background: var(--sage); } +.hero-progress .mono { font-size: 14px; color: var(--line-strong); } +.hero-actions { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; } + +/* =========================================================================== + Watch screen + ========================================================================= */ + +.watch { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 24px; align-items: start; } +.player-frame { background: var(--ink); border-radius: 16px; padding: 12px; } +.player-frame .plyr { border-radius: 10px; overflow: hidden; } +.watch-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-top: 16px; + flex-wrap: wrap; +} +.watch-head h1 { font-size: 26px; } +.watch-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.watch-note { font: 500 15px/1 var(--font-ui); color: var(--ink-3); margin-top: 10px; display: flex; align-items: center; gap: 6px; } + +.playlist { background: var(--card); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; } +.playlist-head { + padding: 12px 14px; + border-bottom: 1px solid var(--line); + font: 600 15px/1.3 var(--font-ui); + display: flex; + align-items: center; + gap: 8px; +} +.playlist-scroll { max-height: 60vh; overflow-y: auto; } +.playlist .row { min-height: 58px; padding: 0 14px; } +.playlist .row.is-active { background: var(--accent-soft); } +.playlist .row.is-active .row-title { color: var(--accent); font-weight: 700; } + +/* =========================================================================== + Quiz + ========================================================================= */ + +.quiz-score { + display: flex; + align-items: center; + gap: 18px; + border-radius: 12px; + padding: 18px 20px; + margin-bottom: 22px; + background: var(--success-tint); + border: 1px solid var(--success-line); + flex-wrap: wrap; +} +.quiz-score.is-fail { background: var(--danger-soft); border-color: var(--danger-line); } +.quiz-score-num { font: 700 44px/1 var(--font-display); color: var(--success); } +.quiz-score.is-fail .quiz-score-num { color: var(--danger); } +.quiz-score-body { flex: 1; min-width: 180px; } +.quiz-score-head { font: 700 17px/1.3 var(--font-ui); color: var(--success); } +.quiz-score.is-fail .quiz-score-head { color: var(--danger); } +.quiz-score-sub { font: 500 15px/1.4 var(--font-ui); color: var(--ink-2); margin-top: 3px; } + +.q-card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + padding: 18px 20px; +} +.q-card.is-right { border-color: var(--success-line); } +.q-card.is-wrong { border-color: var(--danger-line); } +.q-head { display: flex; gap: 12px; margin-bottom: 14px; } +.q-num { + width: 28px; height: 28px; + flex: none; + border-radius: 999px; + background: var(--line-soft); + color: var(--ink-2); + display: flex; + align-items: center; + justify-content: center; + font: 600 14px/1 var(--font-mono); +} +.q-card.is-right .q-num { background: var(--success-soft); color: var(--success); } +.q-card.is-wrong .q-num { background: var(--danger-soft); color: var(--danger); } +.q-text { font: 600 17px/1.4 var(--font-ui); text-wrap: pretty; } +.q-note { font: 500 15px/1.4 var(--font-ui); margin-top: 6px; color: var(--success); } +.q-note.is-wrong { color: var(--danger); } +.q-options { display: flex; flex-direction: column; gap: 8px; padding-left: 40px; } + +.q-option { + display: flex; + align-items: center; + gap: 11px; + padding: 11px 13px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--card); + color: var(--ink); + text-align: left; + font: 500 16px/1.4 var(--font-ui); + cursor: pointer; + width: 100%; +} +.q-option:hover { background: var(--row-hover); } +.q-option .mark { + width: 18px; height: 18px; + flex: none; + border-radius: 999px; + border: 1.5px solid var(--ink-4); + background: transparent; +} +.q-option.multi .mark { border-radius: 5px; } +.q-option .label { flex: 1; } +.q-option .tag { font: 600 14px/1 var(--font-ui); color: var(--ink-3); } + +.q-option.is-picked { background: var(--accent-soft); border-color: var(--accent); } +.q-option.is-picked .mark { border-color: var(--accent); background: var(--accent); } +.q-option.is-correct { background: var(--success-tint); border-color: var(--success); } +.q-option.is-correct .mark { border-color: var(--success); background: var(--success); } +.q-option.is-correct .tag { color: var(--success); } +.q-option.is-missed { background: var(--danger-soft); border-color: var(--danger); } +.q-option.is-missed .mark { border-color: var(--danger); background: var(--danger); } +.q-option.is-missed .tag { color: var(--danger); } +.q-option:disabled { cursor: default; } + +.quiz-actions { display: flex; align-items: center; gap: 14px; margin: 22px 0 8px; flex-wrap: wrap; } +.quiz-actions .hint { font: 500 15px/1 var(--font-ui); color: var(--ink-3); } + +/* =========================================================================== + Settings + ========================================================================= */ + +.settings-section { padding: 20px; margin-bottom: 16px; } +.settings-section h3 { margin-bottom: 4px; } +.settings-section > p { font: 500 15px/1.5 var(--font-ui); color: var(--ink-3); margin-bottom: 14px; } +.settings-section.is-danger { border-color: var(--danger-line); } +.settings-section.is-danger h3 { color: var(--danger); } +.settings-section.is-danger > p { color: var(--ink-2); } + +.path-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; +} +.path-row .path { flex: 1; min-width: 0; font: 500 15px/1.4 var(--font-mono); color: var(--ink-2); word-break: break-all; } +.path-row .count { font: 500 14px/1 var(--font-ui); color: var(--ink-3); white-space: nowrap; } + +.toggle-row { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 0; + border-top: 1px solid var(--line-soft); +} +.toggle-row .label { font: 600 16px/1.3 var(--font-ui); } +.toggle-row .sub { font: 500 15px/1.4 var(--font-ui); color: var(--ink-3); margin-top: 2px; } +.switch { + width: 42px; height: 24px; + flex: none; + border: 0; + border-radius: 999px; + padding: 3px; + cursor: pointer; + display: flex; + justify-content: flex-start; + background: var(--line-strong); + transition: background 180ms cubic-bezier(.2, 0, 0, 1); +} +.switch span { + width: 18px; height: 18px; + border-radius: 999px; + background: var(--card); + box-shadow: 0 1px 2px rgba(16, 15, 13, .2); + transition: transform 180ms cubic-bezier(.2, 0, 0, 1); +} +.switch.is-on { background: var(--accent); } +.switch.is-on span { transform: translateX(18px); } + +.fact-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + border-top: 1px solid var(--line-soft); + font: 500 15px/1.4 var(--font-ui); + color: var(--ink-2); +} +.fact-row .mono { color: var(--ink-3); font-size: 15px; } + +/* =========================================================================== + Toast, modal, account menu + ========================================================================= */ + +.toasts { + position: fixed; + left: 24px; + bottom: 24px; + z-index: 60; + display: flex; + flex-direction: column; + gap: 10px; +} +.toast { + display: flex; + align-items: center; + gap: 12px; + background: var(--ink); + color: var(--surface); + padding: 13px 16px; + border-radius: 10px; + box-shadow: var(--shadow-pop); + animation: rise 180ms cubic-bezier(.2, 0, 0, 1); + font: 500 16px/1.35 var(--font-ui); + max-width: 380px; +} +.toast .glyph { flex: none; display: flex; color: #4ADE80; } +.toast.is-error .glyph { color: #F87171; } + +.overlay { + position: fixed; + inset: 0; + background: rgba(16, 15, 13, .4); + display: flex; + align-items: center; + justify-content: center; + z-index: 70; + padding: 20px; +} +.dialog { + width: 420px; + max-width: 100%; + background: var(--card); + border-radius: 16px; + padding: 24px; + box-shadow: var(--shadow-pop); + animation: rise 160ms cubic-bezier(.2, 0, 0, 1); +} +.dialog h3 { font-family: var(--font-display); font-size: 22px; line-height: 1.2; margin-bottom: 8px; } +.dialog p { font: 500 16px/1.5 var(--font-ui); color: var(--ink-2); margin-bottom: 20px; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; } + +.menu { + position: fixed; + z-index: 65; + min-width: 190px; + background: var(--card); + border: 1px solid var(--line); + border-radius: 12px; + box-shadow: var(--shadow-pop); + padding: 6px; +} +.menu-user { + padding: 8px 10px 10px; + border-bottom: 1px solid var(--line-soft); + margin-bottom: 6px; +} +.menu-user .who { font: 700 15px/1.3 var(--font-ui); } +.menu-user .what { font: 500 14px/1.3 var(--font-ui); color: var(--ink-3); margin-top: 2px; } +.menu button { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 9px 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--ink-2); + font: 600 15px/1 var(--font-ui); + text-align: left; + cursor: pointer; +} +.menu button:hover { background: var(--row-hover); color: var(--ink); } +.menu button.is-danger { color: var(--danger); } +.menu button.is-danger:hover { background: var(--danger-soft); } + +/* =========================================================================== + Auth + ========================================================================= */ + +.auth { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} +.auth-card { + width: 380px; + max-width: 100%; + background: var(--card); + border: 1px solid var(--line); + border-radius: 16px; + box-shadow: var(--shadow-frame); + padding: 28px; +} +.auth-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; } +.auth-card h1 { font-size: 26px; margin-bottom: 6px; } +.auth-card .sub { font: 500 16px/1.5 var(--font-ui); color: var(--ink-3); margin-bottom: 20px; } +.field { margin-bottom: 14px; } +.field label { + display: block; + font: 600 14px/1 var(--font-ui); + color: var(--ink-2); + margin-bottom: 6px; +} +.field input { + width: 100%; + height: 44px; + padding: 0 14px; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: var(--surface); + color: var(--ink); + outline: none; +} +.field input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.auth-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 18px; } +.alert { + background: var(--danger-soft); + border: 1px solid var(--danger-line); + color: var(--danger); + border-radius: 8px; + padding: 10px 12px; + font: 500 15px/1.4 var(--font-ui); + margin-bottom: 14px; +} + +/* =========================================================================== + Responsive — the design's 390px frame informs everything below 900px + ========================================================================= */ + +/* Larger type needs more room in the library list row: drop the state column + first, then narrow the title column, before the mobile rules take over. */ +@media (max-width: 1280px) { + .row-col { display: none; } +} + +@media (max-width: 1100px) { + .grid, .grid-stats { grid-template-columns: repeat(3, 1fr); } + .watch { grid-template-columns: minmax(0, 1fr); } + .playlist-scroll { max-height: 42vh; } + .row-fixed { width: 240px; } +} + +@media (max-width: 900px) { + .app { flex-direction: column; } + .sidebar { display: none; } + .view { padding: 16px 20px 96px; } + + .topbar { height: 64px; padding: 0 20px; gap: 10px; } + .search { max-width: none; border-radius: 999px; height: 44px; } + .topbar-totals { display: none; } + .scan-mobile { + display: flex; + width: 42px; height: 42px; + background: var(--accent); + color: var(--surface); + } + .scan-mobile:hover { background: var(--accent-hover); color: var(--surface); } + .is-scanning .scan-mobile svg { animation: spin 1.4s linear infinite; } + + .grid, .grid-stats { grid-template-columns: 1fr 1fr; gap: 12px; } + h1 { font-size: 28px; } + .hero { flex-direction: column; padding: 12px; border-radius: 14px; } + .hero-art { width: 100%; } + .hero-title { font-size: 22px; } + .row-fixed, .row-col { display: none; } + .q-options { padding-left: 0; } + .toasts { left: 16px; right: 16px; bottom: 84px; } + .toast { max-width: none; } + + .tabbar { + position: fixed; + left: 0; right: 0; bottom: 0; + z-index: 40; + display: flex; + padding: 8px 12px calc(8px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--chrome-line); + background: rgba(20, 17, 13, .94); + backdrop-filter: blur(12px); + } + .tab { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + border: 0; + background: transparent; + padding: 6px 0; + cursor: pointer; + font: 600 13px/1 var(--font-ui); + color: var(--chrome-ink-dim); + text-decoration: none; + } + .tab.is-active { color: var(--chrome-accent); } +} + +@media (max-width: 560px) { + .grid-stats { grid-template-columns: 1fr 1fr; } + .page-head { align-items: flex-start; } +} + +@media (prefers-reduced-motion: reduce) { + * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; } + .is-scanning .scan-spin, .is-scanning .scan-mobile svg { animation: spin 1.4s linear infinite !important; } } diff --git a/config.example.php b/config.example.php index 208ea81..787b5ca 100644 --- a/config.example.php +++ b/config.example.php @@ -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); diff --git a/includes/functions.php b/includes/functions.php index 842af98..ce85589 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -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 ''; diff --git a/index.php b/index.php index e89e5aa..34b331b 100644 --- a/index.php +++ b/index.php @@ -1,46 +1,85 @@ - + + CourseHub - - + + + - + -
-
-
-
+
+
+ + +
+ + +
+
+ +
+
-
-
+ -
+ +
+ + +
+
+
+ +
+
- + diff --git a/scan.php b/scan.php index 6f88db7..ff6942b 100644 --- a/scan.php +++ b/scan.php @@ -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"; diff --git a/schema.sql b/schema.sql index 9781f6a..f552baf 100644 --- a/schema.sql +++ b/schema.sql @@ -9,17 +9,31 @@ 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, - name VARCHAR(255) NOT NULL, - path VARCHAR(700) NOT NULL UNIQUE, - depth INT UNSIGNED NOT NULL DEFAULT 0, - sort_order INT UNSIGNED NOT NULL DEFAULT 0, - is_missing TINYINT(1) NOT NULL DEFAULT 0, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + parent_id INT UNSIGNED NULL, + name VARCHAR(255) NOT NULL, + path VARCHAR(700) NOT NULL UNIQUE, + 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, INDEX idx_folders_parent (parent_id) diff --git a/thumb.php b/thumb.php new file mode 100644 index 0000000..65537d0 --- /dev/null +++ b/thumb.php @@ -0,0 +1,43 @@ + 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);