Five more usability features on top of the search/thumbnails batch: - Recently Added: dashboard card of courses by folder mtime, separate from Recently Viewed (watched vs. just showed up on disk). - Bulk find/replace rename across every course/folder name at once, with a mandatory preview step before anything touches disk. Refactored the single-item rename route to share the same validate/apply logic. - Estimated time remaining on the loaded course's stats card, computed only from lessons that have actually reported a duration. - Library-wide stats overview: total courses, lessons tracked, time watched, daily streak - read from each course's small progress file rather than re-scanning course contents. - One-click backup/export zip of settings, hidden-paths, recently-viewed, and every course's progress/notes. Also fixes a real performance bug found along the way: search was routing through list_library_directory, which computes a full recursive file count and thumbnail lookup for every course at every level regardless of match - turning a search into an O(every file in the library) scan. Gave search its own lightweight directory-only walk (iter_all_courses), now shared by Recently Added and the stats overview too, so the expensive per-course work only runs for courses that actually match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
13 KiB
OfflineU — Project Summary (for Claude Code handoff)
What this is
A fork of the self-hosted course viewer WhiskeyCoder/OfflineU (Flask app), customized for personal use. Deployed via Docker (Dockhand on a Ugreen NAS), built locally from a private Gitea repo rather than pulling the upstream image.
Deployment
docker-compose.yml:build: .+pull_policy: build(forces a real rebuild every deploy — withoutpull_policy: build, Compose can silently reuse a stale cached image).network_mode: host,PUID=1000,PGID=10,TZ=America/New_York.- Volumes: courses at
/volume1/files/training→/app/courses, progress/settings data at/volume2/docker/offlineu/data→/app/data. - Deployed from Gitea via Dockhand's "Deploy from Git" stack type.
Files touched this session
offlineu_core.py— all backend logic (Flask routes, settings, progress tracking)templates/course_dashboard.html— main dashboard / library browsertemplates/lesson_view.html— individual lesson pagetemplates/settings.html— new settings pagetemplates/help.html— new help pagestatic/theme.js— shared script that applies persisted display settings
Features added, roughly in order
-
Library browser — lazy, drill-down directory browsing instead of typing a full path.
/librarylists one directory level at a time; a folder is treated as a "course" once it (or 2+ of its immediate subfolders) contain video/audio files directly. Starts collapsed at the top level. -
PDF preview fix — lesson pages were fetching PDFs as text and dumping raw bytes. Now PDFs (and HTML) render in an iframe;
.docx/.doc/.rtfshow a "no preview, download instead" message rather than garbled text. -
Full-width, resizable layout — removed a hardcoded 800px video cap and 1000px page cap. Video player supports native corner-drag resizing (
resize: both+object-fit: contain), and the last-used size persists via settings. -
Settings system (
/settings,/api/settings) — server-side persisted (/app/data/settings.json), covering:- Theme (dark/light + 10 named presets: Dracula, Tokyo Night, Catppuccin Mocha, Ayu Dark, GitHub Dark, Atom One Dark, Houston, Night Owl, Dainty, Matcha)
- Accent color (auto-syncs to a theme's signature color on switch, independently editable after)
- Font family (system/sans/serif/monospace/Monaspace) and size
- Layout width, density, card style, corner radius
- Library root override (point the browser at a specific subfolder)
- Video player size
All applied via CSS custom properties, fetched once per page load by
static/theme.js.
-
Header/footer redesign — branded gradient header, sticky footer holding Settings + Help links (consolidated from several scattered links). Clicking the "OfflineU" wordmark anywhere calls
/reset_course(clears the loaded course) rather than just/(which would keep showing the same course). -
Help page (
/help) — "How to Use" / "Supported File Types" moved here from the cluttered main dashboard. -
Recently Viewed — tracks the last 5 lessons viewed across all courses (
/app/data/recent_views.json), shown only on the no-course-loaded library screen (not on an individual course's page). Clicking an entry loads that lesson's course if it isn't already active, then jumps to the lesson (/recent/open). -
Watch-progress tracking —
Lesson.duration_secondsadded alongside the existingprogress_seconds. Lesson rows in the course tree and in Recently Viewed show either a green checkmark (completed), an accent-colored "NN% watched" badge + thin progress bar (in progress), or a plain "○" (untouched). -
Library curation (Settings → Curate Library) — hide individual courses or entire folders from the browser without touching anything on disk. Persisted in
/app/data/hidden_paths.json. Hiding a folder hides everything inside it./library(normal browsing) filters hidden items out;/library/manage(used by the Settings UI) includes them flaggedhidden: trueso they can be un-hidden.
Rough edges fixed this session
- Single-section course detection —
_looks_like_course()inofflineu_core.pynow also recognizes a course with exactly one section subfolder, if that subfolder's name reads as a section label ("Section 1", "Module 2", "Chapter 3", etc. — see_SECTION_NAME_RE). This deliberately doesn't touch the general "publisher folder holding one course" case (e.g.Pluralsight/Docker Deep Dive/), since that subfolder is named after the course, not a section. - Empty folder after hiding all its courses — added
_contains_visible_course(), used bylist_library_directory()whenskip_hidden=True, so a folder whose every course has been individually curated out no longer appears as a drillable (but empty) directory in normal browsing. The Settings curation UI (skip_hidden=False) is unaffected — it still needs to show those folders so courses can be un-hidden. - Matcha theme accuracy — replaced the hand-approximated Matcha palette with real values sourced from lucafalasco/matcha's published VS Code theme JSON.
- Dainty theme swapped for Nord — turned out "Dainty" (HotWordland/dainty-vscode)
is a Lab-space theme generator, not a fixed palette — there was no single
official hex set to verify against, so approximating it was never really
fixable. Replaced it with Nord, which has a well-documented, fully verifiable
official palette (nordtheme.com). Anyone with
theme: "dainty"already saved insettings.jsonfalls back gracefully to thedarkdefault on next load.
Mobile UI overhaul + in-app renaming (this session)
- Fixed the mobile text-overlap bug —
.lesson-item/.tree-headerincourse_dashboard.htmlhad nomin-width: 0or truncation on their flex children, so long course/lesson names (especially scene-release-style dotted names likeUdemy.crash.course.electronics...Mimirwith zero whitespace to wrap on) would overflow and visually overlap neighboring text on narrow screens. Fixed withmin-width:0+ ellipsis truncation (desktop/tablet) and a@media (max-width: 600px)block that stacks title/metadata into separate lines withoverflow-wrap: break-word(the dotted-name case needed this specifically — plainwhite-space: normaldoesn't create a break opportunity in a string with no spaces). - Library browser redesigned — replaced the nested-indent accordion
(
AI→ChatGPT→ course, each level eating horizontal space) with a single-level drill-down + breadcrumb (Library / AI / ChatGPT), the standard mobile file-browser pattern. Tapping a folder now replaces the list instead of nesting under it. In-course lesson tree keeps its old expand-in-place behavior (shallower, wasn't the reported problem). - Rename courses/folders from Settings — "Curate Library" is now
"Manage Library": each row gets a ✏️ button alongside Hide/Show that renames
the directory in place on disk via a new
POST /api/rename-path. Handles path-traversal/collision validation, and rebases anyhidden_paths.json/recent_views.jsonentries nested under the renamed path (progress files need no rebasing — they live inside the directory and are keyed relative to it). If you rename your currently loaded course's folder, the app resets to the library view rather than serving a stale path.
Eight usability features (this session)
Asked for after brainstorming what could make the app nicer to use — see
.claude/plans/dazzling-yawning-glacier.md for the full scoping rationale.
- Course thumbnails —
find_course_thumbnail()looks forcover/folder/thumbnail/thumb/poster(.jpg/.jpeg/.png/.webp) directly inside a course folder; served viaGET /library/thumbnail. Shown in the Library browser and Recently Viewed/Continue Watching, falling back to the emoji icon if there's no cover image. - Library search —
GET /library/search?q=...recursively walks the library (reusinglist_library_directory's hidden-path filtering) and matches on course name. Debounced search box above the Library browser. - Continue Watching — reuses the Recently-Viewed history
(
MAX_RECENT_VIEWSbumped 5→20) rather than a full library-wide progress index; split into "in progress" vs. "recently touched" on the dashboard. - Playback speed control —
settings.playback_speed(0.75x–2x), persisted the same way video player size already was; speed buttons on the lesson page, applied on load via the existing/api/settingsfetch. - Installable app (PWA) —
static/manifest.json+static/icons/icon-{192,512}.png, linked from every template's<head>. Install-only, deliberately no service worker/offline caching — this app has no real "offline" mode (it's a thin client over the Flask backend/NAS files), so a caching SW would just create stale-content bugs. Also fixed:lesson_view.htmlwas missing the viewport meta tag entirely, so lesson/video pages weren't actually mobile-responsive until now. - Sort in the Library browser — client-side Name A→Z/Z→A only; progress-based filtering (not-started/in-progress/done) was scoped out, since it'd need the same expensive per-course scan as #3's "ideal" version.
- Per-lesson notes — a
notefield alongsidecompleted/progress_secondsin each lesson's existing progress-file entry, viaProgressTracker.update_lesson_note()/POST /api/lesson-note. Fixed a real bug inupdate_lesson_progresswhile at it: it always overwrote the entire lesson entry, which would have silently deleted a saved note on the next routine playback-progress autosave. - Mark course as watched —
POST /api/course/mark-watched, scoped to whatever course is currently loaded (not an arbitrary library path, to avoid re-validating/re-scanning an untrusted path). Button lives in the course stats card, behind a confirm prompt.
Search perf fix + five more features (this session)
Also caught: search on the real deployed library was taking 3+ seconds
(scanning ~6000+ files across the real course tree). Root cause:
search_library_courses walked via list_library_directory, which computes
a full recursive file count (rglob) and thumbnail lookup for every
course at every level, regardless of whether it matched. Rewrote it with
its own directory-only walk (iter_all_courses - now shared by search,
Recently Added, and library stats below) so the expensive per-course work
only runs for courses that actually match. Verified with a synthetic
6000-file library: effectively instant for narrow queries, same cost as
before only in the pathological "everything matches" case (unavoidable -
you need real data for every result you return).
- Recently Added — dashboard card showing courses by folder mtime
(
get_recently_added_courses), separate from Recently Viewed (which tracks what's been watched, not what showed up on disk). - Bulk find/replace rename —
Settings → Manage Library → Bulk Rename. Preview (GET /api/bulk-rename/preview) before apply (POST /api/bulk-rename/apply) is mandatory; apply takes the exact list the client saw in preview (not a re-derived pattern match), and continues past individual failures (e.g. a name collision) rather than aborting the whole batch. Scans every directory in the library, course and category/group folders alike (_iter_all_directories) - by user's choice, since the naming problem isn't limited to course-level folders. The single-item rename route (/api/rename-path) was refactored to share the same validation/apply logic (validate_new_name,perform_rename) - confirmed byte-for-byte identical error responses after the refactor. - Estimated time remaining — added to
_calculate_completion_stats; only ever computed over lessons that have actually reported a duration (i.e. been played at least once) - no data, no estimate shown, rather than a misleading "0 remaining." - Library stats overview — total courses, lessons tracked, time watched, daily streak. Reads each course's small progress JSON directly rather than re-scanning course contents, so it stays cheap regardless of how many files live inside each course.
- Backup/export —
GET /api/backup, aSettings → Backup & Exportbutton. Zips settings/hidden-paths/recent-views plus every course's progress+notes file (stdlibzipfile, no new dependency) - not the course files themselves.
Known limitations still open
- App is unauthenticated by design (matches upstream) — settings and hidden-path curation apply app-wide, not per-browser/per-user.
Workflow that's been in use
Edit locally → git add . && git commit -m "..." && git push to the private
Gitea repo → redeploy the stack in Dockhand (which builds from the fresh
git pull + pull_policy: build).