diff --git a/OfflineU-project-summary.md b/OfflineU-project-summary.md new file mode 100644 index 0000000..3fe3fca --- /dev/null +++ b/OfflineU-project-summary.md @@ -0,0 +1,107 @@ +# 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. + +## 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`). diff --git a/offlineu_core.py b/offlineu_core.py index 4ffa2d8..307db3f 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -60,7 +60,7 @@ VIDEO_SIZE_BOUNDS = {'video_width': (200, 4000), 'video_height': (120, 3000)} SETTINGS_CHOICES = { 'theme': { 'dark', 'light', 'dracula', 'tokyo_night', 'catppuccin_mocha', 'ayu_dark', - 'github_dark', 'atom_one_dark', 'houston', 'night_owl', 'dainty', 'matcha', + 'github_dark', 'atom_one_dark', 'houston', 'night_owl', 'nord', 'matcha', }, 'font_family': {'system', 'sans', 'serif', 'monospace', 'monaspace'}, 'font_size': {'small', 'medium', 'large', 'xlarge'}, @@ -77,19 +77,24 @@ THEME_DISPLAY_NAMES = { 'tokyo_night': 'Tokyo Night', 'catppuccin_mocha': 'Catppuccin Mocha', 'ayu_dark': 'Ayu Dark', 'github_dark': 'GitHub Dark', 'atom_one_dark': 'Atom One Dark', 'houston': 'Houston', - 'night_owl': 'Night Owl', 'dainty': 'Dainty', 'matcha': 'Matcha', + 'night_owl': 'Night Owl', 'nord': 'Nord', 'matcha': 'Matcha', } # Full color palette per theme. 'accent' here is only the *default* accent # offered when a theme is first selected - the accent_color setting is what # actually drives --accent afterward, so it stays independently editable. # Sourced from each theme's official palette (Dracula, Tokyo Night, -# Catppuccin Mocha, Ayu, GitHub Dark, Atom One Dark, Houston all verified -# against upstream repos/specs). Dainty and Matcha don't have one single -# fixed official hex set (Dainty is a theme *generator*; Matcha's exact -# source values weren't available) - built in the spirit of their -# documented look (refined/minimal, earthy green-and-gray) rather than -# claimed as an exact match. +# Catppuccin Mocha, Ayu, GitHub Dark, Atom One Dark, Houston, Nord all +# verified against upstream repos/specs). Matcha is sourced from +# lucafalasco/matcha's published VS Code theme JSON (editor/sideBar/ +# activityBar backgrounds, foreground, panel.border, and the +# statusBar/button accent color). +# +# Dainty used to be here instead of Nord, but it turned out to be a +# Lab-space theme *generator* (HotWordland/dainty-vscode) with no fixed +# shipped palette - its output depends on whatever base theme you feed +# it, so there was no single "official Dainty" hex set to verify our +# approximation against. Swapped for Nord, which does have one. THEME_PALETTES = { 'dark': { 'bg-primary': '#1a1a1a', 'bg-secondary': '#2d2d2d', 'bg-tertiary': '#3d3d3d', @@ -141,15 +146,15 @@ THEME_PALETTES = { 'bg-tertiary-hover': '#123a5c', 'text-primary': '#d6deeb', 'text-muted': '#5f7e97', 'border-color': '#102a44', 'accent': '#82aaff', 'accent-hover': '#6690e0', }, - 'dainty': { - 'bg-primary': '#1c1c22', 'bg-secondary': '#232329', 'bg-tertiary': '#2c2c34', - 'bg-tertiary-hover': '#35353e', 'text-primary': '#e8e6f0', 'text-muted': '#8a8894', - 'border-color': '#38383f', 'accent': '#c9a0dc', 'accent-hover': '#b285c7', + 'nord': { + 'bg-primary': '#2e3440', 'bg-secondary': '#3b4252', 'bg-tertiary': '#434c5e', + 'bg-tertiary-hover': '#4c566a', 'text-primary': '#d8dee9', 'text-muted': '#4c566a', + 'border-color': '#434c5e', 'accent': '#88c0d0', 'accent-hover': '#5e81ac', }, 'matcha': { - 'bg-primary': '#1e2320', 'bg-secondary': '#262b27', 'bg-tertiary': '#333a34', - 'bg-tertiary-hover': '#3d4539', 'text-primary': '#d6ddd2', 'text-muted': '#7d8a7c', - 'border-color': '#3d443e', 'accent': '#a3c585', 'accent-hover': '#8fb86e', + 'bg-primary': '#1c2427', 'bg-secondary': '#273136', 'bg-tertiary': '#323e45', + 'bg-tertiary-hover': '#3c4850', 'text-primary': '#d1ded3', 'text-muted': '#7c8885', + 'border-color': '#707c4f', 'accent': '#a4b07e', 'accent-hover': '#8b966b', }, } @@ -480,6 +485,11 @@ def _has_direct_media(directory: Path) -> bool: return False +_SECTION_NAME_RE = re.compile( + r'^(section|module|chapter|part|unit|lesson)\b', re.IGNORECASE +) + + def _looks_like_course(directory: Path) -> bool: """ Heuristic for 'this folder is a course, stop recursing into it': @@ -489,17 +499,31 @@ def _looks_like_course(directory: Path) -> bool: Requiring 2+ matching subfolders (rather than just 1) avoids mistaking a publisher/grouping folder that holds a single course - e.g. Pluralsight/Docker Deep Dive/lesson.mp4 - for the course itself. + + The one exception: a directory with exactly one media-holding subfolder + whose name reads as a section label ("Section 1", "Module 2", ...) + rather than a course title. That's still a course with just one + section, not a publisher folder - Pluralsight/Docker Deep Dive doesn't + get named "Section 1", so this doesn't reopen the ambiguity above. """ if _has_direct_media(directory): return True try: - children_with_media = [ + children = [ child for child in directory.iterdir() - if child.is_dir() and not child.name.startswith('.') and _has_direct_media(child) + if child.is_dir() and not child.name.startswith('.') ] except (PermissionError, OSError): return False - return len(children_with_media) >= 2 + children_with_media = [child for child in children if _has_direct_media(child)] + if len(children_with_media) >= 2: + return True + if ( + len(children_with_media) == 1 + and _SECTION_NAME_RE.match(children_with_media[0].name.strip()) + ): + return True + return False HIDDEN_PATHS_FILE = os.path.join(DATA_DIR, 'hidden_paths.json') @@ -533,6 +557,31 @@ def set_path_hidden(path: str, hidden: bool) -> List[str]: return result +def _contains_visible_course(directory: Path, hidden_set: set) -> bool: + """ + Whether `directory` leads to at least one course that isn't curated + out - directly, or via a hidden ancestor folder within this subtree. + + Used by the normal (skip_hidden=True) Library browser so a folder + whose every course has been individually hidden doesn't still show up + as a drillable directory that dead-ends empty once opened. Hiding a + folder hides everything inside it, so a hidden folder short-circuits + the walk rather than counting anything beneath it as visible. + """ + if os.path.abspath(str(directory)) in hidden_set: + return False + if _looks_like_course(directory): + return True + try: + children = [ + c for c in directory.iterdir() + if c.is_dir() and not c.name.startswith('.') + ] + except (PermissionError, OSError): + return False + return any(_contains_visible_course(child, hidden_set) for child in children) + + def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str, Any]: """ List only the immediate children of dir_path for the lazy-loading @@ -584,10 +633,16 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str, 'hidden': is_hidden }) else: - has_course_inside = any( - f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS - for f in entry.rglob('*') - ) + if skip_hidden: + # Only count courses that aren't themselves curated out - + # otherwise a folder whose courses are all hidden + # individually would still show up, empty, once opened. + has_course_inside = _contains_visible_course(entry, hidden_set) + else: + has_course_inside = any( + f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS + for f in entry.rglob('*') + ) if has_course_inside: items.append({ 'type': 'directory',