Compare commits

...
53 Commits
Author SHA1 Message Date
rmsitzandClaude Sonnet 5 d96a456383 Update README screenshots
Replaces the August 2025 screenshot (predated most of the recent UI
work) with current dashboard and lesson-view shots, built against a
synthetic sample library rather than the real deployment's course
names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 11:28:22 -04:00
rmsitzandClaude Sonnet 5 47e7214bd7 Add progress/recently-added sort to Library browser
The sort dropdown only had Name (A-Z/Z-A) - no way to surface "what
did I just download" or "what am I closest to finishing" without
scrolling. Adds Progress and Recently Added, backed by each course's
completion_percentage and folder mtime (already/newly attached in
list_library_directory and search_library_courses). Folders have
neither, so they naturally sort to the end under these two modes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 10:58:01 -04:00
rmsitzandClaude Sonnet 5 bf0a02d008 Redesign expandable lists to a leaner, flat style
Course tree (lesson-page sidebar + dashboard's loaded-course view) and
File Management's Manage Library browser both move from boxed,
bordered rows to a flat list: no per-row background/border, a small
leading chevron that doubles as the expand affordance instead of a
separate right-edge button, indentation for hierarchy. The chevron's
rotation is pure CSS (:has(+ .tree-content.expanded) / :has(+
.curate-children.expanded)) rather than JS swapping glyph text, which
also let a fair amount of now-redundant JS come out.

The course tree's new styles are scoped under .tree-container (course
dashboard) and .lesson-sidebar (lesson page) so they don't leak into
the Library folder browser and other rows that reuse the same base
.tree-header/.lesson-item/etc. class names elsewhere in
course_dashboard.html - confirmed those are visually untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 10:45:38 -04:00
rmsitzandClaude Sonnet 5 016acacdb5 Add expand all/collapse all to the course tree
An "Expand all" link above the tree (toggling to "Collapse all" once
everything's open) opens or closes every section at once - on both
the lesson page's sidebar and the dashboard's loaded-course view.
Toggles stay right-aligned per section, matching convention rather
than moving to the left. Label reflects current state on load and
persists through the same remembered-sections mechanism as manual
expand/collapse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 10:27:42 -04:00
rmsitzandClaude Sonnet 5 9fb193a07e Header/footer polish, fix dead link, add focus rings
- Dashboard header shortened to match every other page (was notably
  taller than the rest).
- Files moved before Settings in the bottom nav.
- Removed the header's course-name badge - purely redundant with the
  page's own heading directly below it.
- Fixed a dead link: "Progress" pointed to #stats, but no element
  with that id existed anywhere on the page.
- Added a visible :focus-visible ring across all pages - tabbing
  through the site previously gave no indication of what was focused.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 10:14:02 -04:00
rmsitzandClaude Sonnet 5 becbb0b419 Move sidebar left, add collapse toggle and remembered sections
- Course outline sidebar now sits on the left of the player instead
  of the right (mobile stacking order unchanged - video still first).
- Collapsible to a slim rail via a toggle button, remembered per
  device, for a full-width player when wanted.
- Which sections a user has manually expanded is now remembered
  (keyed by the section's path) and shared between the lesson-page
  sidebar and the dashboard's course view, so re-opening a course
  doesn't reset everything back to collapsed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 09:41:52 -04:00
rmsitzandClaude Sonnet 5 0ed531503e Add hover tooltips for truncated sidebar titles
Section and lesson names in the course-outline sidebar/tree get
clipped with an ellipsis when they don't fit - a title attribute now
shows the full text on hover.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 09:17:30 -04:00
rmsitzandClaude Sonnet 5 4f25d4201b Add course outline sidebar to the lesson page
Shows every section and lesson in the course - videos and standalone
documents alike - so jumping to a different section no longer means
backing out to the course page first. Current lesson is highlighted,
its section auto-expands and scrolls into view; sticky on desktop,
stacks below the player on narrow viewports. Replaces the old
"Lessons in this section" list, which only showed the current
section.

The tree-rendering markup (shared with the loaded-course dashboard
view) is now a single macro in templates/_course_tree.html instead of
being duplicated, so both views stay in sync going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 09:13:09 -04:00
rmsitzandClaude Sonnet 5 72c7f67c3d Remove unreliable duplicate-lesson-file matching
Filename-similarity matching within a folder can't distinguish a real
re-download duplicate from a course that splits one topic across
several numbered files ("...Part 1"/"...Part 2", "-1"/"-2"/"-3") -
both look like near-100% matches by name alone, and the latter is a
common, completely normal pattern. This produced hundreds of false
positives in practice. Removes Duplicate Lesson Files entirely
(detection function, its two API routes, and the File Management UI
section); whole-course-folder matching (Duplicate Courses) is
unaffected and stays the only duplicate scan in the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 08:25:37 -04:00
rmsitzandClaude Sonnet 5 8220bb542a Widen thumbnail sampling window past platform bumpers
Many course platforms (Pluralsight, Packt, ...) open with a generic
branded bumper animation running 10-20s before the actual course
title card. The 8s candidate window landed squarely inside that
bumper and grabbed the platform logo instead of anything
course-specific. Candidates now spread across the first ~25s.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:33:37 -04:00
rmsitzandClaude Sonnet 5 de9c05d0d0 Fix race condition in thumbnail candidate generation
Concurrent requests for the same course's thumbnail (e.g. the
Precompute background scan overlapping an ordinary page load) shared
fixed candidate filenames, so one request's cleanup could delete a
temp file the other was still using, crashing with ENOENT. Candidate
filenames are now unique per call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:27:08 -04:00
rmsitzandClaude Sonnet 5 36d9e7f89b Add autoplay, progress rings, duplicate-lesson detection, title-card thumbnails
- Auto-play next lesson on end, with a cancelable countdown and a
  Settings toggle (default on).
- Grid-view course cards show a small progress ring (checkmark at
  100%) instead of a separate bar.
- Duplicate Lesson Files: scans within each course/folder for media
  files that look like the same lesson downloaded twice, with per-file
  delete and a shared ignore list with Duplicate Courses.
- Auto-generated cover art now samples a few early candidate frames
  and keeps the largest JPEG, favoring an intro title card over a
  blank fade-in or a plain presenter frame.
- Settings -> "Regenerate Thumbnails" re-runs that logic for every
  course with an auto-generated thumbnail (never touches manual
  covers), so already-cached thumbnails can pick up the improvement.
- Add .gitignore for __pycache__/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:20:36 -04:00
rmsitzandClaude Sonnet 5 fc83876abe Add Favorites, storage drill-down, keyboard shortcuts, and What's New
- Favorites: star toggle on course rows, the course page, and a new
  dashboard card; rebased on rename/move, backed up/restored, and
  caught by stale-reference cleanup (also fixed a pre-existing gap
  where favorites.json wasn't in the backup file list).
- Storage Usage: click a folder row to drill into its contents one
  level at a time, with a Back button.
- Lesson player: added ,/. (speed step), [/] (prev/next lesson), and
  F (fullscreen) keyboard shortcuts.
- Settings: a CHANGELOG.md-backed "What's New" list, since VERSION
  alone only ever shows the current build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 19:23:40 -04:00
rmsitzandClaude Sonnet 5 1fbf0c9312 Fix video resize handle drifting off-screen
The resize observer was persisting any rendered size change, including
incidental reflow from the page's own responsive max-width clamping on
window/viewport resize - not just deliberate corner-handle drags. This
let the stored size drift into a badly non-16:9 box over repeated views
on different window sizes, pushing the actual resize grip far below the
fold. Now only persists while the user has mousedown on the video, and
caps rendered height at 80vh as a backstop against already-drifted values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 19:00:40 -04:00
rmsitzandClaude Sonnet 5 ecabc828ea Cache the category tree walk (fixes ~1s File Management page load)
_build_category_index() was the one tree-scanning function never
wrapped in the app's existing 5-minute cache - it ran fresh on every
File Management page load and Sort Unsorted scan (not a manual "this
may take a moment" action like Duplicate Courses or Storage Usage), so
the cost was invisible until it was already slow. Measured ~1.0-1.1s
consistently against the live NAS-mounted library at 161 courses,
confirmed via profiling that a single call makes dozens of iterdir()
round-trips - each one a network hop over SMB. Now shares the same
cache_get_or_compute pattern and invalidate_cache() call sites as
get_all_course_dirs(), so no new invalidation logic was needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 18:06:57 -04:00
rmsitzandClaude Sonnet 5 ba8d7e2a1b Add search, bulk actions, undo, and storage usage to File Management
- Manage Library search: find any course/folder by name anywhere in
  the tree instead of expanding levels one by one; results carry the
  same Move/Rename/Hide actions.
- Bulk select: every row gets a checkbox, so items found via search or
  expanded across different tree levels can be hidden, shown, or moved
  to the same destination together in one batch.
- Undo last action: a "Last action: ... [Undo]" bar appears after any
  move/rename (Sort Unsorted apply, Bulk Rename apply, a Manage
  Library move/rename, a bulk move) and reverses the whole batch.
  Deliberately never covers Hide/Show (already a one-click toggle) or
  Delete (permanent by design) - only ever move/rename, which are
  trivially reversible. Re-checks each item before reversing it, so a
  partial failure reports exactly what did and didn't reverse.
- Storage Usage: disk usage per top-level library folder, largest
  first, with a simple proportional bar; a manual scan since it reads
  every file's size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 14:41:26 -04:00
rmsitzandClaude Sonnet 5 71d6158c60 Fix build version showing "unknown" in production
The previous approach baked git rev-parse --short HEAD into the image
at Docker build time, but Dockhand's build context doesn't reliably
have .git available, so it silently fell back to "unknown" in
production even though the build itself succeeded. Replace it with a
plain VERSION file committed to the repo (a timestamp + short
description, updated by hand alongside each commit) that ships via the
same COPY . . as everything else - no git access needed inside the
build at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 14:00:42 -04:00
rmsitzandClaude Sonnet 5 8d1ce72372 Fix wrapped-course false positive in picker; add build version display
Sort Unsorted's destination picker still listed a course whose real
video content sat two levels deep inside its own "display name"
folder (a common release-bundle extraction shape), alongside empty
leftover folders from the extraction that neither of the two earlier
exclusion checks could safely rule out - an empty folder is
indistinguishable from a legitimate freshly-created category. Detect
this case by comparing token similarity between a folder's own name
and its single course child's name: high overlap means they're naming
the same thing (a redundant wrapper), not a deliberate category
holding one course.

Also bake the build's git commit + build time into the Docker image
(git rev-parse at build time, .git deleted again immediately after) so
a push's effect on the running container is actually visible - shown
on the Settings page and in /health - instead of having to guess
whether a webhook rebuild picked up the latest commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 13:52:32 -04:00
rmsitzandClaude Sonnet 5 7fbe63e05c Exclude media-free subtrees from the destination picker too
The previous leaf-item fix only caught a single stray file with no
subfolders - it missed a course whose bundled extras (downloaded
source code, a Python virtualenv/dependency dump, project assets)
have plenty of subfolders but no video/audio anywhere in them, which
still left the course itself, and every one of those subfolders,
selectable as destinations. Add a subtree-wide media check (bounded
depth) so a folder with subfolders but no media anywhere beneath it is
excluded the same way, while a folder that still has a real course's
media somewhere inside it - or is simply empty, e.g. a freshly-created
category - stays pickable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 13:21:39 -04:00
rmsitzandClaude Sonnet 5 62bb5e1318 Exclude course/item leaves from destination picker; ease new-folder UX
The destination picker (Sort Unsorted, Manage Library Move) was
listing individual course/item folders as pickable destinations
whenever they held content course detection doesn't recognize as
video/audio - ebooks, audiobooks in unsupported formats, etc. Add a
leaf-item check scoped to the picker's own category index so these no
longer show up, without touching the shared course-detection heuristic
used elsewhere (Library browsing, search, stats).

Also replace "Create new folder"'s single free-text path field with a
parent-folder picker plus a plain new-folder-name field and a live
"Will create: X/Y" preview, so the resulting path is confirmed before
applying instead of hand-typed blind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 13:13:09 -04:00
rmsitzandClaude Sonnet 5 bcd1734c94 Add wildcard and regex modes to Bulk Rename
Plain-text substring matching couldn't handle a batch of
slightly-different patterns (e.g. several release-group suffixes) in
one pass. Add a match-type selector: wildcard (shell-style */?) for
the common case, and full regex (with backreferences in the
replacement) for anything more precise. Invalid regex is caught and
reported inline instead of failing the request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:45:52 -04:00
rmsitzandClaude Sonnet 5 49b68b8e51 Add delete and ignore actions to Duplicate Courses
Delete permanently removes a course from disk (confirmed with the full
path first) and cleans up any Hidden/Next Up/Recently Viewed
references to it. Ignore marks a specific pair as confirmed-not-
duplicates so it stops resurfacing in scans, without suppressing
either course's other matches; ignored pairs are listed and reversible
under "Ignored matches," and now travel with backup/restore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:11:41 -04:00
rmsitzandClaude Sonnet 5 9f0f49a399 Move File Management link into the bottom tab bar
Replace the dashboard-only footer link with a "Files" item in the
persistent bottom tab bar (Home/Notes/Help/Settings) on every page, so
it's reachable the same way everywhere instead of only from the
dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 10:37:56 -04:00
rmsitzandClaude Sonnet 5 9ff8c70311 Tighten Sort Unsorted matching; consolidate file management tools
Fix Sort Unsorted picking a wrong destination on words that are common
clutter across many categories (a creator's name, generic marketing
filler) by ranking matches on a folder's own deliberate name over
incidental sibling-course words, and downweighting borrowed words that
recur across many categories. Add a folder-picker/create-new/rename UI
to override any proposal.

Turn /unsorted into a general File Management page: move Refresh
Library, Bulk Rename, and Manage Library (hide/rename) here from
Settings, add a Move action to Manage Library using the same picker,
and add Duplicate Courses and Clean Up Stale References tools. Add a
footer link to the page from the dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 10:29:55 -04:00
rmsitzandClaude Sonnet 5 4eda493e00 Add Unsorted folder auto-sort with keyword matching
Scans an Unsorted folder at the library root and proposes destinations
for new courses by matching keywords against the existing category
tree, suggesting new subfolders when nothing matches closely, and
flagging items for manual review when nothing matches at all. Review
and apply happen on a dedicated /unsorted page linked from Settings;
nothing moves until the user confirms.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 09:10:19 -04:00
rmsitzandClaude Sonnet 5 52371dd02f Convert all Figma theme backgrounds to dark/colored, dedupe again
The 34 kept themes were split 24 light-mode / 18 dark-mode - the light
ones all shared the same washed-out near-white background (L=0.95),
too bright per feedback. Added a force_mood override to build_theme()
and regenerated all 24 as dark instead, same accent character, colored-
dark background rather than near-white.

That conversion collapsed a lot of previously-distinct light backgrounds
into similar dark neutrals, so re-ran the same color-distance dedup pass
across the full set: 8 more turned out to be near-twins once everything
converged to dark (including a 3-way orange-on-dark cluster trimmed to
one). Net: 53 candidates -> 34 kept, all dark/colored, zero white
backgrounds - down from the 42 (24 light/18 dark) shipped last commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 08:33:34 -04:00
rmsitzandClaude Sonnet 5 722b381913 Add 42 curated themes from Figma's website color schemes
Sourced from Figma's "53 Unique Website Color Schemes" resource page,
which only shows rendered mockup screenshots (no raw hex data) - extracted
dominant colors via canvas pixel sampling, then a small pipeline
(tools/generate_website_scheme_themes.py) maps each scheme's real colors
into this app's palette shape, with contrast enforced against both the
background and the white text every .btn uses.

Cut 11 of the original 53 after review: 2 for a neon-bright accent (the
saturation cap only bounded lightness at first, so ~28 of 53 accents
landed at pure 100% saturation - fixed at the pipeline level, calibrated
against what the existing hand-picked themes actually use), and 9 for
being near-duplicates of another kept scheme once mapped into actual UI
colors (measured by real color distance, not eyeballed) - including one
pixel-identical pair and a 4-way cluster trimmed to its two most distinct
members.

Settings dropdown is grouped into <optgroup>s (Base, Editor Themes, and
5 categories matching the source page's own curation) rather than one
flat 54-option list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 08:08:56 -04:00
rmsitzandClaude Sonnet 5 acabb89981 Add auto-thumbnails, library-wide remaining time, backup restore
Auto-generated course thumbnails: find_course_thumbnail() falls back to
grabbing a frame from a course's first video via ffmpeg when there's no
manual cover image, cached to .offlineu_thumbnail.jpg so it only ever
runs once. Folded into the "Precompute" prewarm button, now "Precompute
Lengths & Cover Art".

Library-wide "Remaining" stat: sums every course's already-cached
duration (no ffprobe, just a JSON read) into the dashboard's Library
Stats card, alongside the existing "Watched" figure.

Backup restore: new /api/backup/restore endpoint and a "Choose Backup
File..." control in Settings, with zip-slip and missing-course guards.
Also fixed a gap in the export itself - next_up.json and
outline_config.json weren't being backed up before, so restore wouldn't
have been a true round trip.

Move Surprise Me from a full-width button above the course list to a
dice-icon button in the dashboard header, swapping with the course-name
badge depending on whether a course is loaded.

Update README to cover all of the above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:50:48 -04:00
rmsitzandClaude Sonnet 5 01a965c0f1 Rewrite README as internal deployment/reference documentation
Replace the original upstream template README (public-project framing,
roadmap items already built, links to a different GitHub repo) with
documentation matching the actual private deployment: real
docker-compose.yml, the Gitea->Dockhand rebuild-on-push behavior, env
vars/CLI flags, data file locations, and the full current feature set
(theming, notes hub, video duration/ffprobe caching, backup export,
Outline integration) that had never been documented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:30:00 -04:00
rmsitzandClaude Sonnet 5 24a07dd3e6 Add duration prewarm button; show video lengths in the loaded-course view
Settings gets a "Precompute Video Lengths" button that walks the whole
library in a background thread, populating every course's persistent
ffprobe duration cache up front instead of paying that cost the first
time each course card is viewed. Progress polls live while it runs and
picks back up correctly if you navigate away mid-scan.

Separately, the loaded-course lesson tree only ever showed a lesson's
duration once you'd actually played it (from progress.json) - a freshly
opened course had no time info anywhere, including the course header's
"~X remaining" line, which existed but was always empty as a result.
apply_progress_to_tree now falls back to the same ffprobe cache for any
lesson without a known duration yet, so per-lesson lengths and the
remaining-time estimate work from the very first visit. Refactored the
duration-cache read/write into a shared helper so the library browser,
the prewarm button, and this tree view all go through one path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:20:10 -04:00
rmsitzandClaude Sonnet 5 8f7808ab46 Show course runtime on library cards via ffprobe
Add ffmpeg to the Docker image so ffprobe can read each video/audio
file's duration server-side. Results are cached to a small persistent
per-course JSON file keyed by (relative path, size, mtime), so a file
only gets probed once - a container restart or the library scan's
in-memory cache expiring never re-runs ffprobe on unchanged files.

Course cards in the library browser (grid, list, and search) now show
total runtime alongside the media-file count, so you know the time
commitment before opening a course. Durations under a minute are
suppressed rather than showing a noisy "0m".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:03:04 -04:00
rmsitzandClaude Sonnet 5 eae6b09ecd Replace all OS-rendered emoji with a consistent SVG icon set
Add a shared Jinja icon macro (templates/_icons.html) with 27 Feather-style
stroke SVGs and import it across every live template - tab bars, buttons,
lesson-type/status icons, folder/course icons, and the lesson player's
footer brand mark all now use currentColor SVGs instead of emoji, matching
the app's vector logo instead of rendering inconsistently per platform.
A few icons built dynamically in JS (thumbnail-load fallbacks, toggled
button states) get a small ICON_SVGS constant per file, since JS template
literals can't call the Jinja macro.

Left pure directional glyphs (expand/collapse chevrons, move up/down
arrows, keyboard-shortcut arrows) as plain Unicode - they already render
as monochrome text, matching the existing collapsible-header chevron
pattern rather than the colorful pictographic emoji this was aimed at.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 21:49:10 -04:00
rmsitzandClaude Sonnet 5 e2db5ecef8 Add per-course progress badges to library cards, replace alert() with toasts
Compute a cheap, uncached completion percentage per course (completed
lessons / media files) and surface it as a badge + thin progress bar on
library cards in both list and grid view, and in search results - untouched
courses stay unbadged so the list doesn't get noisy with "0%".

Replace the native alert() dialogs used for bulk-select validation and
course-load errors with a small slide-in toast component that matches the
rest of the UI instead of a jarring browser popup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 21:22:00 -04:00
rmsitzandClaude Sonnet 5 3039d1206d Polish dashboard: consistent icon set, flatter cards, merged activity card
Replace card-header emoji with a matching stroke-style SVG icon set that
inherits theme color via currentColor. Drop the accent-stripe border from
every card and keep it only on the Your Courses card, the one primary
action. Merge Continue Watching and Recently Viewed into a single
true-recency list so an old completed item can no longer outrank a newer
in-progress one across two separately-capped lists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 21:11:36 -04:00
rmsitzandClaude Sonnet 5 daec0742ec Replace the header brand emoji with a play-badge logo
Swap the 📚 emoji in the header for an inline SVG mark - a rounded
square with a play triangle, echoing that this is a video-course
library. Uses var(--accent)/var(--bg-secondary) rather than fixed
colors so it re-colors automatically with whichever of the app's
theme presets is active, same as everything else in the header.

Applied consistently across all 5 templates; the small secondary
footer brand mark (still plain 📚 text) is untouched - this only
replaces the prominent header lockup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:55:36 -04:00
rmsitzandClaude Sonnet 5 21af7ae349 Add colored grid-card fallbacks, bottom tab bar, dashboard reorder
Three visual/structural changes:

- Grid cards without a thumbnail (nearly all real courses in this
  library) showed the same generic graduation-cap icon, making the
  poster grid hard to visually scan. Replace it with a deterministic
  color+initial per course name (same idea as Slack/Google Photos
  default avatars), so cards without real cover art still look
  distinct from each other.

- Notes/Help/Settings only existed as small footer text links,
  meaning a scroll to the bottom of every page to navigate. Add a
  persistent bottom tab bar (Home/Notes/Help/Settings, current page
  highlighted via a new active_tab context var) to the dashboard,
  Notes Hub, Settings, and Help pages. Deliberately left the lesson
  page alone - it already has its own fixed-bottom notes panel, and a
  second fixed bar there would just eat into an already content-dense
  page.

- The no-course dashboard stacked five informational/queue cards
  (Library Stats, Next Up, Pick Back Up, Recently Added, Continue
  Watching, Recently Viewed) before "Your Courses," so the actual
  library browser was the last thing on the page. Move it up to right
  after the Surprise Me banner - it was already unconditional, so this
  only changes where it renders, not when.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 15:30:44 -04:00
rmsitzandClaude Sonnet 5 219c09692f Show full course name on hover in grid view
Grid card names are clamped to 2 lines and clipped, so a long
dot-separated course name (the common case for this library) had no
way to be read in full without switching to list view. Add a title
attribute so hovering reveals the complete name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 15:11:06 -04:00
rmsitzandClaude Sonnet 5 9e7dc85558 Add Resume button, Surprise Me random pick, and bulk select
Three quality-of-life additions to the Library browser and course
pages:

- A course page shows a "Resume: <lesson>" button when there's a
  last-visited lesson, using Course.last_accessed_path (already
  populated by apply_progress_to_tree, just not surfaced before) -
  no more scrolling the tree to find where you left off.

- "Surprise Me" picks a random not-yet-fully-watched course and loads
  it, for when there's too much library to decide what to watch.
  Reuses the already-cached get_all_course_dirs() and the same
  per-course completed/total shape _scan_library_activity() already
  computes for Stale Courses - no new scanning.

- A select-mode toggle in the Library browser adds checkboxes to every
  row/card (list and grid) with a bulk-action bar to hide or queue
  several courses/folders at once, via two new endpoints
  (/api/hidden-paths/bulk, /api/next-up/bulk) that reuse the existing
  single-item set_path_hidden()/set_path_queued() in a loop. Selection
  is scoped to the current folder view and clears on navigation.

Also fixes runTranscriptSearch() going silently blank on zero results
instead of showing a "no matches" message, and updates the Help page
to cover all of this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 15:06:29 -04:00
rmsitzandClaude Sonnet 5 8f6b00280f Fix course title overflowing the page on mobile
The course dashboard's <h2>{{ course.name }}</h2> heading had no wrap
protection, and course.name is the raw folder name - unlike lesson
titles (cleaned up via _clean_lesson_name), it's never given real
spaces. Most real course folders are dot-separated scene-release-style
names with no natural break point at all, so the browser had nowhere
to wrap and widened the whole page instead - confirmed live against
the deployed site (page rendered at 1066px on a 375px mobile
viewport). Every other place that shows a long course/lesson name
already truncates correctly via flex + overflow:hidden; this heading
was the one gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:40:48 -04:00
rmsitzandClaude Sonnet 5 2941910c54 Cache library filesystem scans to fix ~90s NAS page loads
There was no caching anywhere in the app, so every request re-walked
the SMB-mounted library from scratch - the dashboard alone triggered
2 duplicate full-library directory walks, 100+ individual
.offlineu_progress.json opens, and ~10-15 full per-course recursive
rglob() scans, none of it shared between requests. Measured ~90s per
dashboard load against the real mounted NAS, with a second immediate
reload taking just as long - proof nothing was being reused.

Add a minimal in-process TTL cache (5 min) and apply it at the actual
hot spots: the shared course-directory listing (get_all_course_dirs,
replacing 6 independent iter_all_courses() walks), the per-course
media-count/thumbnail summary (_course_summary, also now reused by
list_library_directory instead of a third duplicate implementation),
the per-course tree scan (get_course_tree - safe to cache since
progress is always re-applied fresh on top, never baked into the
cached structure), and the transcript-search subtitle index (the
worst offender - previously re-read and lowercased every subtitle
file in the library on every search).

Invalidates immediately on hide/show, rename, and library-path
changes; a "Refresh Library" button on Settings covers files added
directly on the NAS outside the app.

Measured after the fix, same real NAS mount: dashboard ~90s -> 1.3s
warm, Notes Hub ~1.15s warm, Library browser ~3.6s warm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:27:24 -04:00
rmsitzandClaude Sonnet 5 bc6725ee4c Add section lesson list, section progress, and library grid view
Three comparison-driven upgrades against typical course/media platforms:

- Lesson page gets a collapsible "Lessons in this section" list so you
  can jump between siblings without leaving the page, instead of only
  global Prev/Next buttons. find_lesson_in_tree() now also returns the
  DirectoryNode that owns the lesson (its .lessons list is the sibling
  set) since DirectoryNode has no parent pointer; a new
  get_section_lessons() resolves each sibling's progress the same way
  view_lesson() already does for the current lesson.

- Course tree section headers show "X/Y watched" instead of a flat item
  count, via DynamicCourseParser._calculate_completion_stats (already
  computed recursive completion for any node, just never called
  per-section) exposed as a Jinja global.

- Library browser gets a list/grid toggle for browsing large category
  folders by poster-style thumbnail instead of a 32px-icon list.
  Client-side only, persisted via localStorage, defaults to the
  existing list view; both folder browsing and search results already
  funnel through the same render function so grid mode covers both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:04:14 -04:00
rmsitzandClaude Sonnet 5 3fedf849d3 Stop resetting lesson progress on view, and add Notes Hub search
view_lesson() called update_lesson_progress() with no arguments on
every page load, which defaults completed=False and progress_seconds=0
and silently overwrote whatever was already saved - just opening an
already-watched lesson reset its progress/completed state unless the
client happened to report real values within the next 15 seconds.
Replaced with touch_lesson_accessed(), which only bumps last_accessed.

Also fixes the "Resume from Xs" feature this fed into: it read a value
that was never populated on this route, and even when given a real
one, checked activeMedia.duration synchronously before metadata had
loaded, so it silently never applied. Now waits for loadedmetadata (or
resolves immediately if already available) and defers to an explicit
note-timestamp jump (?t=) when both are present.

Notes Hub gets a live search box filtering by note text, lesson title,
or course name (client-side, no reload), with a "no notes match" state
- useful now that a single category folder can hold dozens of courses'
worth of timestamped notes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:00:35 -04:00
rmsitzandClaude Sonnet 5 393f78206a Make browser back exit a loaded course instead of the whole app
Every course-loading path (Library browser, Continue Watching, Next
Up, ...) goes through loadCoursePath(), which reloads the page after
POSTing to /load_course - a plain reload never touches history, so
once you were on the course view, back had nothing of ours to land on
and fell straight through to exiting the app.

Push a history entry before that reload, and teach the back handler
that popping back to the course view means "unload the course and
reload" (there's no client-side way to patch back to the library
view's markup - it's a distinct server render tied to current_course).
Also restore the exact library folder trail on that reload instead of
resetting to the root, so back genuinely returns to where you were
browsing, not just the top level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:06:41 -04:00
rmsitzandClaude Sonnet 5 255361ca6e Make browser back button step up one Library folder instead of exiting
Drilling into a Library folder only ever swapped #library-groups's
contents via fetch, never touching browser history - so back had
nothing of the app's own to step through once you were a few folders
deep, and fell straight through to exiting the app instead of going up
a level.

Push a history entry per drill-down and breadcrumb jump, and restore
libraryTrail/re-fetch that level on popstate, so back now un-drills
one folder at a time and only reaches the app's actual exit once
you're back at the library root.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:55:44 -04:00
rmsitzandClaude Sonnet 5 68639a2b5b Fix chapter-name regex missing underscore-joined folder names
_SECTION_NAME_RE used \b after keywords like "chapter", but \b doesn't
fire between "Chapter" and an immediately-following "_" since
underscore counts as a word character too - so a course whose chapter
folders were named "Chapter_1-Introduction" (no space) failed to match
and got misclassified as a plain folder instead of a course.

Swap \b for a (?![a-z]) negative lookahead, which correctly rejects
only a following letter (e.g. "Chapterhouse") while accepting a digit,
underscore, hyphen, space, or end of string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:41:11 -04:00
rmsitzandClaude Sonnet 5 21a468fb7f Fix course-detection heuristic swallowing category folders as one course
_looks_like_course() treated any folder with 2+ media-holding subfolders
as a single multi-section course, with no check that those subfolders
actually looked like sections. A category folder holding many unrelated
courses (e.g. Claude/, General/) could accidentally satisfy that if
several of its courses happened to keep episodes directly at their own
top level rather than nested under a chapter folder, collapsing the
whole category into one bogus course.

Now every media-holding subfolder must read as a chapter/section label
(Section 1, Module 2, or a numbered chapter like 01-Introduction) for
the parent to count as one course - verified against the real library
mount that ChatGPT/Claude/Copilot/General all correctly show as
folders again, while individually-structured courses still detect
correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:33:02 -04:00
rmsitzandClaude Sonnet 5 8b365c4463 Add timestamped notes: multi-note support, quick-capture hotkey, live saved indicator
Replace the single free-text note per lesson with a list of timestamped
note entries, migrated transparently from the old format. Adds a
floating notes panel on the lesson page with an always-reachable
quick-capture bar (press N to pause and jump in), inline edit/delete,
click-to-seek timestamps, and a live "Saved Xs ago" indicator. Notes
Hub, the study guide export, and the Outline push all updated to
render multiple timestamped notes per lesson.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:17:20 -04:00
rmsitzandClaude Sonnet 5 c871d6efae Visual polish: theme-aware colors, collapsible cards, skeletons, hover consistency
Templates only, no backend changes:

- Fixed the real bug: JS-generated status/loading text and a few component
  styles used hardcoded hex (#007acc, #999/#666, #28a745, #ff6b6b) instead
  of the CSS variables the theme system already provides, so they didn't
  track the user's chosen theme/accent - most visible on Light or any named
  theme other than the default. Added --success/--error to all 5 templates'
  :root blocks and replaced every non-:root occurrence. Left the
  .lesson-type badge colors and generic UI chrome grays alone (intentional,
  not theme-dependent). Verified live by switching to Nord and Light themes.
- Dashboard cards (Library Stats, Next Up, Pick Back Up, Recently Added,
  Continue Watching, Recently Viewed) are now collapsible, state remembered
  per device via localStorage - the no-course dashboard had grown to 7
  stacked cards.
- Course page's three action buttons get consistent compact sizing and
  icons instead of a plain stack.
- Library browser and transcript search show shimmering skeleton rows
  instead of bare "Loading..."/"Searching..." text while a fetch is in
  flight.
- Hover treatment made consistent: transcript results get the same lift
  .lesson-item already had, heatmap cells get a GitHub-style hover
  scale-up, and Notes Hub links get a background-chip hover.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:31:31 -04:00
rmsitzandClaude Sonnet 5 0454834e62 Add Notes Hub, transcript search, stale nudges, study guide export, Next Up queue, activity heatmap
Six more dashboard features, all built on the per-course progress-file
scanning infrastructure from earlier sessions:

- Notes Hub (/notes): every lesson note across the library in one place,
  newest first, reusing the existing /recent/open cross-course jump route.
- Transcript search: opt-in checkbox on the Library search box, searching
  inside .srt/.vtt files. Kept cheap by doing a raw substring match as the
  filter step and only extracting a snippet for files that actually match.
- Stale course nudges ("Pick Back Up"): courses with progress that haven't
  been touched in 14+ days.
- Study guide export: compiles a course's notes into one downloadable
  markdown file, section/lesson structure preserved.
- Next Up queue: an ordered, persisted "what to tackle next" list with
  up/down reordering and a queue button on every course row.
- Activity heatmap: a 90-day contribution-style calendar in the Library
  Stats card.

_scan_library_activity() now does one walk over every course's progress
file per dashboard load; library stats, stale courses, and the heatmap all
derive from that single scan instead of three independent ones.

Also refactored the duplicated lesson-progress-key lookup (used in three
places now) into _resolve_lesson_progress_key(), and flagged a pre-existing
bug found along the way (not fixed here, kept out of scope): subtitle files
never actually attach to a Lesson object, so the video player's caption
track has never had anything to render.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:04:01 -04:00
rmsitzandClaude Sonnet 5 145f91a647 Fix note-field space bar hijacking video, rework Outline topics as documents
- Lesson page's global keyboard shortcuts (space/arrows for play-pause/
  seek/volume) fired regardless of focus, so typing a space in the notes
  textarea toggled the video instead of typing. Now skipped entirely
  whenever a text field/select has focus.
- Outline topics no longer spawn their own top-level collection each time.
  A topic is now a document inside one fixed, user-configured collection
  (Settings -> Outline Integration -> Default collection), and a lesson's
  note becomes a child document nested under its topic - matching how
  notes actually get organized in a real Outline instance instead of
  cluttering the collections list with one per topic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 12:27:00 -04:00
rmsitzandClaude Sonnet 5 5fe3fafc7a Add Outline integration: push lesson notes to a topic collection
Pick a topic on a lesson's note and it pushes to that topic's collection
in a self-hosted Outline instance when you leave the page; notes without a
topic stay local-only. Settings gets a new Outline Integration card
(URL/token, Save, Test Connection).

Credential handling: the API token lives in its own outline_config.json
in DATA_DIR, deliberately kept out of the general settings flow (GET
/api/settings is fetched on every page load by theme.js - no place for a
secret to ride along). GET /api/outline/config only ever returns whether
it's configured, never the token itself.

The topic chooser reflects Outline's live collection list rather than a
locally cached copy, and resolves a newly-typed topic name to a real
collection immediately (find-by-name-or-create) rather than waiting until
the note is pushed. That's not just an optimization: the push itself 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 back/forward-
cache restore, for instance) would otherwise re-send the same "create a
new topic" intent every time and spawn duplicate collections. Verified
live against a local mock Outline server that firing pagehide repeatedly
for the same lesson creates the collection/document once and updates
thereafter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 12:12:50 -04:00
rmsitzandClaude Sonnet 5 a6c49d3762 Add Recently Added, bulk rename, time remaining, library stats, backup export
Five more usability features on top of the search/thumbnails batch:

- Recently Added: dashboard card of courses by folder mtime, separate from
  Recently Viewed (watched vs. just showed up on disk).
- Bulk find/replace rename across every course/folder name at once, with a
  mandatory preview step before anything touches disk. Refactored the
  single-item rename route to share the same validate/apply logic.
- Estimated time remaining on the loaded course's stats card, computed only
  from lessons that have actually reported a duration.
- Library-wide stats overview: total courses, lessons tracked, time
  watched, daily streak - read from each course's small progress file
  rather than re-scanning course contents.
- One-click backup/export zip of settings, hidden-paths, recently-viewed,
  and every course's progress/notes.

Also fixes a real performance bug found along the way: search was routing
through list_library_directory, which computes a full recursive file count
and thumbnail lookup for every course at every level regardless of match -
turning a search into an O(every file in the library) scan. Gave search its
own lightweight directory-only walk (iter_all_courses), now shared by
Recently Added and the stats overview too, so the expensive per-course work
only runs for courses that actually match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 11:36:05 -04:00
rmsitzandClaude Sonnet 5 dbf9528fea Speed up library search on large libraries
search_library_courses was walking via list_library_directory, which
computes a full recursive file count (rglob) and thumbnail lookup for
every course at every level - turning a search into an O(every file in
every course) scan regardless of how few results actually match. Give
search its own lightweight directory-only walk and defer the expensive
per-course lookups until after a name match is confirmed, so cost now
scales with matches found rather than total library size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 09:22:54 -04:00
20 changed files with 9746 additions and 913 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+25
View File
@@ -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
+5
View File
@@ -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 .
+198
View File
@@ -163,10 +163,208 @@ Asked for after brainstorming what could make the app nicer to use — see
avoid re-validating/re-scanning an untrusted path). Button lives in the avoid re-validating/re-scanning an untrusted path). Button lives in the
course stats card, behind a confirm prompt. course stats card, behind a confirm prompt.
## Search perf fix + five more features (this session)
Also caught: search on the real deployed library was taking 3+ seconds
(scanning ~6000+ files across the real course tree). Root cause:
`search_library_courses` walked via `list_library_directory`, which computes
a full recursive file count (`rglob`) and thumbnail lookup for *every*
course at *every* level, regardless of whether it matched. Rewrote it with
its own directory-only walk (`iter_all_courses` - now shared by search,
Recently Added, and library stats below) so the expensive per-course work
only runs for courses that actually match. Verified with a synthetic
6000-file library: effectively instant for narrow queries, same cost as
before only in the pathological "everything matches" case (unavoidable -
you need real data for every result you return).
1. **Recently Added** — dashboard card showing courses by folder mtime
(`get_recently_added_courses`), separate from Recently Viewed (which
tracks what's been *watched*, not what showed up on disk).
2. **Bulk find/replace rename**`Settings → Manage Library → Bulk Rename`.
Preview (`GET /api/bulk-rename/preview`) before apply
(`POST /api/bulk-rename/apply`) is mandatory; apply takes the *exact*
list the client saw in preview (not a re-derived pattern match), and
continues past individual failures (e.g. a name collision) rather than
aborting the whole batch. Scans every directory in the library, course
and category/group folders alike (`_iter_all_directories`) - by user's
choice, since the naming problem isn't limited to course-level folders.
The single-item rename route (`/api/rename-path`) was refactored to
share the same validation/apply logic (`validate_new_name`,
`perform_rename`) - confirmed byte-for-byte identical error responses
after the refactor.
3. **Estimated time remaining** — added to `_calculate_completion_stats`;
only ever computed over lessons that have actually reported a duration
(i.e. been played at least once) - no data, no estimate shown, rather
than a misleading "0 remaining."
4. **Library stats overview** — total courses, lessons tracked, time
watched, daily streak. Reads each course's small progress JSON directly
rather than re-scanning course contents, so it stays cheap regardless of
how many files live inside each course.
5. **Backup/export**`GET /api/backup`, a `Settings → Backup & Export`
button. Zips settings/hidden-paths/recent-views plus every course's
progress+notes file (stdlib `zipfile`, no new dependency) - not the
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
+335 -88
View File
@@ -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.
![Dashboard](images/dashboard-2026-08.png)
![Lesson view](images/lesson-2026-08.png)
*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
> ![image](https://github.com/WhiskeyCoder/OfflineU/blob/main/images/lesson-0-8-2025-08-04-04_58_17.png) ```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 |
--- ---
## 🛠️ Installation ## Features
### 🔁 Quick Start (Local) **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.
1. Clone the repo: **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)
```bash **Playback & progress**
git clone https://github.com/WhiskeyCoder/OfflineU.git - Video/audio player with resize, playback-speed presets, and resume-from-
cd OfflineU 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
2. Install Python dependencies: **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)
```bash **Personalization** (Settings)
pip install flask - 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)
3. Run the app: **Backup & integrations**
- One-click backup export and restore: settings, hidden-path choices, the
```bash Next Up queue, Favorites, recent-view history, ignored-duplicate pairs,
python offlineu_core.py --create-templates 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
4. Open your browser: 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
http://127.0.0.1:5000 lesson's notes to an Outline document/collection
```
--- ---
## 📂 Folder Structure Example ## Data files
```bash 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 ## Supported file types
| Type | Extensions | | Type | Extensions |
| --------- | ----------------------------------------------------------- | | --------- | ------------------------------------------------------------------ |
| Videos | `.mp4`, `.mkv`, `.webm`, `.mov`, `.avi`, etc. | | Video | `.mp4` `.mkv` `.avi` `.mov` `.webm` `.m4v` `.flv` `.wmv` |
| Audio | `.mp3`, `.wav`, `.aac`, etc. | | Audio | `.mp3` `.wav` `.m4a` `.aac` `.ogg` `.flac` |
| Docs | `.txt`, `.md`, `.html`, `.pdf`, `.docx` | | Documents | `.txt` `.md` `.html` `.htm` `.pdf` `.docx` `.doc` `.rtf` |
| Subtitles | `.srt`, `.vtt` | | Subtitles | `.srt` `.vtt` `.ass` `.sub` `.sbv` |
| Quizzes | Detected if file name contains `quiz`, `exam`, `test`, etc. | | Quizzes | Any doc file whose name contains `quiz`, `exam`, `test`, `assessment`, `exercise`, `assignment`, or `homework` |
--- ---
## ⚙️ CLI Options ## Tools
| Option | Description | `tools/generate_website_scheme_themes.py` is the one-off script that
| -------------------- | ------------------------------- | produced the 34 "website scheme" theme entries in `THEME_PALETTES` (of
| `--host` | Set host (default: `127.0.0.1`) | 53 candidates - all 24 light-mode ones were converted to dark/colored
| `--port` | Set port (default: `5000`) | backgrounds per request, then 19 were cut across two review passes: 2
| `--debug` | Enable Flask debug mode | for a neon-bright accent, 17 for landing too close to another kept
| `--create-templates` | Generate default HTML templates | scheme once mapped into actual UI colors, since forcing everything dark
| `<course_path>` | Load course directly at startup | 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
## 🧠 Roadmap how it turned Figma's color-scheme screenshots into actual UI palettes.
* [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.**
+1
View File
@@ -0,0 +1 @@
2026-08-26 15:27 UTC — update README screenshots
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

+3541 -157
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -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 %}
+8
View File
@@ -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 %}
File diff suppressed because it is too large Load Diff
+120 -38
View File
@@ -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>
+1035 -63
View File
File diff suppressed because it is too large Load Diff
+315
View File
@@ -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>
+426 -335
View File
@@ -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,8 +71,9 @@
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.app-header .brand-icon { .app-header .brand-mark {
font-size: 1.6em; flex-shrink: 0;
display: block;
} }
.app-header a.brand-link { .app-header a.brand-link {
color: var(--accent); color: var(--accent);
@@ -77,35 +82,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 {
display: flex;
gap: 20px;
}
.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;
@@ -210,90 +221,29 @@
padding: 5px 12px; padding: 5px 12px;
font-size: 0.85em; font-size: 0.85em;
} }
.curate-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius);
background: var(--bg-tertiary);
margin-bottom: 5px;
cursor: pointer;
}
.curate-row:hover {
background: var(--bg-tertiary-hover);
}
.curate-row.is-hidden {
opacity: 0.55;
}
.curate-row-name {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.curate-row-name span.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.curate-toggle {
font-size: 1.1em;
width: 18px;
text-align: center;
flex-shrink: 0;
}
.curate-children {
margin-left: 22px;
margin-top: 4px;
display: none;
}
.curate-children.expanded {
display: block;
}
.curate-empty-hint {
color: var(--text-muted);
font-size: 0.9em;
padding: 6px 0;
}
.curate-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.curate-rename-input {
flex: 1;
min-width: 0;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 4px 8px;
font-size: 0.95em;
font-family: var(--font-family);
}
.curate-row-status {
flex-basis: 100%;
font-size: 0.8em;
color: #ff6b6b;
}
#save-status { #save-status {
font-size: 0.9em; font-size: 0.9em;
color: #28a745; color: var(--success);
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
} }
#save-status.visible { opacity: 1; } #save-status.visible { opacity: 1; }
/* 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>
@@ -310,9 +260,13 @@
<span class="setting-desc">Overall light/dark palette</span> <span class="setting-desc">Overall light/dark palette</span>
</label> </label>
<select id="theme" data-setting="theme"> <select id="theme" data-setting="theme">
{% for choice in theme_choices %} {% for group_label, choices in theme_groups %}
<optgroup label="{{ group_label }}">
{% for choice in choices %}
<option value="{{ choice }}" {% if settings.theme == choice %}selected{% endif %}>{{ theme_display_names[choice] }}</option> <option value="{{ choice }}" {% if settings.theme == choice %}selected{% endif %}>{{ theme_display_names[choice] }}</option>
{% endfor %} {% endfor %}
</optgroup>
{% endfor %}
</select> </select>
</div> </div>
<div class="setting-row"> <div class="setting-row">
@@ -406,25 +360,30 @@
<button class="btn" onclick="saveLibraryPath()">Save</button> <button class="btn" onclick="saveLibraryPath()">Save</button>
</div> </div>
<span id="library-path-status" style="font-size: 0.85em; min-height: 1.2em;"></span> <span id="library-path-status" style="font-size: 0.85em; min-height: 1.2em;"></span>
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px;">
<button class="btn btn-secondary btn-sm" id="prewarm-durations-btn" onclick="startDurationPrewarm()">{{ icons.icon('refresh', 14) }} Precompute Lengths &amp; Cover Art</button>
<span id="prewarm-durations-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
</div>
<span class="setting-desc">Reads every video/audio file's length and generates cover art for any course without one, up front - so course cards in the Library show their runtime and artwork immediately instead of computing it the first time each course is viewed.</span>
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px;">
<button class="btn btn-secondary btn-sm" id="regen-thumbnails-btn" onclick="startThumbnailRegen()">{{ icons.icon('refresh', 14) }} Regenerate Thumbnails</button>
<span id="regen-thumbnails-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
</div>
<span class="setting-desc">Re-runs auto-generated cover art from scratch for every course that has one (not manually-placed covers) - useful after an improvement to how thumbnails are picked, since existing ones are otherwise cached forever.</span>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2>Manage Library</h2> <h2>File Management</h2>
<p class="setting-desc" style="margin-bottom: 12px;"> <p class="setting-desc" style="margin-bottom: 12px;">
Hide courses or whole folders from the Library browser without touching anything on disk Sort new courses out of an "Unsorted" folder at the root of your library, refresh the
(hiding a folder hides everything inside it), or rename a course/folder directly - no need library cache, bulk-rename or move things around, hide courses, check for duplicates, and
to go to the NAS. clean up stale references - all on its own page, with room to review before anything
changes on disk.
</p> </p>
<a class="btn btn-secondary" href="/unsorted">{{ icons.icon('folder', 14) }} Open File Management</a>
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Currently hidden</div>
<div id="hidden-list"></div>
</div>
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse</div>
<div id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
<div id="curate-tree"></div>
</div> </div>
<div class="card"> <div class="card">
@@ -466,29 +425,112 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="setting-row">
<label for="autoplay_next">Auto-play next lesson
<span class="setting-desc">When a lesson finishes, automatically move on to the next one after a few seconds (cancelable)</span>
</label>
<input type="checkbox" id="autoplay_next" data-setting="autoplay_next" {% if settings.autoplay_next %}checked{% endif %}>
</div>
</div>
<div class="card">
<h2>Backup &amp; Export</h2>
<div class="setting-row">
<label>Download a backup
<span class="setting-desc">Settings, hidden-path curation, the Next Up queue, recently-viewed history, Outline config, and every course's progress/notes - not the course files themselves.</span>
</label>
<a class="btn" href="/api/backup">Download Backup</a>
</div>
<div class="setting-row">
<label for="restore-backup-input">Restore from a backup
<span class="setting-desc">Overwrites current settings and progress with what's in the zip. Course files on disk aren't touched.</span>
</label>
<input type="file" id="restore-backup-input" accept=".zip" style="display: none;" onchange="handleRestoreFileChosen(this)">
<button class="btn btn-secondary" onclick="document.getElementById('restore-backup-input').click()">Choose Backup File…</button>
</div>
<span id="restore-backup-status" style="font-size: 0.85em; min-height: 1.2em; display: block;"></span>
</div>
<div class="card">
<h2>Outline Integration</h2>
<p class="setting-desc" style="margin-bottom: 12px;">
Pick a topic on a lesson's note (on the lesson page) to push it there when you leave the page -
each topic becomes a document inside the collection below, and the note is nested under it.
Notes without a topic stay local only.
</p>
<div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;">
<label for="outline-url">Outline URL
<span class="setting-desc" id="outline-config-status">Not configured</span>
</label>
<input type="text" id="outline-url" class="hex-input" style="width: 100%; font-family: var(--font-family);"
placeholder="https://outline.example.com">
<label for="outline-token" style="margin-top: 4px;">API token</label>
<input type="password" id="outline-token" class="hex-input" style="width: 100%; font-family: var(--font-family);"
placeholder="Leave blank to keep the current token">
<label for="outline-collection" style="margin-top: 4px;">Default collection
<span class="setting-desc">Where topic documents get created - e.g. "Training Notes"</span>
</label>
<select id="outline-collection">
<option value="">Save your URL/token first, then pick one</option>
</select>
<div style="display: flex; gap: 10px; margin-top: 4px;">
<button class="btn" onclick="saveOutlineConfig()">Save</button>
<button class="btn btn-secondary" onclick="testOutlineConnection()">Test Connection</button>
</div>
<span id="outline-test-status" style="font-size: 0.85em; min-height: 1.2em;"></span>
</div>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button> <button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button>
<span id="save-status"> Saved</span> <span id="save-status">{{ icons.icon('check', 14) }} Saved</span>
</div> </div>
<p style="text-align: center; color: var(--text-muted); font-size: 0.8em; margin-top: 30px;">
Version {{ build_version }}
</p>
{% if changelog_entries %}
<details style="max-width: 500px; margin: 10px auto 0; color: var(--text-muted); font-size: 0.8em;">
<summary style="text-align: center; cursor: pointer;">What's New</summary>
<ul style="margin: 8px 0 0; padding-left: 20px;">
{% for entry in changelog_entries %}
<li style="margin-bottom: 4px;">{{ entry }}</li>
{% endfor %}
</ul>
</details>
{% endif %}
</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="/help">❓ Help</a> </a>
<a href="/">← Back to app</a> <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>
</div> </a>
</footer> <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>
<script> <script>
// Client-rendered mirror of a few templates/_icons.html entries -
// JS template literals can't call the Jinja macro, so the icons
// built dynamically here are kept in sync with the same path data
// by hand.
const ICON_SVGS = {
check: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>',
};
let saveTimeout = null; let saveTimeout = null;
function showSaved() { function showSaved() {
@@ -518,6 +560,10 @@
el.addEventListener('change', () => saveSetting(el.dataset.setting, el.value)); el.addEventListener('change', () => saveSetting(el.dataset.setting, el.value));
}); });
document.querySelectorAll('input[type="checkbox"][data-setting]').forEach(el => {
el.addEventListener('change', () => saveSetting(el.dataset.setting, el.checked));
});
const accentPicker = document.getElementById('accent-picker'); const accentPicker = document.getElementById('accent-picker');
const accentHex = document.getElementById('accent-hex'); const accentHex = document.getElementById('accent-hex');
@@ -537,209 +583,6 @@
} }
}); });
// ---- Library curation: hide/show courses & folders ----
function loadHiddenList() {
fetch('/api/hidden-paths')
.then(r => r.json())
.then(data => {
const wrap = document.getElementById('hidden-list-wrap');
const list = document.getElementById('hidden-list');
const items = data.hidden_paths || [];
if (items.length === 0) {
wrap.style.display = 'none';
return;
}
wrap.style.display = 'block';
list.innerHTML = items.map(item => `
<div class="curate-row">
<div class="curate-row-name">
<span>🚫</span>
<span class="name">${item.name}</span>
</div>
<button class="btn btn-secondary btn-sm" onclick="toggleHidden('${item.path.replace(/'/g, "\\'")}', false)">Show</button>
</div>
`).join('');
})
.catch(() => {});
}
function loadCurateLevel(path, container, isRoot) {
const url = path ? `/library/manage?path=${encodeURIComponent(path)}` : '/library/manage';
fetch(url)
.then(r => r.json())
.then(data => {
if (isRoot) {
document.getElementById('curate-path-bar').textContent = `Browsing ${data.library_path}`;
}
renderCurateLevel(data, container);
})
.catch(() => {
container.innerHTML = '<p style="color:#ff6b6b;">Could not reach the library scanner.</p>';
});
}
function renderCurateLevel(data, container) {
if (!data.items || data.items.length === 0) {
const reason = (data.errors && data.errors.length) ? data.errors.join(' ') : 'Nothing here.';
container.innerHTML = `<div class="curate-empty-hint">${reason}</div>`;
return;
}
container.innerHTML = data.items.map(item => {
const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'");
const icon = item.type === 'course' ? '🎓' : '📁';
const hiddenClass = item.hidden ? 'is-hidden' : '';
const renameBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startRename(this, '${safePath}', '${safeName}')" title="Rename">✏️</button>`;
const toggleBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
const actions = `<div class="curate-actions">${renameBtn}${toggleBtn}</div>`;
if (item.type === 'course') {
return `
<div class="curate-row ${hiddenClass}">
<div class="curate-row-name">
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${actions}
</div>
`;
}
return `
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
<div class="curate-row-name">
<span class="curate-toggle">▶</span>
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${actions}
</div>
<div class="curate-children" data-loaded="false"></div>
`;
}).join('');
}
function toggleCurateDir(rowEl, path) {
const content = rowEl.nextElementSibling;
const toggleIcon = rowEl.querySelector('.curate-toggle');
if (!content || !content.classList.contains('curate-children')) return;
if (content.classList.contains('expanded')) {
content.classList.remove('expanded');
if (toggleIcon) toggleIcon.textContent = '▶';
return;
}
content.classList.add('expanded');
if (toggleIcon) toggleIcon.textContent = '▼';
if (content.dataset.loaded === 'true') return;
content.innerHTML = '<div class="curate-empty-hint">Loading...</div>';
content.dataset.loaded = 'true';
content.dataset.path = path;
loadCurateLevel(path, content, false);
}
function toggleHidden(path, hidden, btnEl) {
fetch('/api/hidden-paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, hidden: hidden })
})
.then(r => r.json())
.then(data => {
if (data.success) {
loadHiddenList();
// Refresh just the level this toggle happened in, rather
// than collapsing the whole tree back to root.
const container = btnEl ? (btnEl.closest('.curate-children') || document.getElementById('curate-tree'))
: document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
}
})
.catch(() => {});
}
// ---- Library curation: rename a course/folder in place on disk ----
function startRename(btnEl, path, currentName) {
const row = btnEl.closest('.curate-row');
const nameSpan = row ? row.querySelector('.curate-row-name .name') : null;
if (!row || !nameSpan) return;
let settled = false;
const input = document.createElement('input');
input.type = 'text';
input.className = 'curate-rename-input';
input.value = currentName;
input.onclick = (e) => e.stopPropagation();
nameSpan.replaceWith(input);
input.focus();
input.select();
function cancel() {
if (settled) return;
settled = true;
input.replaceWith(nameSpan);
}
function commit() {
if (settled) return;
const newName = input.value.trim();
if (!newName || newName === currentName) {
cancel();
return;
}
settled = true;
submitRename(btnEl, path, newName, row, nameSpan, input);
}
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
input.addEventListener('blur', commit);
}
function submitRename(btnEl, path, newName, row, nameSpan, input) {
fetch('/api/rename-path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, new_name: newName })
})
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
input.replaceWith(nameSpan); // nameSpan still shows the original, unchanged name
showRowError(row, data.error || 'Rename failed');
return;
}
if (data.active_course_reset) {
alert('That was your active course - reload the main page to pick it up under its new name.');
}
loadHiddenList();
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
})
.catch(() => {
input.replaceWith(nameSpan);
showRowError(row, 'Could not reach the server');
});
}
function showRowError(row, message) {
let status = row.querySelector('.curate-row-status');
if (!status) {
status = document.createElement('div');
status.className = 'curate-row-status';
row.appendChild(status);
}
status.textContent = message;
}
loadHiddenList();
loadCurateLevel(null, document.getElementById('curate-tree'), true);
function saveLibraryPath() { function saveLibraryPath() {
const input = document.getElementById('library_path'); const input = document.getElementById('library_path');
const status = document.getElementById('library-path-status'); const status = document.getElementById('library-path-status');
@@ -751,16 +594,178 @@
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
status.style.color = '#28a745'; status.style.color = 'var(--success)';
status.textContent = ' Saved — reload the main page to see it take effect'; status.innerHTML = ICON_SVGS.check + ' Saved — reload the main page to see it take effect';
showSaved(); showSaved();
} else { } else {
status.style.color = '#ff6b6b'; status.style.color = 'var(--error)';
status.textContent = data.error || 'Could not save'; status.textContent = data.error || 'Could not save';
} }
}) })
.catch(err => { .catch(err => {
status.style.color = '#ff6b6b'; status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server';
});
}
function updatePrewarmStatus(data) {
const btn = document.getElementById('prewarm-durations-btn');
const status = document.getElementById('prewarm-durations-status');
if (data.running) {
btn.disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = `Scanning… ${data.done}/${data.total} courses`;
return;
}
btn.disabled = false;
if (data.error) {
status.style.color = 'var(--error)';
status.textContent = `Error: ${data.error}`;
} else if (data.total) {
status.style.color = 'var(--success)';
status.innerHTML = `${ICON_SVGS.check} Scanned ${data.total} course${data.total === 1 ? '' : 's'}`;
setTimeout(() => { status.textContent = ''; }, 5000);
}
}
function pollPrewarmStatus() {
fetch('/api/library/prewarm-durations/status')
.then(r => r.json())
.then(data => {
updatePrewarmStatus(data);
if (data.running) setTimeout(pollPrewarmStatus, 1500);
})
.catch(() => {});
}
function startDurationPrewarm() {
const status = document.getElementById('prewarm-durations-status');
document.getElementById('prewarm-durations-btn').disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = 'Starting…';
fetch('/api/library/prewarm-durations', { method: 'POST' })
.then(r => r.json())
.then(data => {
updatePrewarmStatus(data);
if (data.running) setTimeout(pollPrewarmStatus, 1500);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server';
document.getElementById('prewarm-durations-btn').disabled = false;
});
}
// Pick back up a prewarm that's already running from a previous
// page load, rather than leaving the button looking idle while
// work is actually happening in the background.
(function resumePrewarmStatusIfRunning() {
fetch('/api/library/prewarm-durations/status')
.then(r => r.json())
.then(data => {
if (data.running) {
updatePrewarmStatus(data);
setTimeout(pollPrewarmStatus, 1500);
}
})
.catch(() => {});
})();
function updateThumbnailRegenStatus(data) {
const btn = document.getElementById('regen-thumbnails-btn');
const status = document.getElementById('regen-thumbnails-status');
if (data.running) {
btn.disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = `Regenerating… ${data.done}/${data.total}`;
return;
}
btn.disabled = false;
if (data.error) {
status.style.color = 'var(--error)';
status.textContent = `Error: ${data.error}`;
} else if (data.total) {
status.style.color = 'var(--success)';
status.innerHTML = `${ICON_SVGS.check} Regenerated ${data.total} thumbnail${data.total === 1 ? '' : 's'}`;
setTimeout(() => { status.textContent = ''; }, 5000);
} else {
status.style.color = 'var(--text-muted)';
status.textContent = 'No auto-generated thumbnails to regenerate';
setTimeout(() => { status.textContent = ''; }, 5000);
}
}
function pollThumbnailRegenStatus() {
fetch('/api/library/regenerate-thumbnails/status')
.then(r => r.json())
.then(data => {
updateThumbnailRegenStatus(data);
if (data.running) setTimeout(pollThumbnailRegenStatus, 1500);
})
.catch(() => {});
}
function startThumbnailRegen() {
const status = document.getElementById('regen-thumbnails-status');
document.getElementById('regen-thumbnails-btn').disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = 'Starting…';
fetch('/api/library/regenerate-thumbnails', { method: 'POST' })
.then(r => r.json())
.then(data => {
updateThumbnailRegenStatus(data);
if (data.running) setTimeout(pollThumbnailRegenStatus, 1500);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server';
document.getElementById('regen-thumbnails-btn').disabled = false;
});
}
(function resumeThumbnailRegenStatusIfRunning() {
fetch('/api/library/regenerate-thumbnails/status')
.then(r => r.json())
.then(data => {
if (data.running) {
updateThumbnailRegenStatus(data);
setTimeout(pollThumbnailRegenStatus, 1500);
}
})
.catch(() => {});
})();
function handleRestoreFileChosen(input) {
const file = input.files && input.files[0];
input.value = ''; // allow re-choosing the same file later
if (!file) return;
if (!confirm(`Restore from "${file.name}"? This overwrites your current settings and every course's progress/notes with what's in the backup.`)) {
return;
}
const status = document.getElementById('restore-backup-status');
status.style.color = 'var(--text-muted)';
status.textContent = 'Restoring…';
const formData = new FormData();
formData.append('backup', file);
fetch('/api/backup/restore', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.success) {
status.style.color = 'var(--success)';
const skippedNote = data.skipped && data.skipped.length
? ` (${data.skipped.length} course${data.skipped.length === 1 ? '' : 's'} skipped - not found in the current library)`
: '';
status.innerHTML = `${ICON_SVGS.check} Restored ${data.restored} file${data.restored === 1 ? '' : 's'}${skippedNote} - reload to see it take effect`;
} else {
status.style.color = 'var(--error)';
status.textContent = data.error || 'Restore failed';
}
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server'; status.textContent = 'Could not reach the server';
}); });
} }
@@ -771,7 +776,7 @@
const coursePath = input.value.trim(); const coursePath = input.value.trim();
if (!coursePath) { if (!coursePath) {
status.style.color = '#ff6b6b'; status.style.color = 'var(--error)';
status.textContent = 'Please enter a course path.'; status.textContent = 'Please enter a course path.';
return; return;
} }
@@ -787,16 +792,16 @@
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
status.style.color = '#28a745'; status.style.color = 'var(--success)';
status.textContent = ` Loaded "${data.course_name}" — redirecting...`; status.innerHTML = ICON_SVGS.check + ` Loaded "${data.course_name}" — redirecting...`;
setTimeout(() => { window.location.href = '/'; }, 1000); setTimeout(() => { window.location.href = '/'; }, 1000);
} else { } else {
status.style.color = '#ff6b6b'; status.style.color = 'var(--error)';
status.textContent = data.error || 'Could not load course'; status.textContent = data.error || 'Could not load course';
} }
}) })
.catch(() => { .catch(() => {
status.style.color = '#ff6b6b'; status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server'; status.textContent = 'Could not reach the server';
}); });
} }
@@ -818,6 +823,92 @@
.catch(err => console.error('Failed to reset video size:', err)); .catch(err => console.error('Failed to reset video size:', err));
} }
// ---- Outline integration ----
function loadOutlineConfig() {
fetch('/api/outline/config')
.then(r => r.json())
.then(data => {
document.getElementById('outline-url').value = data.base_url || '';
document.getElementById('outline-config-status').textContent =
data.configured ? 'Configured' : 'Not configured';
loadOutlineCollectionsDropdown(data.default_collection_id || '');
})
.catch(() => {});
}
function loadOutlineCollectionsDropdown(selectedId) {
const select = document.getElementById('outline-collection');
fetch('/api/outline/collections')
.then(r => r.json())
.then(data => {
const collections = data.collections || [];
if (collections.length === 0) {
select.innerHTML = '<option value="">No collections found - check your URL/token</option>';
return;
}
select.innerHTML = collections.map(c =>
`<option value="${c.id}">${c.name}</option>`
).join('');
if (selectedId) select.value = selectedId;
})
.catch(() => {});
}
function saveOutlineConfig() {
const status = document.getElementById('outline-test-status');
const url = document.getElementById('outline-url').value.trim();
const token = document.getElementById('outline-token').value.trim();
const collectionSelect = document.getElementById('outline-collection');
const collectionId = collectionSelect.value;
const collectionName = collectionId ? collectionSelect.options[collectionSelect.selectedIndex].text : '';
status.style.color = 'var(--text-muted)';
status.textContent = 'Saving...';
fetch('/api/outline/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
base_url: url,
api_token: token,
default_collection_id: collectionId,
default_collection_name: collectionName
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
status.style.color = 'var(--success)';
status.textContent = 'Saved.';
document.getElementById('outline-token').value = '';
loadOutlineConfig();
} else {
status.style.color = 'var(--error)';
status.textContent = data.error || 'Could not save.';
}
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
function testOutlineConnection() {
const status = document.getElementById('outline-test-status');
status.style.color = 'var(--text-muted)';
status.textContent = 'Testing...';
fetch('/api/outline/test')
.then(r => r.json())
.then(data => {
status.style.color = data.success ? 'var(--success)' : 'var(--error)';
status.textContent = data.success ? 'Connected.' : (data.error || 'Connection failed.');
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
loadOutlineConfig();
function resetSettings() { function resetSettings() {
fetch('/api/settings/reset', { method: 'POST' }) fetch('/api/settings/reset', { method: 'POST' })
.then(r => r.json()) .then(r => r.json())
File diff suppressed because it is too large Load Diff
+254
View File
@@ -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