Eight usability additions on top of the mobile redesign: course cover-image thumbnails, a debounced library search, a Continue Watching section split from Recently Viewed, per-lesson playback speed control, an installable PWA manifest/icons, name sort in the library browser, per-lesson notes, and a bulk mark-course-as-watched action. Also fixes two real bugs found along the way: lesson_view.html was missing the viewport meta tag (so lesson pages weren't actually mobile-responsive), and update_lesson_progress always overwrote the whole progress entry, which would have silently deleted a saved note on the next routine autosave. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
174 lines
10 KiB
Markdown
174 lines
10 KiB
Markdown
# OfflineU — Project Summary (for Claude Code handoff)
|
||
|
||
## What this is
|
||
A fork of the self-hosted course viewer [WhiskeyCoder/OfflineU](https://github.com/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 tracking** — `Lesson.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
|
||
(`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 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 thumbnails** — `find_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 search** — `GET /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 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/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 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.
|
||
|
||
## 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`).
|