Fixed themes, empty folder showing and single section course detection

This commit is contained in:
2026-08-21 09:49:44 -04:00
parent 0e9fff65b0
commit fa00a0b0c4
2 changed files with 184 additions and 22 deletions
+107
View File
@@ -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`).
+77 -22
View File
@@ -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',