Files
offlineu/OfflineU-project-summary.md
T
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

22 KiB
Raw Blame History

OfflineU — Project Summary (for Claude Code handoff)

What this is

A fork of the self-hosted course viewer WhiskeyCoder/OfflineU (Flask app), customized for personal use. Deployed via Docker (Dockhand on a Ugreen NAS), built locally from a private Gitea repo rather than pulling the upstream image.

Deployment

  • docker-compose.yml: build: . + pull_policy: build (forces a real rebuild every deploy — without pull_policy: build, Compose can silently reuse a stale cached image).
  • network_mode: host, PUID=1000, PGID=10, TZ=America/New_York.
  • Volumes: courses at /volume1/files/training/app/courses, progress/settings data at /volume2/docker/offlineu/data/app/data.
  • Deployed from Gitea via Dockhand's "Deploy from Git" stack type.

Files touched this session

  • offlineu_core.py — all backend logic (Flask routes, settings, progress tracking)
  • templates/course_dashboard.html — main dashboard / library browser
  • templates/lesson_view.html — individual lesson page
  • templates/settings.html — new settings page
  • templates/help.html — new help page
  • static/theme.js — shared script that applies persisted display settings

Features added, roughly in order

  1. Library browser — lazy, drill-down directory browsing instead of typing a full path. /library lists one directory level at a time; a folder is treated as a "course" once it (or 2+ of its immediate subfolders) contain video/audio files directly. Starts collapsed at the top level.

  2. PDF preview fix — lesson pages were fetching PDFs as text and dumping raw bytes. Now PDFs (and HTML) render in an iframe; .docx/.doc/.rtf show a "no preview, download instead" message rather than garbled text.

  3. Full-width, resizable layout — removed a hardcoded 800px video cap and 1000px page cap. Video player supports native corner-drag resizing (resize: both + object-fit: contain), and the last-used size persists via settings.

  4. Settings system (/settings, /api/settings) — server-side persisted (/app/data/settings.json), covering:

    • Theme (dark/light + 10 named presets: Dracula, Tokyo Night, Catppuccin Mocha, Ayu Dark, GitHub Dark, Atom One Dark, Houston, Night Owl, Dainty, Matcha)
    • Accent color (auto-syncs to a theme's signature color on switch, independently editable after)
    • Font family (system/sans/serif/monospace/Monaspace) and size
    • Layout width, density, card style, corner radius
    • Library root override (point the browser at a specific subfolder)
    • Video player size All applied via CSS custom properties, fetched once per page load by static/theme.js.
  5. Header/footer redesign — branded gradient header, sticky footer holding Settings + Help links (consolidated from several scattered links). Clicking the "OfflineU" wordmark anywhere calls /reset_course (clears the loaded course) rather than just / (which would keep showing the same course).

  6. Help page (/help) — "How to Use" / "Supported File Types" moved here from the cluttered main dashboard.

  7. Recently Viewed — tracks the last 5 lessons viewed across all courses (/app/data/recent_views.json), shown only on the no-course-loaded library screen (not on an individual course's page). Clicking an entry loads that lesson's course if it isn't already active, then jumps to the lesson (/recent/open).

  8. Watch-progress trackingLesson.duration_seconds added alongside the existing progress_seconds. Lesson rows in the course tree and in Recently Viewed show either a green checkmark (completed), an accent-colored "NN% watched" badge + thin progress bar (in progress), or a plain "○" (untouched).

  9. Library curation (Settings → Curate Library) — hide individual courses or entire folders from the browser without touching anything on disk. Persisted in /app/data/hidden_paths.json. Hiding a folder hides everything inside it. /library (normal browsing) filters hidden items out; /library/manage (used by the Settings UI) includes them flagged hidden: true so they can be un-hidden.

Rough edges fixed this session

  • Single-section course detection_looks_like_course() in offlineu_core.py now also recognizes a course with exactly one section subfolder, if that subfolder's name reads as a section label ("Section 1", "Module 2", "Chapter 3", etc. — see _SECTION_NAME_RE). This deliberately doesn't touch the general "publisher folder holding one course" case (e.g. Pluralsight/Docker Deep Dive/), since that subfolder is named after the course, not a section.
  • Empty folder after hiding all its courses — added _contains_visible_course(), used by list_library_directory() when skip_hidden=True, so a folder whose every course has been individually curated out no longer appears as a drillable (but empty) directory in normal browsing. The Settings curation UI (skip_hidden=False) is unaffected — it still needs to show those folders so courses can be un-hidden.
  • Matcha theme accuracy — replaced the hand-approximated Matcha palette with real values sourced from lucafalasco/matcha's published VS Code theme JSON.
  • Dainty theme swapped for Nord — turned out "Dainty" (HotWordland/dainty-vscode) is a Lab-space theme generator, not a fixed palette — there was no single official hex set to verify against, so approximating it was never really fixable. Replaced it with Nord, which has a well-documented, fully verifiable official palette (nordtheme.com). Anyone with theme: "dainty" already saved in settings.json falls back gracefully to the dark default on next load.

Mobile UI overhaul + in-app renaming (this session)

  • Fixed the mobile text-overlap bug.lesson-item/.tree-header in course_dashboard.html had no min-width: 0 or truncation on their flex children, so long course/lesson names (especially scene-release-style dotted names like Udemy.crash.course.electronics...Mimir with zero whitespace to wrap on) would overflow and visually overlap neighboring text on narrow screens. Fixed with min-width:0 + ellipsis truncation (desktop/tablet) and a @media (max-width: 600px) block that stacks title/metadata into separate lines with overflow-wrap: break-word (the dotted-name case needed this specifically — plain white-space: normal doesn't create a break opportunity in a string with no spaces).
  • Library browser redesigned — replaced the nested-indent accordion (AIChatGPT → course, each level eating horizontal space) with a single-level drill-down + breadcrumb (Library / AI / ChatGPT), the standard mobile file-browser pattern. Tapping a folder now replaces the list instead of nesting under it. In-course lesson tree keeps its old expand-in-place behavior (shallower, wasn't the reported problem).
  • Rename courses/folders from Settings — "Curate Library" is now "Manage Library": each row gets a ✏️ button alongside Hide/Show that renames the directory in place on disk via a new POST /api/rename-path. Handles path-traversal/collision validation, and rebases any hidden_paths.json / recent_views.json entries nested under the renamed path (progress files need no rebasing — they live inside the directory and are keyed relative to it). If you rename your currently loaded course's folder, the app resets to the library view rather than serving a stale path.

Eight usability features (this session)

Asked for after brainstorming what could make the app nicer to use — see .claude/plans/dazzling-yawning-glacier.md for the full scoping rationale.

  1. Course thumbnailsfind_course_thumbnail() looks for cover/folder/thumbnail/thumb/poster (.jpg/.jpeg/.png/.webp) directly inside a course folder; served via GET /library/thumbnail. Shown in the Library browser and Recently Viewed/Continue Watching, falling back to the emoji icon if there's no cover image.
  2. Library searchGET /library/search?q=... recursively walks the library (reusing list_library_directory's hidden-path filtering) and matches on course name. Debounced search box above the Library browser.
  3. Continue Watching — reuses the Recently-Viewed history (MAX_RECENT_VIEWS bumped 5→20) rather than a full library-wide progress index; split into "in progress" vs. "recently touched" on the dashboard.
  4. Playback speed controlsettings.playback_speed (0.75x2x), persisted the same way video player size already was; speed buttons on the lesson page, applied on load via the existing /api/settings fetch.
  5. Installable app (PWA)static/manifest.json + static/icons/icon-{192,512}.png, linked from every template's <head>. Install-only, deliberately no service worker/offline caching — this app has no real "offline" mode (it's a thin client over the Flask backend/NAS files), so a caching SW would just create stale-content bugs. Also fixed: lesson_view.html was missing the viewport meta tag entirely, so lesson/video pages weren't actually mobile-responsive until now.
  6. Sort in the Library browser — client-side Name A→Z/Z→A only; progress-based filtering (not-started/in-progress/done) was scoped out, since it'd need the same expensive per-course scan as #3's "ideal" version.
  7. Per-lesson notes — a note field alongside completed/ progress_seconds in each lesson's existing progress-file entry, via ProgressTracker.update_lesson_note() / POST /api/lesson-note. Fixed a real bug in update_lesson_progress while at it: it always overwrote the entire lesson entry, which would have silently deleted a saved note on the next routine playback-progress autosave.
  8. Mark course as watchedPOST /api/course/mark-watched, scoped to whatever course is currently loaded (not an arbitrary library path, to avoid re-validating/re-scanning an untrusted path). Button lives in the 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 renameSettings → 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/exportGET /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

  • App is unauthenticated by design (matches upstream) — settings and hidden-path 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

Edit locally → git add . && git commit -m "..." && git push to the private Gitea repo → redeploy the stack in Dockhand (which builds from the fresh git pull + pull_policy: build).