Compare commits
51
Commits
a6c49d3762
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d96a456383 | ||
|
|
47e7214bd7 | ||
|
|
bf0a02d008 | ||
|
|
016acacdb5 | ||
|
|
9fb193a07e | ||
|
|
becbb0b419 | ||
|
|
0ed531503e | ||
|
|
4f25d4201b | ||
|
|
72c7f67c3d | ||
|
|
8220bb542a | ||
|
|
de9c05d0d0 | ||
|
|
36d9e7f89b | ||
|
|
fc83876abe | ||
|
|
1fbf0c9312 | ||
|
|
ecabc828ea | ||
|
|
ba8d7e2a1b | ||
|
|
71d6158c60 | ||
|
|
8d1ce72372 | ||
|
|
7fbe63e05c | ||
|
|
62bb5e1318 | ||
|
|
bcd1734c94 | ||
|
|
49b68b8e51 | ||
|
|
9f0f49a399 | ||
|
|
9ff8c70311 | ||
|
|
4eda493e00 | ||
|
|
52371dd02f | ||
|
|
722b381913 | ||
|
|
acabb89981 | ||
|
|
01a965c0f1 | ||
|
|
24a07dd3e6 | ||
|
|
8f7808ab46 | ||
|
|
eae6b09ecd | ||
|
|
e2db5ecef8 | ||
|
|
3039d1206d | ||
|
|
daec0742ec | ||
|
|
21af7ae349 | ||
|
|
219c09692f | ||
|
|
9e7dc85558 | ||
|
|
8f6b00280f | ||
|
|
2941910c54 | ||
|
|
bc6725ee4c | ||
|
|
3fedf849d3 | ||
|
|
393f78206a | ||
|
|
255361ca6e | ||
|
|
68639a2b5b | ||
|
|
21a468fb7f | ||
|
|
8b365c4463 | ||
|
|
c871d6efae | ||
|
|
0454834e62 | ||
|
|
145f91a647 | ||
|
|
5fe3fafc7a |
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
2026-08-26 15:27 UTC — Update README screenshots
|
||||||
|
2026-08-26 14:57 UTC — Add progress/recently-added sort to Library browser
|
||||||
|
2026-08-26 14:45 UTC — Redesign expandable lists to a leaner, flat style
|
||||||
|
2026-08-26 14:27 UTC — Add expand all/collapse all to the course tree
|
||||||
|
2026-08-26 14:13 UTC — Header/footer polish, fix dead link, add focus rings
|
||||||
|
2026-08-26 13:41 UTC — Move sidebar left, add collapse toggle and remembered sections
|
||||||
|
2026-08-26 13:17 UTC — Add hover tooltips for truncated sidebar titles
|
||||||
|
2026-08-26 13:12 UTC — Add course outline sidebar to the lesson page
|
||||||
|
2026-08-25 12:25 UTC — Remove unreliable duplicate-lesson-file matching
|
||||||
|
2026-08-25 00:33 UTC — Widen thumbnail sampling window past platform bumpers
|
||||||
|
2026-08-25 00:26 UTC — Fix race condition in thumbnail candidate generation
|
||||||
|
2026-08-25 00:20 UTC — Add autoplay, progress rings, duplicate-lesson detection, title-card thumbnails
|
||||||
|
2026-08-24 23:22 UTC — Add Favorites, storage drill-down, shortcuts, and What's New
|
||||||
|
2026-08-24 19:00 UTC — Fix video resize handle drifting off-screen
|
||||||
|
2026-08-24 18:06 UTC — Cache the category tree walk (fixes ~1s File Management page load)
|
||||||
|
2026-08-24 14:41 UTC — Add search, bulk actions, undo, and storage usage to File Management
|
||||||
|
2026-08-24 14:00 UTC — Fix build version showing "unknown" in production
|
||||||
|
2026-08-24 13:52 UTC — Fix wrapped-course false positive in picker; add build version display
|
||||||
|
2026-08-24 13:21 UTC — Exclude media-free subtrees from the destination picker too
|
||||||
|
2026-08-24 13:13 UTC — Exclude course/item leaves from destination picker; ease new-folder UX
|
||||||
|
2026-08-24 11:45 UTC — Add wildcard and regex modes to Bulk Rename
|
||||||
|
2026-08-24 11:11 UTC — Add delete and ignore actions to Duplicate Courses
|
||||||
|
2026-08-24 10:37 UTC — Move File Management link into the bottom tab bar
|
||||||
|
2026-08-24 10:29 UTC — Tighten Sort Unsorted matching; consolidate file management tools
|
||||||
|
2026-08-24 09:10 UTC — Add Unsorted folder auto-sort with keyword matching
|
||||||
@@ -4,6 +4,11 @@ FROM python:3.13.5-slim-bookworm
|
|||||||
# set the working directory in the container
|
# set the working directory in the container
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ffprobe (from ffmpeg) reads each video's duration for the library
|
||||||
|
# browser's "how long is this course" display
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# copy the dependencies file to the working directory
|
# copy the dependencies file to the working directory
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
|||||||
@@ -205,10 +205,166 @@ you need real data for every result you return).
|
|||||||
progress+notes file (stdlib `zipfile`, no new dependency) - not the
|
progress+notes file (stdlib `zipfile`, no new dependency) - not the
|
||||||
course files themselves.
|
course files themselves.
|
||||||
|
|
||||||
|
## Outline integration (this session)
|
||||||
|
|
||||||
|
Push lesson notes to a self-hosted Outline instance
|
||||||
|
(`https://mikeline.michaelsitz.com`), organized by a "topic" the user picks
|
||||||
|
per note - each topic is its own Outline collection. Only notes with a
|
||||||
|
topic selected get pushed; everything else stays local-only, same as
|
||||||
|
before.
|
||||||
|
|
||||||
|
- **Credential handling**: the API token lives in its own
|
||||||
|
`OUTLINE_CONFIG_FILE` (`outline_config.json` in `DATA_DIR`), deliberately
|
||||||
|
*not* part of `DEFAULT_SETTINGS`/`load_settings` - those flow through
|
||||||
|
`GET /api/settings`, which `theme.js` fetches on every page load, and a
|
||||||
|
secret has no business riding along in that response. `GET
|
||||||
|
/api/outline/config` only ever returns `{configured, base_url}`, never
|
||||||
|
the token. Confirmed via a real (mocked-backend) test: token round-trips
|
||||||
|
through save but never comes back out on GET.
|
||||||
|
- **Topic chooser = live Outline collections**, not a locally-cached list -
|
||||||
|
`GET /api/outline/collections` proxies `collections.list` fresh every
|
||||||
|
time. Naming a new topic resolves it to a real collection **immediately**
|
||||||
|
(find-by-exact-name-or-create, `resolve_outline_topic()` /
|
||||||
|
`POST /api/outline/resolve-topic`) the moment you tab out of the "new
|
||||||
|
topic" field, rather than waiting until the note is pushed.
|
||||||
|
- **Why eager resolution, not deferred**: the first design deferred
|
||||||
|
collection creation to push-time (avoid an empty collection if the topic
|
||||||
|
was never actually used) - caught a real bug testing it: the push fires
|
||||||
|
via `navigator.sendBeacon()` on `pagehide`, which can't read a response,
|
||||||
|
so a page that fires `pagehide` more than once for the same load (a
|
||||||
|
browser back/forward-cache restore, for instance) would silently
|
||||||
|
re-create a *new* collection every time, since the client had no way to
|
||||||
|
learn the topic name had already been resolved. Fixed by resolving up
|
||||||
|
front and by making the deferred fallback path dedupe-by-name too
|
||||||
|
(`resolve_outline_topic`) - verified live (mocked Outline backend) that
|
||||||
|
firing multiple sequential `pagehide` events for the same lesson creates
|
||||||
|
the collection/document once and updates thereafter, never duplicates.
|
||||||
|
- **Push flow**: `POST /api/outline/push` - creates the Outline document on
|
||||||
|
first push, updates the same one (by the id stashed in the lesson's
|
||||||
|
progress-file entry, alongside the existing `note` field) on every push
|
||||||
|
after. No-ops if the note is empty.
|
||||||
|
- Verified end-to-end against a local mock Outline server (not the real
|
||||||
|
instance - no write-testing against live external services this
|
||||||
|
session), including Settings → Test Connection hitting the real HTTP
|
||||||
|
path.
|
||||||
|
|
||||||
|
## Outline integration follow-up fixes (this session)
|
||||||
|
|
||||||
|
- **Space bar bug**: the lesson page's global keyboard-shortcut handler
|
||||||
|
(space/arrows for play-pause/seek/volume) fired regardless of what had
|
||||||
|
focus, so typing a space in the notes textarea toggled the video instead.
|
||||||
|
Fixed with an `isTypingTarget()` guard that skips the shortcut handler
|
||||||
|
entirely when a textarea/input/select/contenteditable has focus.
|
||||||
|
- **Topic model redesign**: topics used to each spawn their own top-level
|
||||||
|
Outline *collection*. Changed so a topic is instead a *document* inside
|
||||||
|
one fixed, user-configured collection (Settings → Outline Integration →
|
||||||
|
Default collection, e.g. "Training Notes") - a lesson's note becomes a
|
||||||
|
child document nested under its topic document, matching how the user
|
||||||
|
actually organizes their real Outline instance. `list_outline_topics()`
|
||||||
|
fetches the collection's documents and filters to `parentDocumentId is
|
||||||
|
None` locally rather than trusting `documents.list`'s `collectionId`/
|
||||||
|
`parentDocumentId` request filters - both are marked deprecated in
|
||||||
|
Outline's own API spec with unclear recursive-vs-top-level semantics, so
|
||||||
|
filtering the response ourselves is the version that can't be wrong.
|
||||||
|
Verified end-to-end against a local mock Outline server: resolving a new
|
||||||
|
topic creates a top-level doc, pushing a note creates a child doc under
|
||||||
|
it, and the child never leaks back into the topic chooser as if it were
|
||||||
|
a topic itself.
|
||||||
|
|
||||||
## Known limitations still open
|
## Known limitations still open
|
||||||
- App is unauthenticated by design (matches upstream) — settings and hidden-path
|
- App is unauthenticated by design (matches upstream) — settings and hidden-path
|
||||||
curation apply app-wide, not per-browser/per-user.
|
curation apply app-wide, not per-browser/per-user.
|
||||||
|
|
||||||
|
## Six more features (this session)
|
||||||
|
|
||||||
|
Building on `iter_all_courses`/per-course-progress-file scanning from
|
||||||
|
earlier: Notes Hub, transcript search, stale-course nudges, study guide
|
||||||
|
export, a Next Up queue, and an activity heatmap.
|
||||||
|
|
||||||
|
- **One shared library scan** — `_scan_library_activity()` walks every
|
||||||
|
course's progress file once; `format_library_stats()`,
|
||||||
|
`format_stale_courses()`, and `format_activity_heatmap()` all derive from
|
||||||
|
a single call in `index()`, instead of three separate full-library reads
|
||||||
|
on the same dashboard load.
|
||||||
|
- **Notes Hub** (`/notes`, `templates/notes_hub.html`) — every lesson note
|
||||||
|
across the whole library in one place, newest first, since a note was
|
||||||
|
otherwise only visible from its own lesson page. Reuses the existing
|
||||||
|
`/recent/open` cross-course jump route for navigation - no new route
|
||||||
|
needed there.
|
||||||
|
- **Transcript search** — opt-in checkbox on the existing Library search
|
||||||
|
box ("Also search transcripts"). `search_transcripts()` reads subtitle
|
||||||
|
file *contents* for a raw substring match as the cheap filter step, only
|
||||||
|
extracting a cleaned snippet for files that actually match - deliberately
|
||||||
|
scoped to avoid reintroducing the exact O(every file) perf problem fixed
|
||||||
|
earlier this session. Found along the way (flagged as a separate task,
|
||||||
|
not fixed here): subtitle files never actually attach to a Lesson object
|
||||||
|
today - `DynamicCourseParser._create_lesson_from_file` sets
|
||||||
|
`subtitle_file` then immediately returns `None` before ever constructing
|
||||||
|
the `Lesson`, so the `<track kind="subtitles">` element in
|
||||||
|
`lesson_view.html` has never had anything to show. Transcript search
|
||||||
|
works around this by matching a subtitle file to its lesson via same-stem
|
||||||
|
video/audio file lookup instead of depending on `lesson.subtitle_file`.
|
||||||
|
- **Stale course nudges** ("⏰ Pick Back Up") — courses with some progress,
|
||||||
|
not fully done, untouched 14+ days. Known limitation: "not fully done" is
|
||||||
|
judged only from progress-file entries (lessons never opened at all
|
||||||
|
aren't in that file), so a course with several never-touched lessons
|
||||||
|
alongside one marked-complete lesson can look falsely "finished" - getting
|
||||||
|
a true completion count would mean re-scanning every course's full file
|
||||||
|
tree, the exact cost this whole scanning approach exists to avoid.
|
||||||
|
- **Study guide export** (`/course/study-guide`) — compiles every note
|
||||||
|
written for the loaded course into one markdown file, section/lesson
|
||||||
|
structure preserved, skipping anything without a note. Stdlib only,
|
||||||
|
matching how `/api/backup` already works.
|
||||||
|
- **Next Up queue** — ordered, persisted list of courses to tackle next
|
||||||
|
(`next_up.json`, mirrors `hidden_paths.json`'s pattern but as an ordered
|
||||||
|
list, since order matters here). Reorder via ▲▼ buttons rather than
|
||||||
|
drag-and-drop - more reliable on mobile. "📌" button added to Library
|
||||||
|
browser course rows and the loaded-course stats card.
|
||||||
|
- **Activity heatmap** — GitHub-style 90-day contribution calendar folded
|
||||||
|
into the existing Library Stats card, using the same per-day counts the
|
||||||
|
shared scan already computes for the streak stat.
|
||||||
|
|
||||||
|
## Visual polish pass (this session)
|
||||||
|
|
||||||
|
Templates-only, no backend changes. Confirmed by inventory
|
||||||
|
(`grep -noE '#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b'` across all 5 templates)
|
||||||
|
before touching anything, to separate real bugs from the `:root` theme
|
||||||
|
palette definitions themselves.
|
||||||
|
|
||||||
|
- **Theme-aware colors (the actual bug)** — JS-generated status/loading
|
||||||
|
text and a few component styles used hardcoded hex instead of the CSS
|
||||||
|
variables the theme system already provides: `#007acc` instead of
|
||||||
|
`var(--accent)` (the course completion-% number, lesson_view.html's
|
||||||
|
notification banner), `#999`/`#666` instead of `var(--text-muted)` for
|
||||||
|
loading/empty/error hint text, and `#28a745`/`#ff6b6b` (success/error)
|
||||||
|
hardcoded in ~25 places with no shared variable at all. Added
|
||||||
|
`--success`/`--error` to all 5 templates' `:root` blocks (fixed values,
|
||||||
|
not theme-varied - semantic status colors stay recognizable across
|
||||||
|
themes by convention) and replaced every non-`:root` occurrence.
|
||||||
|
Deliberately left alone: the `.lesson-type` badge colors (video/audio/
|
||||||
|
text/quiz/mixed - intentional fixed content-type coding) and generic UI
|
||||||
|
chrome grays (progress-bar track, disabled-button background) unrelated
|
||||||
|
to theme. Verified live by switching to Nord and Light themes and
|
||||||
|
confirming the completion-% number and status text actually track the
|
||||||
|
chosen accent/muted colors instead of staying stuck on the old defaults.
|
||||||
|
- **Dashboard card collapse** — click a card's `<h2>` to collapse it,
|
||||||
|
remembered in `localStorage` per card, no backend involved. Applied to
|
||||||
|
the six stackable no-course-dashboard cards (Library Stats, Next Up,
|
||||||
|
Pick Back Up, Recently Added, Continue Watching, Recently Viewed) - the
|
||||||
|
Library browser card stays always-visible as the primary action.
|
||||||
|
- **Course page action buttons** — the three stats-card buttons (Mark all
|
||||||
|
completed / Add to Next Up / Download Study Guide) now use consistent
|
||||||
|
`.btn-sm` compact sizing with icons, in a `.course-actions` flex row
|
||||||
|
instead of a plain inline-styled stack.
|
||||||
|
- **Skeleton loading states** — the Library browser and transcript search
|
||||||
|
show shimmering placeholder rows (`skeletonRowsHtml()`, pure CSS
|
||||||
|
gradient-position animation) while a fetch is in flight, instead of
|
||||||
|
bare "Loading…"/"Searching…" text.
|
||||||
|
- **Hover consistency** — `.transcript-result` now gets the same lift
|
||||||
|
hover `.lesson-item` already had; `.heatmap-day` gets a GitHub-style
|
||||||
|
hover scale-up; `.note-card-link` (Notes Hub) gets a background-chip
|
||||||
|
hover instead of just underline.
|
||||||
|
|
||||||
## Workflow that's been in use
|
## Workflow that's been in use
|
||||||
Edit locally → `git add . && git commit -m "..." && git push` to the private
|
Edit locally → `git add . && git commit -m "..." && git push` to the private
|
||||||
Gitea repo → redeploy the stack in Dockhand (which builds from the fresh
|
Gitea repo → redeploy the stack in Dockhand (which builds from the fresh
|
||||||
|
|||||||
@@ -1,62 +1,333 @@
|
|||||||
# OfflineU: Self-Hosted Local Course Loader & Progress Tracker
|
# OfflineU
|
||||||
|
|
||||||
**OfflineU** is a sleek, self-hosted web app designed to load and view your offline video, audio, text, and quiz-based training courses. Whether it's Udemy downloads, "open sourced" training archives, or personal content, OfflineU turns your course folder into a fully navigable dashboard with automatic progress tracking.
|
Self-hosted course viewer for a local video/audio/text training library. Runs
|
||||||
|
as a single Flask app in Docker, tracks watch progress and notes per lesson,
|
||||||
|
and gives the whole library a dashboard-style home page instead of a bare
|
||||||
|
file browser.
|
||||||
|
|
||||||
|
This is a personal deployment, not a public project — this doc is internal
|
||||||
|
reference for running/maintaining it, not a pitch.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
*Screenshots use a synthetic sample library, not the real deployment's
|
||||||
|
content. Default dark theme shown — 45 others are available in Settings.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## Deployment
|
||||||
|
|
||||||
* 📁 **Dynamic folder parsing**: Scans and maps your course structure into a browsable tree view.
|
Deployed via `docker-compose.yml`, built directly from the `Dockerfile` in
|
||||||
* 🎥 **Video & Audio player**: Integrated media player with resume & completion tracking.
|
this repo (`pull_policy: build`, not a registry pull):
|
||||||
* 📄 **Text & HTML viewer**: Supports .txt, .md, .html, .pdf, and more.
|
|
||||||
* ✅ **Lesson progress tracking**: Auto-saves your time spent and marks lessons as completed.
|
```yaml
|
||||||
* ♻️ **Continue where you left off**: Resume instantly from your last-accessed lesson.
|
services:
|
||||||
* 💾 **Local-first & private**: 100% offline. No cloud, no tracking, no nonsense.
|
offlineu:
|
||||||
* 🧑💻 **Works with any course format**: No metadata required, just structured folders.
|
build: .
|
||||||
* 🧠 **Ideal for hoarders, students, or offline learning setups**
|
pull_policy: build
|
||||||
|
container_name: offlineu
|
||||||
|
network_mode: host
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
- PUID=1000
|
||||||
|
- PGID=10
|
||||||
|
- TZ=America/New_York
|
||||||
|
- FLASK_ENV=production
|
||||||
|
volumes:
|
||||||
|
- /volume1/files/training:/app/courses # course library
|
||||||
|
- /volume2/docker/offlineu/data:/app/data # settings/progress/notes
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auto-deploy:** the Gitea repo has a webhook to Dockhand — a push to `main`
|
||||||
|
triggers a full image **rebuild** from the Dockerfile (confirmed, not just a
|
||||||
|
container restart), so Dockerfile changes (e.g. adding `ffmpeg`) take effect
|
||||||
|
on the very next push without any manual step.
|
||||||
|
|
||||||
|
**Confirming a deploy landed:** a plain `VERSION` file at the repo root
|
||||||
|
(a timestamp + short description, updated by hand alongside each commit)
|
||||||
|
ships into the image via the normal `COPY . .` and is shown at the bottom
|
||||||
|
of the Settings page and in `/health`'s JSON response, so after a push you
|
||||||
|
can check the running container actually picked it up instead of
|
||||||
|
guessing. Deliberately not derived from `git rev-parse` at Docker build
|
||||||
|
time - Dockhand's build context doesn't reliably have `.git` available,
|
||||||
|
which silently produced "unknown" instead of an actual commit. Reads as
|
||||||
|
`dev` outside Docker (no `VERSION` file to read, e.g. before the first
|
||||||
|
commit that adds one). `CHANGELOG.md` at the repo root accumulates that
|
||||||
|
same line on every commit (newest first) instead of overwriting it, and
|
||||||
|
shows as a "What's New" list under the version line in Settings - so you
|
||||||
|
can see recent history, not just the current build.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🗈️ Screenshots
|
## Local development
|
||||||
|
|
||||||
> 
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Installation
|
|
||||||
|
|
||||||
### 🔁 Quick Start (Local)
|
|
||||||
|
|
||||||
1. Clone the repo:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/WhiskeyCoder/OfflineU.git
|
|
||||||
cd OfflineU
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Install Python dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install flask
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Run the app:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python offlineu_core.py --create-templates
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Open your browser:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://127.0.0.1:5000
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📂 Folder Structure Example
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python offlineu_core.py --library-path /path/to/courses --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens on `http://127.0.0.1:5000`. `ffprobe` (from ffmpeg) needs to be on
|
||||||
|
`PATH` for video-duration lookups to work locally; without it those just
|
||||||
|
silently stay blank instead of erroring.
|
||||||
|
|
||||||
|
### CLI options
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
| ---------------------- | ----------------------------------------------------- |
|
||||||
|
| `--host` | Bind host (default `0.0.0.0`) |
|
||||||
|
| `--port` | Bind port (default `5000`) |
|
||||||
|
| `--debug` | Enable Flask debug mode |
|
||||||
|
| `--create-templates` | Regenerate default templates if missing |
|
||||||
|
| `--library-path` | Course library root (overrides `COURSES_LIBRARY_PATH`) |
|
||||||
|
| `course_path` (positional) | Load a specific course directly at startup |
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| ---------------------- | -------------- | ------------------------------------------ |
|
||||||
|
| `COURSES_LIBRARY_PATH` | `/app/courses` | Course library root |
|
||||||
|
| `OFFLINEU_DATA_DIR` | `/app/data` | Where settings/progress/notes data lives |
|
||||||
|
| `AUTO_LOAD_COURSE` | — | Course path to load automatically on start |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
**Library browsing**
|
||||||
|
- Lazy-loading folder browser (grid or list view) with search, including an
|
||||||
|
opt-in transcript search across subtitle files. Sortable by name,
|
||||||
|
progress, or how recently a course showed up in the library
|
||||||
|
- Course cards show media file count, total video/audio runtime, and
|
||||||
|
completion % at a glance
|
||||||
|
- Cover art: uses a manually-placed `cover`/`folder`/`thumbnail`/`thumb`/
|
||||||
|
`poster` image if a course has one, otherwise auto-generates one via
|
||||||
|
`ffmpeg` - samples a few candidate frames from the first several seconds
|
||||||
|
of the course's first video (where an intro title card typically lives)
|
||||||
|
and keeps the one that compresses to the largest JPEG, a cheap proxy for
|
||||||
|
"has the most going on" that favors a title card/logo over a blank
|
||||||
|
fade-in or a plain frame of the presenter
|
||||||
|
- **File Management** ([/unsorted](templates/unsorted.html), its own item
|
||||||
|
("Files") in the bottom tab bar alongside Home/Notes/Help/Settings): a
|
||||||
|
dedicated page for everything that touches files on disk, since it needs
|
||||||
|
more room than a Settings card and warrants review before anything
|
||||||
|
actually changes.
|
||||||
|
- *Sort Unsorted*: drop new/incoming courses into an `Unsorted` folder at
|
||||||
|
the library root, then scan proposes a destination for each one by keyword
|
||||||
|
overlap against the *existing* category tree - read fresh from disk every
|
||||||
|
scan, so it adapts to whatever folders you actually have rather than any
|
||||||
|
hardcoded subject list. Three outcomes per item: a confident match to an
|
||||||
|
existing folder, a suggested new subfolder under a broader category match,
|
||||||
|
or "needs review" (unchecked by default) when nothing overlaps at all.
|
||||||
|
Every row has a folder-picker dropdown (listing every real category in the
|
||||||
|
library, plus "+ Create new folder…") so a wrong or low-confidence guess
|
||||||
|
is one click to correct, and a name field to rename the course's folder
|
||||||
|
in the same move. Creating a new folder means picking its parent from a
|
||||||
|
dropdown and typing just the new folder's own name, with a live "Will
|
||||||
|
create: X/Y" preview so the resulting path is confirmed before applying -
|
||||||
|
the same picker Manage Library's Move action uses. The picker excludes
|
||||||
|
actual course/item folders, not just organizational ones: a leaf folder
|
||||||
|
holding a single non-video/audio file (an ebook, an audiobook in a
|
||||||
|
format this app doesn't play, etc.); a folder whose whole subtree has no
|
||||||
|
video/audio anywhere in it at all (a course's bundled source code, a
|
||||||
|
Python virtualenv, project assets, ...) even though it has plenty of
|
||||||
|
subfolders; and a release-bundle folder that wraps a single real course
|
||||||
|
one level down under its own "course display name" folder, alongside
|
||||||
|
unrelated (possibly empty) junk siblings at the same level - all three
|
||||||
|
cases Library browsing's own course detection wouldn't catch either,
|
||||||
|
since it only checks one level of nesting for video/audio. Nothing on
|
||||||
|
disk moves until you review and hit Apply. Matching
|
||||||
|
ignores common noise (e-learning
|
||||||
|
platform names, release/distribution-group tags, dates) via a stopword
|
||||||
|
list, and beyond that treats a match against a folder's own deliberate
|
||||||
|
name as always stronger evidence than a word only borrowed from a
|
||||||
|
sibling course's title - and among those borrowed words, downweights
|
||||||
|
ones that recur across many categories (a prolific creator's name, etc.)
|
||||||
|
so they can't outvote a genuinely specific word just by sharing more of
|
||||||
|
them.
|
||||||
|
- *Refresh Library*: manually bypasses the 5-minute filesystem-scan cache
|
||||||
|
(this now includes the category tree Sort Unsorted/Manage Library's
|
||||||
|
picker builds - previously rebuilt on every page load, a full,
|
||||||
|
uncached walk of the whole library that got noticeably slow over a
|
||||||
|
NAS-mounted (SMB) library as the course count grew), for when files
|
||||||
|
were added/removed directly on disk.
|
||||||
|
- *Bulk Rename*: find & replace across every course/folder name in the
|
||||||
|
library at once, with a per-match preview and the ability to drop
|
||||||
|
individual matches before applying. Three match modes: plain text
|
||||||
|
(default, literal substring), wildcard (shell-style `*`/`?`, e.g.
|
||||||
|
`.BOOKWARE*` catches `.BOOKWARE-GETH`, `.BOOKWARE-BOOKTIME`,
|
||||||
|
`.BOOKWARE-BLZiSO`, etc. in one pass), or full regex (with
|
||||||
|
backreferences in the replacement, e.g. `\1`).
|
||||||
|
- *Manage Library*: hide courses/folders from the browser without
|
||||||
|
touching anything on disk (hiding a folder hides everything inside it;
|
||||||
|
bulk-select elsewhere to hide or queue several at once), rename a
|
||||||
|
course/folder in place, or **move** one anywhere else in the library via
|
||||||
|
the same folder-picker/create-new-folder UI Sort Unsorted uses - handy
|
||||||
|
for correcting a bad auto-sort later or just reorganizing. A search box
|
||||||
|
finds any course/folder by name anywhere in the tree instead of
|
||||||
|
expanding levels one by one (results get the same Move/Rename/Hide
|
||||||
|
actions, just without an expand arrow, since a hit is somewhere specific
|
||||||
|
rather than a level to browse into); every row also has a checkbox, so
|
||||||
|
several items - found via search or expanded across different tree
|
||||||
|
levels - can be hidden, shown, or moved to the same destination together
|
||||||
|
in one batch instead of one at a time. Rows use the same flat, leading-
|
||||||
|
chevron styling as the course tree (see Playback & progress below).
|
||||||
|
- *Undo*: a "Last action: ... [Undo]" bar appears after any move or
|
||||||
|
rename (Sort Unsorted apply, Bulk Rename apply, a Manage Library
|
||||||
|
move/rename, a bulk move) and reverses the whole batch in one click.
|
||||||
|
Only ever covers move/rename - hiding is already a one-click toggle with
|
||||||
|
nothing to undo, and Delete is permanent by design, so it's deliberately
|
||||||
|
never in this history no matter how "undo" gets framed. Re-checks each
|
||||||
|
item before reversing it (the original spot may have been reused since),
|
||||||
|
so a partial batch failure reports exactly what did and didn't reverse
|
||||||
|
rather than silently doing nothing.
|
||||||
|
- *Duplicate Courses*: scans every course in the library (Unsorted
|
||||||
|
included) for names that look like the same thing filed twice, using the
|
||||||
|
same tokenizer as Sort Unsorted so a platform-name or release-tag
|
||||||
|
difference doesn't hide a real duplicate. Groups by similarity (union of
|
||||||
|
any two courses over the threshold, transitively) rather than showing
|
||||||
|
raw pairs; each course in a group gets a one-click Hide, or a **Delete**
|
||||||
|
that permanently removes it from disk (the one destructive action in the
|
||||||
|
whole app - confirmed with the full path before it runs). A group can
|
||||||
|
also be marked **"Not a duplicate"** if the match is wrong, which
|
||||||
|
excludes that specific pair from future scans without touching anything
|
||||||
|
else that happens to match one of those courses; ignored pairs are
|
||||||
|
listed (and reversible) under "Ignored matches," and persist through
|
||||||
|
backup/restore alongside hidden paths and Next Up. (Matching is
|
||||||
|
deliberately scoped to whole course folders, not individual lesson
|
||||||
|
files - courses that split one topic across several numbered files,
|
||||||
|
e.g. "...Part 1"/"...Part 2", look just as similar by name as a genuine
|
||||||
|
re-download, so file-level matching wasn't reliable enough to keep.)
|
||||||
|
- *Clean Up Stale References*: finds entries in the hidden-paths list,
|
||||||
|
Next Up queue, Favorites, or Recently Viewed history that point at a path
|
||||||
|
no longer on disk - normally from renaming/moving/deleting a course
|
||||||
|
directly on the NAS instead of through the app (doing it through the app
|
||||||
|
already keeps these in sync). Review individually or clear them all at
|
||||||
|
once; course files themselves are never touched.
|
||||||
|
- *Storage Usage*: disk usage per top-level library folder, largest
|
||||||
|
first, with a simple proportional bar per entry - manually triggered
|
||||||
|
(it reads every file's size) rather than run automatically. Click any
|
||||||
|
folder row to drill into its own contents one level at a time, with a
|
||||||
|
Back button to climb back out.
|
||||||
|
|
||||||
|
**Dashboard**
|
||||||
|
- Library-wide stats (courses, lessons completed, time watched, time
|
||||||
|
remaining, day streak) and a 90-day activity heatmap
|
||||||
|
- Favorites — star any course from its list row or its own page for quick
|
||||||
|
access from the dashboard, independent of recency or curated queues
|
||||||
|
- Next Up queue (manually curated, reorderable)
|
||||||
|
- Pick Back Up (courses with progress that have gone stale)
|
||||||
|
- Recently Added / Recently Viewed
|
||||||
|
- Surprise Me — dice icon in the header, random pick weighted toward
|
||||||
|
incomplete courses
|
||||||
|
- Grid view shows a small progress ring in the corner of each course's
|
||||||
|
thumbnail (a checkmark once complete) instead of a separate bar, so
|
||||||
|
completion reads at a glance without switching to list view
|
||||||
|
- Interface polish: a shorter header (matching every other page instead of
|
||||||
|
standing out taller), Files moved before Settings in the bottom nav, a
|
||||||
|
visible focus ring for keyboard navigation, and a redundant course-name
|
||||||
|
badge removed from the header (the course name is already the page's own
|
||||||
|
heading right below it)
|
||||||
|
|
||||||
|
**Playback & progress**
|
||||||
|
- Video/audio player with resize, playback-speed presets, and resume-from-
|
||||||
|
last-position
|
||||||
|
- Course outline sidebar on the lesson page (left side): every section and
|
||||||
|
lesson in the course (videos and documents alike - a standalone PDF/doc
|
||||||
|
is its own entry, same as a video), one click to jump anywhere without
|
||||||
|
backing out to the course page first. The current lesson's section
|
||||||
|
opens automatically and scrolls into view; sticky and independently
|
||||||
|
scrollable on desktop, stacks below the player on narrow viewports.
|
||||||
|
Collapsible to a slim rail (remembered per device) for a full-width
|
||||||
|
player, and which sections you've manually expanded is remembered too,
|
||||||
|
so re-opening a course doesn't reset everything back to collapsed. An
|
||||||
|
"Expand all" link (toggling to "Collapse all") opens or closes every
|
||||||
|
section at once - same control on the dashboard's course tree.
|
||||||
|
Shares its rendering with the loaded-course dashboard view (`templates/
|
||||||
|
_course_tree.html`), so the two always look and behave the same. Flat
|
||||||
|
list styling - no per-row box or border, a small leading chevron that
|
||||||
|
doubles as the expand affordance instead of a separate right-edge
|
||||||
|
button, indentation for hierarchy - for a denser, cleaner look than
|
||||||
|
boxed rows would give at this list length.
|
||||||
|
- Auto-play next lesson when one ends, with a cancelable few-second
|
||||||
|
countdown - on by default, toggle it off in Settings → Video Player
|
||||||
|
- Keyboard shortcuts on the lesson page: Space (play/pause), ←/→ (seek
|
||||||
|
10s), ↑/↓ (volume), `,`/`.` (step playback speed), `[`/`]` (previous/next
|
||||||
|
lesson), `F` (fullscreen), `N` (quick-capture a note) - see Help for the
|
||||||
|
full list
|
||||||
|
- Auto-tracks watch progress and completion per lesson
|
||||||
|
- Video/audio durations read via `ffprobe` and cached persistently per
|
||||||
|
course (`.offlineu_duration_cache.json`), so total runtime and per-lesson
|
||||||
|
length show up before you've ever pressed play — not just after; the
|
||||||
|
same cache backs the library-wide "time remaining" stat
|
||||||
|
- Settings → "Precompute Lengths & Cover Art" walks the whole library in
|
||||||
|
the background to populate durations and thumbnails up front, with live
|
||||||
|
progress
|
||||||
|
- Settings → "Regenerate Thumbnails" deletes and re-runs auto-generated
|
||||||
|
cover art for every course that has one (manually-placed covers are
|
||||||
|
never touched) - the only way to pick up an improvement to how
|
||||||
|
thumbnails are picked on courses whose thumbnail was already cached
|
||||||
|
under the old logic, since it's otherwise served from disk forever
|
||||||
|
|
||||||
|
**Notes**
|
||||||
|
- Timestamped notes per lesson, capturable via a keyboard shortcut without
|
||||||
|
leaving the player
|
||||||
|
- Notes Hub: every note across the whole library, searchable, in one list
|
||||||
|
- Per-course study guide export (markdown, organized by section)
|
||||||
|
|
||||||
|
**Personalization** (Settings)
|
||||||
|
- 46 built-in themes (grouped in the dropdown: Base, Editor Themes, and 5
|
||||||
|
categories of website-inspired schemes sourced from Figma's "53 Unique
|
||||||
|
Website Color Schemes" - 34 of the 53 kept, all as dark/colored
|
||||||
|
backgrounds rather than the source page's mostly-white mockups, after
|
||||||
|
cutting a couple of neon-bright accents and pruning near-duplicates by
|
||||||
|
actual color distance), or a fully custom accent color, corner radius,
|
||||||
|
and card style
|
||||||
|
- Font, text size, page width, and spacing density
|
||||||
|
- Default library path override (Refresh Library and Bulk Rename live on the
|
||||||
|
File Management page - see above)
|
||||||
|
|
||||||
|
**Backup & integrations**
|
||||||
|
- One-click backup export and restore: settings, hidden-path choices, the
|
||||||
|
Next Up queue, Favorites, recent-view history, ignored-duplicate pairs,
|
||||||
|
Outline config, and every course's progress/notes as a zip (not the
|
||||||
|
course files themselves). Restore
|
||||||
|
overwrites current data and needs a matching course folder to already
|
||||||
|
exist for each course's progress to land - it's a "put my data back"
|
||||||
|
action, not a merge
|
||||||
|
- Optional [Outline](https://www.getoutline.com/) integration — push a
|
||||||
|
lesson's notes to an Outline document/collection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data files
|
||||||
|
|
||||||
|
Everything under `OFFLINEU_DATA_DIR` (app-wide, not tied to a course):
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
| ---------------------- | ------------------------------------------ |
|
||||||
|
| `settings.json` | Theme, layout, library path, etc. |
|
||||||
|
| `hidden_paths.json` | Courses/folders curated out of the browser |
|
||||||
|
| `next_up.json` | The Next Up queue, in order |
|
||||||
|
| `favorites.json` | Favorited course paths |
|
||||||
|
| `recent_views.json` | Cross-course "Recently Viewed" history |
|
||||||
|
| `ignored_duplicates.json` | Course-path pairs confirmed not duplicates |
|
||||||
|
| `outline_config.json` | Outline API token/collection mapping |
|
||||||
|
|
||||||
|
Per-course, written inside the course's own folder on the library volume:
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
| ----------------------------------- | -------------------------------------------------------- |
|
||||||
|
| `.offlineu_progress.json` | Per-lesson completed/progress/duration/notes |
|
||||||
|
| `.offlineu_duration_cache.json` | ffprobe duration cache, keyed by (path, size, mtime) |
|
||||||
|
| `.offlineu_thumbnail.jpg` | Auto-generated cover art (only if no manual cover exists) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Folder structure example
|
||||||
|
|
||||||
|
```
|
||||||
MyCourse/
|
MyCourse/
|
||||||
├── Section 1/
|
├── Section 1/
|
||||||
│ ├── 01 - Intro.mp4
|
│ ├── 01 - Intro.mp4
|
||||||
@@ -66,64 +337,40 @@ MyCourse/
|
|||||||
│ ├── 04 - Advanced Tips.mp4
|
│ ├── 04 - Advanced Tips.mp4
|
||||||
│ └── resources/
|
│ └── resources/
|
||||||
│ └── extras.md
|
│ └── extras.md
|
||||||
└── .offlineu_progress.json ← created automatically
|
├── .offlineu_progress.json ← created automatically
|
||||||
|
├── .offlineu_duration_cache.json ← created automatically
|
||||||
|
└── .offlineu_thumbnail.jpg ← created automatically, only if no manual cover exists
|
||||||
```
|
```
|
||||||
|
|
||||||
> 🌟 File types are detected automatically — videos, audio, quizzes, and docs.
|
No metadata files needed — course/section/lesson names come straight from
|
||||||
|
folder and file names.
|
||||||
|
|
||||||
|
An `Unsorted/` folder at the library root (alongside the real category
|
||||||
|
folders) is the drop point for new/incoming courses — see Sort Unsorted
|
||||||
|
above.
|
||||||
|
|
||||||
|
## Supported file types
|
||||||
|
|
||||||
|
| Type | Extensions |
|
||||||
|
| --------- | ------------------------------------------------------------------ |
|
||||||
|
| Video | `.mp4` `.mkv` `.avi` `.mov` `.webm` `.m4v` `.flv` `.wmv` |
|
||||||
|
| Audio | `.mp3` `.wav` `.m4a` `.aac` `.ogg` `.flac` |
|
||||||
|
| Documents | `.txt` `.md` `.html` `.htm` `.pdf` `.docx` `.doc` `.rtf` |
|
||||||
|
| Subtitles | `.srt` `.vtt` `.ass` `.sub` `.sbv` |
|
||||||
|
| Quizzes | Any doc file whose name contains `quiz`, `exam`, `test`, `assessment`, `exercise`, `assignment`, or `homework` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📁 Supported File Types
|
## Tools
|
||||||
|
|
||||||
| Type | Extensions |
|
`tools/generate_website_scheme_themes.py` is the one-off script that
|
||||||
| --------- | ----------------------------------------------------------- |
|
produced the 34 "website scheme" theme entries in `THEME_PALETTES` (of
|
||||||
| Videos | `.mp4`, `.mkv`, `.webm`, `.mov`, `.avi`, etc. |
|
53 candidates - all 24 light-mode ones were converted to dark/colored
|
||||||
| Audio | `.mp3`, `.wav`, `.aac`, etc. |
|
backgrounds per request, then 19 were cut across two review passes: 2
|
||||||
| Docs | `.txt`, `.md`, `.html`, `.pdf`, `.docx` |
|
for a neon-bright accent, 17 for landing too close to another kept
|
||||||
| Subtitles | `.srt`, `.vtt` |
|
scheme once mapped into actual UI colors, since forcing everything dark
|
||||||
| Quizzes | Detected if file name contains `quiz`, `exam`, `test`, etc. |
|
made several collide that hadn't before) - not part of the running app,
|
||||||
|
kept for reference if those palettes ever need regenerating or
|
||||||
---
|
extending. `build_theme()` takes a `force_mood='dark'|'light'` override
|
||||||
|
for exactly this kind of bulk remapping. See its module docstring for
|
||||||
## ⚙️ CLI Options
|
how it turned Figma's color-scheme screenshots into actual UI palettes.
|
||||||
|
|
||||||
| Option | Description |
|
|
||||||
| -------------------- | ------------------------------- |
|
|
||||||
| `--host` | Set host (default: `127.0.0.1`) |
|
|
||||||
| `--port` | Set port (default: `5000`) |
|
|
||||||
| `--debug` | Enable Flask debug mode |
|
|
||||||
| `--create-templates` | Generate default HTML templates |
|
|
||||||
| `<course_path>` | Load course directly at startup |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧠 Roadmap
|
|
||||||
|
|
||||||
* [x] Base function and testing
|
|
||||||
* [ ] Multi-user profile support
|
|
||||||
* [ ] Dark/light theme switcher
|
|
||||||
* [ ] Built-in quiz interactivity
|
|
||||||
* [ ] Import/export course metadata
|
|
||||||
* [ ] Mobile app wrapper
|
|
||||||
* [ ] Self hosted Docker Deployment
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💬 Community
|
|
||||||
|
|
||||||
Join the development, suggest features, or ask questions via:
|
|
||||||
|
|
||||||
* GitHub Issues: [https://github.com/WhiskeyCoder/OfflineU/issues](https://github.com/WhiskeyCoder/OfflineU/issues)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ License
|
|
||||||
|
|
||||||
MIT License — Use freely, modify locally, share widely.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Author
|
|
||||||
|
|
||||||
Built with ❤️ by [@WhiskeyCoder](https://github.com/WhiskeyCoder)
|
|
||||||
Inspired by the dream of **learning freely, offline, and without limits.**
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
+3279
-189
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
|||||||
|
{% import '_icons.html' as icons %}
|
||||||
|
{#
|
||||||
|
Shared recursive course-outline renderer - one section/lesson tree
|
||||||
|
markup used by both the loaded-course dashboard view and the lesson
|
||||||
|
page's sidebar, so the two stay visually and behaviorally in sync
|
||||||
|
instead of drifting apart as separate copies. Needs `toggleTree()`
|
||||||
|
(JS) and the .tree-*/.lesson-*/.status-icon/.watched-badge CSS to be
|
||||||
|
defined by whichever page imports this - see course_dashboard.html
|
||||||
|
for the canonical versions.
|
||||||
|
|
||||||
|
Flat list styling (no per-row box/border), a leading chevron that
|
||||||
|
doubles as the expand affordance instead of a separate right-edge
|
||||||
|
button, and indentation for hierarchy - the chevron's rotation is
|
||||||
|
driven purely by CSS (:has(+ .tree-content.expanded)), so toggling
|
||||||
|
is just adding/removing one class on .tree-content; nothing in JS
|
||||||
|
ever touches the icon directly.
|
||||||
|
|
||||||
|
`current_lesson` (a Lesson object, not a path string) is optional -
|
||||||
|
when given, the matching row gets a `current` class instead of being
|
||||||
|
a plain click target, and stays unclickable since you're already
|
||||||
|
there. Path-string comparison isn't used for this because the lesson
|
||||||
|
page's incoming URL can be in several different formats (see
|
||||||
|
find_lesson_in_tree) - comparing lesson.path (the raw absolute
|
||||||
|
filesystem path, always unique and unambiguous) sidesteps all of that.
|
||||||
|
#}
|
||||||
|
{% macro render_tree_node(node, course_path, current_lesson=none, depth=0) %}
|
||||||
|
{% set stats = section_stats(node) %}
|
||||||
|
<div class="tree-item">
|
||||||
|
<div class="tree-header directory" onclick="toggleTree(this)">
|
||||||
|
<span class="tree-toggle-icon">▸</span>
|
||||||
|
<span class="tree-name" title="{{ node.name }}">{{ node.name }}</span>
|
||||||
|
<span class="tree-stats">
|
||||||
|
{% if stats.total_lessons %}{{ stats.completed_lessons }}/{{ stats.total_lessons }}{% else %}Empty{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if node.children or node.lessons %}
|
||||||
|
<div class="tree-content" data-section-path="{{ node.path }}">
|
||||||
|
{% for child_name, child_node in node.children.items() %}
|
||||||
|
{{ render_tree_node(child_node, course_path, current_lesson, depth + 1) }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for lesson in node.lessons %}
|
||||||
|
{% set lesson_relative_path = lesson.path|replace('\\', '/')|replace(course_path|replace('\\', '/'), '')|replace('//', '/')|replace('/', '', 1) %}
|
||||||
|
{% set percent_watched = 100 if lesson.completed else (((100 * lesson.progress_seconds / lesson.duration_seconds)|round|int) if (lesson.duration_seconds and lesson.progress_seconds) else 0) %}
|
||||||
|
{% set is_current = current_lesson and lesson.path == current_lesson.path %}
|
||||||
|
<div class="lesson-item {% if is_current %}current{% elif lesson.completed %}completed{% elif percent_watched %}in-progress{% endif %}"
|
||||||
|
{% if not is_current %}onclick="window.location.href='/lesson/{{ lesson_relative_path }}/{{ lesson.title|replace(' ', '_') }}'"{% endif %}>
|
||||||
|
<span class="lesson-icon">
|
||||||
|
{% if lesson.lesson_type == 'video' %}{{ icons.icon('video', 15) }}
|
||||||
|
{% elif lesson.lesson_type == 'audio' %}{{ icons.icon('music', 15) }}
|
||||||
|
{% elif lesson.lesson_type == 'quiz' %}{{ icons.icon('clipboard', 15) }}
|
||||||
|
{% elif lesson.lesson_type == 'mixed' %}{{ icons.icon('package', 15) }}
|
||||||
|
{% else %}{{ icons.icon('file-text', 15) }}{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="lesson-name" title="{{ lesson.title }}">{{ lesson.title }}</span>
|
||||||
|
{% if lesson.duration_seconds and lesson.duration_seconds >= 60 %}
|
||||||
|
<span class="lesson-duration">{{ lesson.duration_seconds|format_duration }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if lesson.completed %}
|
||||||
|
<span class="status-icon completed">{{ icons.icon('check', 13) }}</span>
|
||||||
|
{% elif percent_watched %}
|
||||||
|
<span class="watched-badge">{{ percent_watched }}%</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{#
|
||||||
|
Shared stroke-style SVG icon set (Feather-style, 24x24 viewBox,
|
||||||
|
currentColor stroke) so every page uses the same hand-picked icons
|
||||||
|
instead of OS-rendered emoji, which render inconsistently across
|
||||||
|
platforms and clash with the app's vector logo. Import with
|
||||||
|
{% import '_icons.html' as icons %} and call icons.icon('name', size).
|
||||||
|
#}
|
||||||
|
{% macro icon(name, size=18) %}<svg class="icon" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{% if name == 'home' %}<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"></path>{% elif name == 'pencil' %}<path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>{% elif name == 'help' %}<circle cx="12" cy="12" r="9"></circle><path d="M9.1 9a2.9 2.9 0 0 1 5.6 1c0 2-2.9 2.5-2.9 4.5"></path><line x1="12" y1="17.5" x2="12" y2="17.51"></line>{% elif name == 'settings' %}<circle cx="12" cy="12" r="3"></circle><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"></path>{% elif name == 'dice' %}<rect x="3" y="3" width="18" height="18" rx="4"></rect><circle cx="8" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="8" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"></circle>{% elif name == 'folder' %}<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>{% elif name == 'graduation-cap' %}<path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path>{% elif name == 'check' %}<polyline points="20 6 9 17 4 12"></polyline>{% elif name == 'circle' %}<circle cx="12" cy="12" r="9"></circle>{% elif name == 'x' %}<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>{% elif name == 'pin' %}<path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21Z"></path><circle cx="12" cy="9.5" r="2.3"></circle>{% elif name == 'ban' %}<circle cx="12" cy="12" r="9"></circle><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"></line>{% elif name == 'video' %}<rect x="2" y="5" width="14" height="14" rx="2"></rect><path d="M16 10.5 22 7v10l-6-3.5Z"></path>{% elif name == 'music' %}<path d="M9 18V5l11-2v13"></path><circle cx="6.5" cy="18" r="2.5"></circle><circle cx="17.5" cy="16" r="2.5"></circle>{% elif name == 'clipboard' %}<rect x="6" y="4" width="12" height="17" rx="2"></rect><path d="M9 4V3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"></path><line x1="9" y1="11" x2="15" y2="11"></line><line x1="9" y1="15" x2="13" y2="15"></line>{% elif name == 'package' %}<path d="M21 8 12 3 3 8v8l9 5 9-5Z"></path><path d="M3 8l9 5 9-5M12 13v8"></path>{% elif name == 'file-text' %}<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"></path><path d="M14 2v6h6"></path><line x1="9" y1="13" x2="15" y2="13"></line><line x1="9" y1="17" x2="15" y2="17"></line>{% elif name == 'trash' %}<path d="M4 7h16"></path><path d="M10 11v6M14 11v6"></path><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"></path><path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"></path>{% elif name == 'refresh' %}<path d="M21 12a9 9 0 0 1-15.3 6.4"></path><path d="M3 12a9 9 0 0 1 15.3-6.4"></path><polyline points="21 3 21 9 15 9"></polyline><polyline points="3 21 3 15 9 15"></polyline>{% elif name == 'checkbox' %}<path d="M9 11l3 3 7-7"></path><path d="M21 12v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h10"></path>{% elif name == 'list' %}<line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line>{% elif name == 'grid' %}<rect x="3" y="3" width="8" height="8" rx="1.5"></rect><rect x="13" y="3" width="8" height="8" rx="1.5"></rect><rect x="3" y="13" width="8" height="8" rx="1.5"></rect><rect x="13" y="13" width="8" height="8" rx="1.5"></rect>{% elif name == 'bar-chart' %}<line x1="6" y1="20" x2="6" y2="14"></line><line x1="12" y1="20" x2="12" y2="8"></line><line x1="18" y1="20" x2="18" y2="11"></line>{% elif name == 'list-dots' %}<line x1="8" y1="6" x2="20" y2="6"></line><line x1="8" y1="12" x2="20" y2="12"></line><line x1="8" y1="18" x2="20" y2="18"></line><circle cx="4" cy="6" r="1"></circle><circle cx="4" cy="12" r="1"></circle><circle cx="4" cy="18" r="1"></circle>{% elif name == 'history' %}<path d="M3 12a9 9 0 1 0 3-6.7"></path><polyline points="3 4 3 9 8 9"></polyline>{% elif name == 'plus-circle' %}<circle cx="12" cy="12" r="9"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line>{% elif name == 'play-circle' %}<circle cx="12" cy="12" r="9"></circle><polygon points="10 8 16 12 10 16 10 8"></polygon>{% elif name == 'play' %}<polygon points="6 3 20 12 6 21 6 3"></polygon>{% elif name == 'star' %}<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>{% endif %}</svg>{% endmacro %}
|
||||||
+1278
-217
File diff suppressed because it is too large
Load Diff
+120
-38
@@ -1,3 +1,4 @@
|
|||||||
|
{% import '_icons.html' as icons %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -22,6 +23,8 @@
|
|||||||
--font-size-base: 16px;
|
--font-size-base: 16px;
|
||||||
--container-max-width: 1600px;
|
--container-max-width: 1600px;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
|
--success: #28a745;
|
||||||
|
--error: #ff6b6b;
|
||||||
}
|
}
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--bg-primary: #f2f2f2;
|
--bg-primary: #f2f2f2;
|
||||||
@@ -53,6 +56,7 @@
|
|||||||
.page-content {
|
.page-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
padding-bottom: 70px;
|
||||||
}
|
}
|
||||||
.app-header {
|
.app-header {
|
||||||
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
|
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
|
||||||
@@ -67,7 +71,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.app-header .brand-icon { font-size: 1.6em; }
|
.app-header .brand-mark { flex-shrink: 0; display: block; }
|
||||||
.app-header a.brand-link {
|
.app-header a.brand-link {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -75,31 +79,41 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
.app-footer {
|
.bottom-tab-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 9000;
|
||||||
|
display: flex;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
padding: 18px 0;
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
}
|
}
|
||||||
.app-footer .footer-inner {
|
.tab-item {
|
||||||
max-width: 800px;
|
flex: 1;
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 2px;
|
||||||
gap: 15px;
|
padding: 8px 4px;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.footer-brand {
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.9em;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.footer-links a {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.9em;
|
font-size: 0.72em;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.tab-item .tab-icon {
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
.tab-item:hover,
|
||||||
|
.tab-item.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: -3px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.container {
|
.container {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
@@ -125,12 +139,22 @@
|
|||||||
.card li {
|
.card li {
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
/* Visible keyboard-focus ring - see course_dashboard.html for why
|
||||||
|
:focus-visible (keyboard/switch only, not mouse clicks). */
|
||||||
|
a:focus-visible, button:focus-visible, input:focus-visible,
|
||||||
|
select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-header">
|
<div class="app-header">
|
||||||
<div class="header-inner">
|
<div class="header-inner">
|
||||||
<span class="brand-icon">📚</span>
|
<svg class="brand-mark" viewBox="0 0 64 64" width="28" height="28" aria-hidden="true">
|
||||||
|
<rect x="2" y="2" width="60" height="60" rx="16" fill="var(--accent)"></rect>
|
||||||
|
<path d="M24 20 L24 44 L46 32 Z" fill="var(--bg-secondary)"></path>
|
||||||
|
</svg>
|
||||||
<a href="/reset_course" class="brand-link">OfflineU</a>
|
<a href="/reset_course" class="brand-link">OfflineU</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,37 +164,95 @@
|
|||||||
<h1>Help</h1>
|
<h1>Help</h1>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>How to Use OfflineU</h3>
|
<h3>Getting Started</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Prepare your course files</strong> in a directory structure</li>
|
<li><strong>Prepare your course files</strong> in a directory structure</li>
|
||||||
<li><strong>Browse your library</strong> from the main page, or enter a path manually</li>
|
<li><strong>Browse your library</strong> from the main page, or enter a path manually in Settings</li>
|
||||||
<li><strong>Click a course</strong> to load it</li>
|
<li><strong>Click a course</strong> to load it - folders drill down, courses open directly</li>
|
||||||
<li><strong>Start learning!</strong> Your progress will be saved automatically</li>
|
<li><strong>Start learning!</strong> Progress, notes, and completion are all saved automatically</li>
|
||||||
|
<li>Not sure what to watch? The dice icon {{ icons.icon('dice', 14) }} in the top-right of the dashboard header picks a random course you haven't finished</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Watching a Lesson</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Space</strong> - play/pause</li>
|
||||||
|
<li><strong>← / →</strong> - seek back/forward 10 seconds</li>
|
||||||
|
<li><strong>↑ / ↓</strong> - volume up/down</li>
|
||||||
|
<li><strong>, / .</strong> - step playback speed down/up</li>
|
||||||
|
<li><strong>[ / ]</strong> - previous/next lesson</li>
|
||||||
|
<li><strong>F</strong> - toggle fullscreen</li>
|
||||||
|
<li><strong>N</strong> - jump into the quick-capture note box (pauses the video)</li>
|
||||||
|
<li>Drag the video's bottom-right corner to resize it, and pick a default playback speed in Settings - both are remembered</li>
|
||||||
|
<li>Reopening a lesson resumes from where you left off, and the <strong>▶ Resume</strong> button at the top of a loaded course jumps straight back to the last lesson you had open</li>
|
||||||
|
<li>The <strong>Lessons in this section</strong> list under the player jumps between other lessons in the same folder without leaving the page</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Notes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Every note is stamped with the video's timestamp when you save it - click a timestamp to jump straight to that moment</li>
|
||||||
|
<li>Press <strong>N</strong> anywhere on a lesson page to capture one without reaching for the mouse</li>
|
||||||
|
<li>Edit or delete any note from the floating panel at the bottom of the lesson page</li>
|
||||||
|
<li><strong>Notes</strong> (in the bottom bar) collects every note across your whole library in one searchable list</li>
|
||||||
|
<li><strong>Download Study Guide</strong> on a loaded course exports every note as one markdown file, organized by section</li>
|
||||||
|
<li>If you use <a href="/settings">Outline</a>, pick a topic on a lesson's notes to push them there automatically when you navigate away</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Finding Things</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Search courses by name from the Library browser on the main page</li>
|
||||||
|
<li>Check <strong>Also search transcripts</strong> to search inside subtitle files (.srt/.vtt) too, if your courses have them</li>
|
||||||
|
<li>Switch between list and grid (poster) view with the toggle next to the search box - handy for browsing a folder with lots of courses by thumbnail</li>
|
||||||
|
<li><strong>Hide</strong> a course or whole folder from Settings → Manage Library without touching anything on disk</li>
|
||||||
|
<li>The {{ icons.icon('checkbox', 14) }} button in the Library browser turns on <strong>select mode</strong> - pick several courses or folders at once to hide or add to Next Up together</li>
|
||||||
|
<li><strong>Bulk Rename</strong> (Settings) finds and replaces text across every course/folder name at once - useful for stripping a release-group suffix off a batch of downloads</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Settings</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Pick a built-in theme or set a custom accent color, corner style, and card style</li>
|
||||||
|
<li>Adjust font, text size, page width, and spacing density</li>
|
||||||
|
<li>Set the default library folder, and use <strong>Refresh Library</strong> if you've just added or removed files directly on disk and don't want to wait for it to notice</li>
|
||||||
|
<li><strong>Download Backup</strong> exports your settings, hidden-path choices, recently-viewed history, and every course's progress/notes as a zip - not the course files themselves</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Supported File Types</h3>
|
<h3>Supported File Types</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Videos:</strong> .mp4, .mkv, .avi, .mov, .webm</li>
|
<li><strong>Videos:</strong> .mp4, .mkv, .avi, .mov, .webm, .m4v, .flv, .wmv</li>
|
||||||
<li><strong>Audio:</strong> .mp3, .wav, .m4a, .aac</li>
|
<li><strong>Audio:</strong> .mp3, .wav, .m4a, .aac, .ogg, .flac</li>
|
||||||
<li><strong>Documents:</strong> .txt, .md, .html, .pdf</li>
|
<li><strong>Documents:</strong> .txt, .md, .html, .htm, .pdf, .docx, .doc, .rtf</li>
|
||||||
<li><strong>Subtitles:</strong> .srt, .vtt</li>
|
<li><strong>Subtitles:</strong> .srt, .vtt, .ass, .sub, .sbv</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="app-footer">
|
<nav class="bottom-tab-bar">
|
||||||
<div class="footer-inner">
|
<a href="/reset_course" class="tab-item {% if active_tab == 'home' %}active{% endif %}">
|
||||||
<div class="footer-brand">
|
<span class="tab-icon">{{ icons.icon('home', 20) }}</span><span class="tab-label">Home</span>
|
||||||
📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
|
</a>
|
||||||
</div>
|
<a href="/notes" class="tab-item {% if active_tab == 'notes' %}active{% endif %}">
|
||||||
<div class="footer-links">
|
<span class="tab-icon">{{ icons.icon('pencil', 20) }}</span><span class="tab-label">Notes</span>
|
||||||
<a href="/">← Back to app</a>
|
</a>
|
||||||
</div>
|
<a href="/help" class="tab-item {% if active_tab == 'help' %}active{% endif %}">
|
||||||
</div>
|
<span class="tab-icon">{{ icons.icon('help', 20) }}</span><span class="tab-label">Help</span>
|
||||||
</footer>
|
</a>
|
||||||
|
<a href="/unsorted" class="tab-item {% if active_tab == 'files' %}active{% endif %}">
|
||||||
|
<span class="tab-icon">{{ icons.icon('folder', 20) }}</span><span class="tab-label">Files</span>
|
||||||
|
</a>
|
||||||
|
<a href="/settings" class="tab-item {% if active_tab == 'settings' %}active{% endif %}">
|
||||||
|
<span class="tab-icon">{{ icons.icon('settings', 20) }}</span><span class="tab-label">Settings</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<script src="/static/theme.js"></script>
|
<script src="/static/theme.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+1039
-67
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,315 @@
|
|||||||
|
{% import '_icons.html' as icons %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Notes - OfflineU</title>
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<meta name="theme-color" content="#007acc">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #1a1a1a;
|
||||||
|
--bg-secondary: #2d2d2d;
|
||||||
|
--bg-tertiary: #3d3d3d;
|
||||||
|
--bg-tertiary-hover: #404040;
|
||||||
|
--text-primary: #e0e0e0;
|
||||||
|
--text-muted: #999;
|
||||||
|
--border-color: #555;
|
||||||
|
--accent: #007acc;
|
||||||
|
--accent-hover: #005a9e;
|
||||||
|
--font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
--font-size-base: 16px;
|
||||||
|
--container-max-width: 1600px;
|
||||||
|
--radius: 8px;
|
||||||
|
--success: #28a745;
|
||||||
|
--error: #ff6b6b;
|
||||||
|
}
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-primary: #f2f2f2;
|
||||||
|
--bg-secondary: #ffffff;
|
||||||
|
--bg-tertiary: #eeeeee;
|
||||||
|
--bg-tertiary-hover: #e2e2e2;
|
||||||
|
--text-primary: #222222;
|
||||||
|
--text-muted: #666666;
|
||||||
|
--border-color: #cccccc;
|
||||||
|
}
|
||||||
|
[data-card-style="elevated"] .card,
|
||||||
|
[data-card-style="elevated"] .note-card {
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
[data-card-style="bordered"] .card,
|
||||||
|
[data-card-style="bordered"] .note-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.page-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom: 70px;
|
||||||
|
}
|
||||||
|
.app-header {
|
||||||
|
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
|
||||||
|
padding: 18px 0;
|
||||||
|
border-bottom: 3px solid var(--accent);
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.app-header .header-inner {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.app-header .brand-mark { flex-shrink: 0; display: block; }
|
||||||
|
.app-header a.brand-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.bottom-tab-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 9000;
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.72em;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.tab-item .tab-icon {
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
.tab-item:hover,
|
||||||
|
.tab-item.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: -3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 { color: var(--accent); margin-bottom: 5px; }
|
||||||
|
.subtitle { color: var(--text-muted); margin-bottom: 25px; }
|
||||||
|
.empty-hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
.notes-search-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.note-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
}
|
||||||
|
.note-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.note-card-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 1.3em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.note-card-titles {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.note-lesson-title {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.note-course-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.outline-badge {
|
||||||
|
font-size: 0.75em;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.timestamp-badge {
|
||||||
|
font-size: 0.75em;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.note-card-body {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.note-card-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
.note-card-link:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
/* Visible keyboard-focus ring - see course_dashboard.html for why
|
||||||
|
:focus-visible (keyboard/switch only, not mouse clicks). */
|
||||||
|
a:focus-visible, button:focus-visible, input:focus-visible,
|
||||||
|
select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-header">
|
||||||
|
<div class="header-inner">
|
||||||
|
<svg class="brand-mark" viewBox="0 0 64 64" width="28" height="28" aria-hidden="true">
|
||||||
|
<rect x="2" y="2" width="60" height="60" rx="16" fill="var(--accent)"></rect>
|
||||||
|
<path d="M24 20 L24 44 L46 32 Z" fill="var(--bg-secondary)"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/reset_course" class="brand-link">OfflineU</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="container">
|
||||||
|
<h1>{{ icons.icon('pencil', 22) }} Notes</h1>
|
||||||
|
<p class="subtitle">Every lesson note across your library, newest first.</p>
|
||||||
|
|
||||||
|
{% if not notes %}
|
||||||
|
<div class="empty-hint">No notes yet - jot something down on a lesson page and it'll show up here.</div>
|
||||||
|
{% else %}
|
||||||
|
<input type="text" id="notes-search-input" class="notes-search-input"
|
||||||
|
placeholder="Search notes, lessons, or courses…" oninput="filterNotes(this.value)">
|
||||||
|
<div id="notes-no-results-hint" class="empty-hint" style="display: none;">No notes match your search.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for note in notes %}
|
||||||
|
<div class="note-card" data-search="{{ (note.lesson_title ~ ' ' ~ note.course_name ~ ' ' ~ note.text) | lower }}">
|
||||||
|
<div class="note-card-header">
|
||||||
|
<span class="note-card-icon">{{ icons.icon('pencil', 20) }}</span>
|
||||||
|
<div class="note-card-titles">
|
||||||
|
<span class="note-lesson-title">{{ note.lesson_title }}</span>
|
||||||
|
<span class="note-course-name">{{ note.course_name }}</span>
|
||||||
|
</div>
|
||||||
|
{% if note.timestamp_label %}
|
||||||
|
<span class="timestamp-badge">{{ note.timestamp_label }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if note.pushed_to_outline %}
|
||||||
|
<span class="outline-badge">Outline</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="note-card-body">{{ note.text }}</div>
|
||||||
|
<a class="note-card-link"
|
||||||
|
href="/recent/open?course_path={{ note.course_path | urlencode }}&lesson_path={{ note.lesson_path | urlencode }}{% if note.timestamp_seconds is not none %}&t={{ note.timestamp_seconds }}{% endif %}">
|
||||||
|
Open lesson →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="bottom-tab-bar">
|
||||||
|
<a href="/reset_course" class="tab-item {% if active_tab == 'home' %}active{% endif %}">
|
||||||
|
<span class="tab-icon">{{ icons.icon('home', 20) }}</span><span class="tab-label">Home</span>
|
||||||
|
</a>
|
||||||
|
<a href="/notes" class="tab-item {% if active_tab == 'notes' %}active{% endif %}">
|
||||||
|
<span class="tab-icon">{{ icons.icon('pencil', 20) }}</span><span class="tab-label">Notes</span>
|
||||||
|
</a>
|
||||||
|
<a href="/help" class="tab-item {% if active_tab == 'help' %}active{% endif %}">
|
||||||
|
<span class="tab-icon">{{ icons.icon('help', 20) }}</span><span class="tab-label">Help</span>
|
||||||
|
</a>
|
||||||
|
<a href="/unsorted" class="tab-item {% if active_tab == 'files' %}active{% endif %}">
|
||||||
|
<span class="tab-icon">{{ icons.icon('folder', 20) }}</span><span class="tab-label">Files</span>
|
||||||
|
</a>
|
||||||
|
<a href="/settings" class="tab-item {% if active_tab == 'settings' %}active{% endif %}">
|
||||||
|
<span class="tab-icon">{{ icons.icon('settings', 20) }}</span><span class="tab-label">Settings</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function filterNotes(query) {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const cards = document.querySelectorAll('.note-card');
|
||||||
|
let visibleCount = 0;
|
||||||
|
cards.forEach(function(card) {
|
||||||
|
const match = !q || card.dataset.search.includes(q);
|
||||||
|
card.style.display = match ? '' : 'none';
|
||||||
|
if (match) visibleCount++;
|
||||||
|
});
|
||||||
|
const noResultsHint = document.getElementById('notes-no-results-hint');
|
||||||
|
if (noResultsHint) {
|
||||||
|
noResultsHint.style.display = (visibleCount === 0 && cards.length > 0) ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="/static/theme.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+418
-480
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,254 @@
|
|||||||
|
"""
|
||||||
|
One-off tool behind the 34 "website scheme" theme entries in
|
||||||
|
THEME_PALETTES/THEME_DISPLAY_NAMES (offlineu_core.py), sourced from Figma's
|
||||||
|
"53 Unique Website Color Schemes" resource page:
|
||||||
|
https://www.figma.com/resource-library/website-color-schemes/
|
||||||
|
|
||||||
|
That page has no raw hex data - each scheme is a rendered mockup
|
||||||
|
screenshot, not a swatch grid - so website_scheme_swatches.json (checked in
|
||||||
|
alongside this script) holds dominant colors already extracted from those
|
||||||
|
53 images via canvas pixel-histogram sampling in a browser, one entry per
|
||||||
|
scheme: {n, name, colors: [{hex, pct}, ...]}. build_theme() turns that raw
|
||||||
|
material into an actual UI palette per scheme (bg/text/accent, matching
|
||||||
|
THEME_PALETTES' shape), then nudges lightness/saturation as needed so every
|
||||||
|
result clears the same contrast bars this file's checks apply:
|
||||||
|
text-vs-background, accent-vs-background, and accent-vs-white (buttons
|
||||||
|
always use white text - see .btn in course_dashboard.html - so a too-bright
|
||||||
|
accent needs catching even when it reads fine against the background
|
||||||
|
alone), and so accents don't land at the full saturation that reads as
|
||||||
|
neon in the mid-lightness band.
|
||||||
|
|
||||||
|
Running this file directly (see __main__ below) auto-detects each scheme's
|
||||||
|
own mood from its swatches and writes all 53 as candidates - that's NOT
|
||||||
|
what's actually shipped. The source material is mostly light-mode
|
||||||
|
marketing mockups, so the first pass over-represented near-white
|
||||||
|
backgrounds; the shipped 34 all use build_theme(entry, force_mood='dark')
|
||||||
|
instead (every one of the 53 converted to a dark/colored background), then
|
||||||
|
had duplicates pruned by measuring real color distance (hue + lightness +
|
||||||
|
saturation, background and accent both) between every pair, tight enough
|
||||||
|
to only catch genuine near-twins - not something this script's __main__
|
||||||
|
does for you. That curation was one-off analysis, not captured as a single
|
||||||
|
rerunnable command; to redo it, call build_theme(entry, force_mood='dark')
|
||||||
|
per scheme, then de-duplicate the results the same way before splicing
|
||||||
|
into offlineu_core.py by hand.
|
||||||
|
|
||||||
|
To add more schemes: extract their dominant colors the same way (see the
|
||||||
|
canvas-sampling approach used originally; not scripted here) and append to
|
||||||
|
the JSON, then rerun.
|
||||||
|
|
||||||
|
Usage: python3 generate_website_scheme_themes.py
|
||||||
|
Writes generated_themes.json with all 53 candidates, auto-detected mood.
|
||||||
|
"""
|
||||||
|
import colorsys
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def hex_to_rgb(h):
|
||||||
|
h = h.lstrip('#')
|
||||||
|
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
||||||
|
|
||||||
|
|
||||||
|
def rgb_to_hex(rgb):
|
||||||
|
return '#' + ''.join(f'{max(0, min(255, round(c))):02x}' for c in rgb)
|
||||||
|
|
||||||
|
|
||||||
|
def rgb_to_hsl(rgb):
|
||||||
|
r, g, b = [c / 255 for c in rgb]
|
||||||
|
h, l, s = colorsys.rgb_to_hls(r, g, b)
|
||||||
|
return h, s, l
|
||||||
|
|
||||||
|
|
||||||
|
def hsl_to_rgb(h, s, l):
|
||||||
|
r, g, b = colorsys.hls_to_rgb(h, l, s)
|
||||||
|
return tuple(round(c * 255) for c in (r, g, b))
|
||||||
|
|
||||||
|
|
||||||
|
def set_l(h, s, l):
|
||||||
|
return hsl_to_rgb(h, s, l)
|
||||||
|
|
||||||
|
|
||||||
|
def relative_luminance(rgb):
|
||||||
|
def chan(c):
|
||||||
|
c = c / 255
|
||||||
|
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
||||||
|
r, g, b = rgb
|
||||||
|
return 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b)
|
||||||
|
|
||||||
|
|
||||||
|
def contrast_ratio(rgb1, rgb2):
|
||||||
|
l1, l2 = relative_luminance(rgb1), relative_luminance(rgb2)
|
||||||
|
l1, l2 = max(l1, l2), min(l1, l2)
|
||||||
|
return (l1 + 0.05) / (l2 + 0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(x, lo, hi):
|
||||||
|
return max(lo, min(hi, x))
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(name):
|
||||||
|
return name.lower().replace(' ', '_').replace("'", '')
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ACCENT_HSL = rgb_to_hsl(hex_to_rgb('#007acc'))
|
||||||
|
|
||||||
|
|
||||||
|
def tame_accent_saturation(s, l):
|
||||||
|
"""
|
||||||
|
Cap accent saturation to something a UI element can wear all day rather
|
||||||
|
than the fully-saturated brand colors these screenshots were sampled
|
||||||
|
from. Calibrated against the existing hand-picked themes: they only
|
||||||
|
reach full saturation out at pastel lightness (houston/night_owl,
|
||||||
|
l>=0.75) - in the mid-lightness band accents actually render in here
|
||||||
|
(~0.32-0.62, both branches below), full saturation is exactly what
|
||||||
|
reads as neon, so it gets capped harder there.
|
||||||
|
"""
|
||||||
|
if 0.32 <= l <= 0.62:
|
||||||
|
return min(s, 0.68)
|
||||||
|
return min(s, 0.85)
|
||||||
|
|
||||||
|
|
||||||
|
def build_theme(entry, force_mood=None):
|
||||||
|
name = entry['name']
|
||||||
|
swatches = entry['colors']
|
||||||
|
parsed = []
|
||||||
|
for sw in swatches:
|
||||||
|
rgb = hex_to_rgb(sw['hex'])
|
||||||
|
h, s, l = rgb_to_hsl(rgb)
|
||||||
|
parsed.append({'hex': sw['hex'], 'rgb': rgb, 'h': h, 's': s, 'l': l, 'pct': sw['pct']})
|
||||||
|
|
||||||
|
# exclude near-white "page chrome" padding, keep the scheme's real content colors
|
||||||
|
content = [p for p in parsed if p['l'] < 0.90]
|
||||||
|
if not content:
|
||||||
|
content = parsed
|
||||||
|
|
||||||
|
if force_mood == 'dark':
|
||||||
|
is_dark = True
|
||||||
|
elif force_mood == 'light':
|
||||||
|
is_dark = False
|
||||||
|
else:
|
||||||
|
darkest_l = min(p['l'] for p in content)
|
||||||
|
is_dark = darkest_l < 0.28
|
||||||
|
|
||||||
|
accent_candidates = [p for p in content if p['s'] > 0.25]
|
||||||
|
if accent_candidates:
|
||||||
|
accent_src = max(accent_candidates, key=lambda p: (round(p['s'], 2), p['pct']))
|
||||||
|
ah, asat, al = accent_src['h'], accent_src['s'], accent_src['l']
|
||||||
|
else:
|
||||||
|
ah, asat, al = DEFAULT_ACCENT_HSL
|
||||||
|
|
||||||
|
if is_dark:
|
||||||
|
base = min(content, key=lambda p: p['l'])
|
||||||
|
bh, bs = base['h'], min(base['s'], 0.22)
|
||||||
|
bg_l = clamp(base['l'], 0.09, 0.16)
|
||||||
|
bg_primary = set_l(bh, bs, bg_l)
|
||||||
|
bg_secondary = set_l(bh, bs, clamp(bg_l + 0.07, 0.14, 0.24))
|
||||||
|
bg_tertiary = set_l(bh, bs, clamp(bg_l + 0.14, 0.20, 0.32))
|
||||||
|
bg_tertiary_hover = set_l(bh, bs, clamp(bg_l + 0.17, 0.22, 0.35))
|
||||||
|
text_primary = set_l(bh, min(bs * 0.3, 0.08), 0.90)
|
||||||
|
text_muted = set_l(bh, min(bs * 0.3, 0.10), 0.62)
|
||||||
|
border_color = set_l(bh, bs, clamp(bg_l + 0.16, 0.24, 0.34))
|
||||||
|
accent_l = clamp(al, 0.50, 0.68)
|
||||||
|
accent_s = tame_accent_saturation(max(asat, 0.45), accent_l)
|
||||||
|
accent = set_l(ah, accent_s, accent_l)
|
||||||
|
else:
|
||||||
|
light_candidates = [p for p in content if p['l'] >= 0.55]
|
||||||
|
base_h = light_candidates[0]['h'] if light_candidates else ah
|
||||||
|
bg_primary = set_l(base_h, 0.12, 0.95)
|
||||||
|
bg_secondary = (255, 255, 255)
|
||||||
|
bg_tertiary = set_l(base_h, 0.12, 0.93)
|
||||||
|
bg_tertiary_hover = set_l(base_h, 0.14, 0.88)
|
||||||
|
text_primary = set_l(base_h, 0.05, 0.14)
|
||||||
|
text_muted = set_l(base_h, 0.05, 0.42)
|
||||||
|
border_color = set_l(base_h, 0.10, 0.82)
|
||||||
|
accent_l = clamp(al, 0.38, 0.55)
|
||||||
|
accent_s = tame_accent_saturation(max(asat, 0.45), accent_l)
|
||||||
|
accent = set_l(ah, accent_s, accent_l)
|
||||||
|
|
||||||
|
palette = {
|
||||||
|
'bg-primary': rgb_to_hex(bg_primary),
|
||||||
|
'bg-secondary': rgb_to_hex(bg_secondary),
|
||||||
|
'bg-tertiary': rgb_to_hex(bg_tertiary),
|
||||||
|
'bg-tertiary-hover': rgb_to_hex(bg_tertiary_hover),
|
||||||
|
'text-primary': rgb_to_hex(text_primary),
|
||||||
|
'text-muted': rgb_to_hex(text_muted),
|
||||||
|
'border-color': rgb_to_hex(border_color),
|
||||||
|
'accent': rgb_to_hex(accent),
|
||||||
|
}
|
||||||
|
|
||||||
|
bgp_rgb = hex_to_rgb(palette['bg-primary'])
|
||||||
|
|
||||||
|
tp_rgb = hex_to_rgb(palette['text-primary'])
|
||||||
|
tries = 0
|
||||||
|
while contrast_ratio(bgp_rgb, tp_rgb) < 4.5 and tries < 30:
|
||||||
|
h, s, l = rgb_to_hsl(tp_rgb)
|
||||||
|
l = clamp(l + (0.03 if is_dark else -0.03), 0, 1)
|
||||||
|
tp_rgb = hsl_to_rgb(h, s, l)
|
||||||
|
tries += 1
|
||||||
|
palette['text-primary'] = rgb_to_hex(tp_rgb)
|
||||||
|
|
||||||
|
acc_rgb = hex_to_rgb(palette['accent'])
|
||||||
|
tries = 0
|
||||||
|
while contrast_ratio(bgp_rgb, acc_rgb) < 3.0 and tries < 30:
|
||||||
|
h, s, l = rgb_to_hsl(acc_rgb)
|
||||||
|
l = clamp(l + (0.03 if is_dark else -0.03), 0, 1)
|
||||||
|
acc_rgb = hsl_to_rgb(h, s, l)
|
||||||
|
tries += 1
|
||||||
|
# .btn always uses white text on the accent background (see
|
||||||
|
# course_dashboard.html .btn), regardless of theme - calibrated to
|
||||||
|
# 1.8 rather than a stricter WCAG bar because the existing hand-picked
|
||||||
|
# themes (ayu_dark 1.91, nord 2.00, dracula 2.41, ...) already run
|
||||||
|
# fairly loose here; this only catches genuine outliers like a
|
||||||
|
# near-white/neon accent that would be nearly illegible.
|
||||||
|
white_rgb = (255, 255, 255)
|
||||||
|
tries = 0
|
||||||
|
while contrast_ratio(acc_rgb, white_rgb) < 1.8 and tries < 30:
|
||||||
|
h, s, l = rgb_to_hsl(acc_rgb)
|
||||||
|
l = clamp(l - 0.03, 0, 1)
|
||||||
|
acc_rgb = hsl_to_rgb(h, s, l)
|
||||||
|
tries += 1
|
||||||
|
palette['accent'] = rgb_to_hex(acc_rgb)
|
||||||
|
|
||||||
|
# Derived from the *final* accent (after both safety loops above), not
|
||||||
|
# the pre-adjustment value - matching every existing hand-picked theme's
|
||||||
|
# own convention (accent-hover always a bit darker than accent, e.g.
|
||||||
|
# dracula #bd93f9 -> #a672f0, light theme #007acc -> #005a9e). Building
|
||||||
|
# this from the original unadjusted accent_l would let a since-darkened
|
||||||
|
# accent end up *lighter* than its own hover state.
|
||||||
|
h, s, l = rgb_to_hsl(acc_rgb)
|
||||||
|
hover_l = clamp(l - 0.10, 0.10, 0.90)
|
||||||
|
hover_rgb = hsl_to_rgb(h, s, hover_l)
|
||||||
|
palette['accent-hover'] = rgb_to_hex(hover_rgb)
|
||||||
|
|
||||||
|
bgs_rgb = hex_to_rgb(palette['bg-secondary'])
|
||||||
|
tm_rgb = hex_to_rgb(palette['text-muted'])
|
||||||
|
tries = 0
|
||||||
|
while contrast_ratio(bgs_rgb, tm_rgb) < 3.0 and tries < 30:
|
||||||
|
h, s, l = rgb_to_hsl(tm_rgb)
|
||||||
|
l = clamp(l + (0.03 if is_dark else -0.03), 0, 1)
|
||||||
|
tm_rgb = hsl_to_rgb(h, s, l)
|
||||||
|
tries += 1
|
||||||
|
palette['text-muted'] = rgb_to_hex(tm_rgb)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'key': slugify(name),
|
||||||
|
'display_name': name,
|
||||||
|
'mood': 'dark' if is_dark else 'light',
|
||||||
|
'palette': palette,
|
||||||
|
'contrast_text_bg': round(contrast_ratio(bgp_rgb, hex_to_rgb(palette['text-primary'])), 2),
|
||||||
|
'contrast_accent_bg': round(contrast_ratio(bgp_rgb, hex_to_rgb(palette['accent'])), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
data = json.load(open('website_scheme_swatches.json'))
|
||||||
|
results = [build_theme(e) for e in data]
|
||||||
|
json.dump(results, open('generated_themes.json', 'w'), indent=2)
|
||||||
|
dark_count = sum(1 for r in results if r['mood'] == 'dark')
|
||||||
|
light_count = sum(1 for r in results if r['mood'] == 'light')
|
||||||
|
print(f'Generated {len(results)} themes: {dark_count} dark, {light_count} light')
|
||||||
|
low_contrast = [r for r in results if r['contrast_text_bg'] < 4.5 or r['contrast_accent_bg'] < 3.0]
|
||||||
|
print(f'Themes still under contrast targets after adjustment: {len(low_contrast)}')
|
||||||
|
for r in low_contrast:
|
||||||
|
print(' ', r['key'], r['contrast_text_bg'], r['contrast_accent_bg'])
|
||||||
|
for r in results:
|
||||||
|
print(r['key'].ljust(32), r['mood'].ljust(6), r['palette']['bg-primary'], r['palette']['accent'], f"text/bg={r['contrast_text_bg']}", f"accent/bg={r['contrast_accent_bg']}")
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user