Files
offlineu/OfflineU-project-summary.md
T
rmsitzandClaude Sonnet 5 a6c49d3762 Add Recently Added, bulk rename, time remaining, library stats, backup export
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>
2026-08-22 11:36:05 -04:00

13 KiB
Raw Blame History

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 — without pull_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 browser
  • templates/lesson_view.html — individual lesson page
  • templates/settings.html — new settings page
  • templates/help.html — new help page
  • static/theme.js — shared script that applies persisted display settings

Features added, roughly in order

  1. Library browser — lazy, drill-down directory browsing instead of typing a full path. /library lists 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.

  2. PDF preview fix — lesson pages were fetching PDFs as text and dumping raw bytes. Now PDFs (and HTML) render in an iframe; .docx/.doc/.rtf show a "no preview, download instead" message rather than garbled text.

  3. 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.

  4. 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.
  5. 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).

  6. Help page (/help) — "How to Use" / "Supported File Types" moved here from the cluttered main dashboard.

  7. 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).

  8. Watch-progress trackingLesson.duration_seconds added alongside the existing progress_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).

  9. 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 flagged hidden: true so they can be un-hidden.

Rough edges fixed this session

  • Single-section course detection_looks_like_course() in offlineu_core.py now 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 by list_library_directory() when skip_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 in settings.json falls back gracefully to the dark default on next load.

Mobile UI overhaul + in-app renaming (this session)

  • Fixed the mobile text-overlap bug.lesson-item/.tree-header in course_dashboard.html had no min-width: 0 or truncation on their flex children, so long course/lesson names (especially scene-release-style dotted names like Udemy.crash.course.electronics...Mimir with zero whitespace to wrap on) would overflow and visually overlap neighboring text on narrow screens. Fixed with min-width:0 + ellipsis truncation (desktop/tablet) and a @media (max-width: 600px) block that stacks title/metadata into separate lines with overflow-wrap: break-word (the dotted-name case needed this specifically — plain white-space: normal doesn't create a break opportunity in a string with no spaces).
  • Library browser redesigned — replaced the nested-indent accordion (AIChatGPT → 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 any hidden_paths.json / recent_views.json entries 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.

  1. Course thumbnailsfind_course_thumbnail() looks for cover/folder/thumbnail/thumb/poster (.jpg/.jpeg/.png/.webp) directly inside a course folder; served via GET /library/thumbnail. Shown in the Library browser and Recently Viewed/Continue Watching, falling back to the emoji icon if there's no cover image.
  2. Library searchGET /library/search?q=... recursively walks the library (reusing list_library_directory's hidden-path filtering) and matches on course name. Debounced search box above the Library browser.
  3. Continue Watching — reuses the Recently-Viewed history (MAX_RECENT_VIEWS bumped 5→20) rather than a full library-wide progress index; split into "in progress" vs. "recently touched" on the dashboard.
  4. Playback speed controlsettings.playback_speed (0.75x2x), persisted the same way video player size already was; speed buttons on the lesson page, applied on load via the existing /api/settings fetch.
  5. 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.html was missing the viewport meta tag entirely, so lesson/video pages weren't actually mobile-responsive until now.
  6. 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.
  7. Per-lesson notes — a note field alongside completed/ progress_seconds in each lesson's existing progress-file entry, via ProgressTracker.update_lesson_note() / POST /api/lesson-note. Fixed a real bug in update_lesson_progress while at it: it always overwrote the entire lesson entry, which would have silently deleted a saved note on the next routine playback-progress autosave.
  8. Mark course as watchedPOST /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).

  1. 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).
  2. Bulk find/replace renameSettings → 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.
  3. 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."
  4. 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.
  5. Backup/exportGET /api/backup, a Settings → Backup & Export button. Zips settings/hidden-paths/recent-views plus every course's progress+notes file (stdlib zipfile, 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).