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`).
|
||||
+77
-22
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user