Fixed themes, empty folder showing and single section course detection
This commit is contained in:
@@ -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`).
|
||||||
+73
-18
@@ -60,7 +60,7 @@ VIDEO_SIZE_BOUNDS = {'video_width': (200, 4000), 'video_height': (120, 3000)}
|
|||||||
SETTINGS_CHOICES = {
|
SETTINGS_CHOICES = {
|
||||||
'theme': {
|
'theme': {
|
||||||
'dark', 'light', 'dracula', 'tokyo_night', 'catppuccin_mocha', 'ayu_dark',
|
'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_family': {'system', 'sans', 'serif', 'monospace', 'monaspace'},
|
||||||
'font_size': {'small', 'medium', 'large', 'xlarge'},
|
'font_size': {'small', 'medium', 'large', 'xlarge'},
|
||||||
@@ -77,19 +77,24 @@ THEME_DISPLAY_NAMES = {
|
|||||||
'tokyo_night': 'Tokyo Night', 'catppuccin_mocha': 'Catppuccin Mocha',
|
'tokyo_night': 'Tokyo Night', 'catppuccin_mocha': 'Catppuccin Mocha',
|
||||||
'ayu_dark': 'Ayu Dark', 'github_dark': 'GitHub Dark',
|
'ayu_dark': 'Ayu Dark', 'github_dark': 'GitHub Dark',
|
||||||
'atom_one_dark': 'Atom One Dark', 'houston': 'Houston',
|
'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
|
# 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
|
# offered when a theme is first selected - the accent_color setting is what
|
||||||
# actually drives --accent afterward, so it stays independently editable.
|
# actually drives --accent afterward, so it stays independently editable.
|
||||||
# Sourced from each theme's official palette (Dracula, Tokyo Night,
|
# Sourced from each theme's official palette (Dracula, Tokyo Night,
|
||||||
# Catppuccin Mocha, Ayu, GitHub Dark, Atom One Dark, Houston all verified
|
# Catppuccin Mocha, Ayu, GitHub Dark, Atom One Dark, Houston, Nord all
|
||||||
# against upstream repos/specs). Dainty and Matcha don't have one single
|
# verified against upstream repos/specs). Matcha is sourced from
|
||||||
# fixed official hex set (Dainty is a theme *generator*; Matcha's exact
|
# lucafalasco/matcha's published VS Code theme JSON (editor/sideBar/
|
||||||
# source values weren't available) - built in the spirit of their
|
# activityBar backgrounds, foreground, panel.border, and the
|
||||||
# documented look (refined/minimal, earthy green-and-gray) rather than
|
# statusBar/button accent color).
|
||||||
# claimed as an exact match.
|
#
|
||||||
|
# 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 = {
|
THEME_PALETTES = {
|
||||||
'dark': {
|
'dark': {
|
||||||
'bg-primary': '#1a1a1a', 'bg-secondary': '#2d2d2d', 'bg-tertiary': '#3d3d3d',
|
'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',
|
'bg-tertiary-hover': '#123a5c', 'text-primary': '#d6deeb', 'text-muted': '#5f7e97',
|
||||||
'border-color': '#102a44', 'accent': '#82aaff', 'accent-hover': '#6690e0',
|
'border-color': '#102a44', 'accent': '#82aaff', 'accent-hover': '#6690e0',
|
||||||
},
|
},
|
||||||
'dainty': {
|
'nord': {
|
||||||
'bg-primary': '#1c1c22', 'bg-secondary': '#232329', 'bg-tertiary': '#2c2c34',
|
'bg-primary': '#2e3440', 'bg-secondary': '#3b4252', 'bg-tertiary': '#434c5e',
|
||||||
'bg-tertiary-hover': '#35353e', 'text-primary': '#e8e6f0', 'text-muted': '#8a8894',
|
'bg-tertiary-hover': '#4c566a', 'text-primary': '#d8dee9', 'text-muted': '#4c566a',
|
||||||
'border-color': '#38383f', 'accent': '#c9a0dc', 'accent-hover': '#b285c7',
|
'border-color': '#434c5e', 'accent': '#88c0d0', 'accent-hover': '#5e81ac',
|
||||||
},
|
},
|
||||||
'matcha': {
|
'matcha': {
|
||||||
'bg-primary': '#1e2320', 'bg-secondary': '#262b27', 'bg-tertiary': '#333a34',
|
'bg-primary': '#1c2427', 'bg-secondary': '#273136', 'bg-tertiary': '#323e45',
|
||||||
'bg-tertiary-hover': '#3d4539', 'text-primary': '#d6ddd2', 'text-muted': '#7d8a7c',
|
'bg-tertiary-hover': '#3c4850', 'text-primary': '#d1ded3', 'text-muted': '#7c8885',
|
||||||
'border-color': '#3d443e', 'accent': '#a3c585', 'accent-hover': '#8fb86e',
|
'border-color': '#707c4f', 'accent': '#a4b07e', 'accent-hover': '#8b966b',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,6 +485,11 @@ def _has_direct_media(directory: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_SECTION_NAME_RE = re.compile(
|
||||||
|
r'^(section|module|chapter|part|unit|lesson)\b', re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_course(directory: Path) -> bool:
|
def _looks_like_course(directory: Path) -> bool:
|
||||||
"""
|
"""
|
||||||
Heuristic for 'this folder is a course, stop recursing into it':
|
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
|
Requiring 2+ matching subfolders (rather than just 1) avoids mistaking
|
||||||
a publisher/grouping folder that holds a single course - e.g.
|
a publisher/grouping folder that holds a single course - e.g.
|
||||||
Pluralsight/Docker Deep Dive/lesson.mp4 - for the course itself.
|
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):
|
if _has_direct_media(directory):
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
children_with_media = [
|
children = [
|
||||||
child for child in directory.iterdir()
|
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):
|
except (PermissionError, OSError):
|
||||||
return False
|
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')
|
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
|
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]:
|
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
|
List only the immediate children of dir_path for the lazy-loading
|
||||||
@@ -583,6 +632,12 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str,
|
|||||||
'media_files': media_count,
|
'media_files': media_count,
|
||||||
'hidden': is_hidden
|
'hidden': is_hidden
|
||||||
})
|
})
|
||||||
|
else:
|
||||||
|
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:
|
else:
|
||||||
has_course_inside = any(
|
has_course_inside = any(
|
||||||
f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
|
f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
|
||||||
|
|||||||
Reference in New Issue
Block a user