Commit Graph
9 Commits
Author SHA1 Message Date
peter 6fda49369f fix(ui): a sticky table header turns the scroller's top fade off itself; DataTable renders one layout
- "A page with a sticky ColumnHead must declare fadeTop: false" lived in
  prose: a registry flag on three pages plus an App special case for the
  Channels card layout, and the next table page would have re-acquired the
  fade-on-the-header defect with every lane green. ColumnHead now registers
  itself with the enclosing PageScroller (ui/stickyTop) while mounted; the
  registry flag, the App branch and the prop plumbing are gone. Measured on
  localdev: the top mask is 0px under Channels' and Audit's headers, 20px on
  Home and the Feed.
- ui/DataTable mounted its table AND its card list at every width, one
  hidden by CSS: double DOM and render cost, duplicate ids, two keyboard
  copies of every row. It now mounts the one the `md` media query calls for
  (lib/useMediaQuery); the keyboard test asserts one layout at each width.
- The Plex module's content width went px → rem with the rest of the page.
2026-09-19 07:51:33 +02:00
peter b5996a0e56 feat(diagnostics): the admin log console
Admin → Diagnostics reads the server's stored log: one list, newest first,
where a line opens in place to its full record (time, source and logger,
account, request and job ids, version, message, attributes, stack). A
request or job id narrows the list to that request or job with every other
filter off, shown as a removable chip. Filters: level group, source,
account, event, period and a free-text search; all of them live in the URL
(`l*` keys, which setPage clears like the Plex and Channels keys), so a
filtered view is a link and Back restores it. "Live" re-reads the newest
page every few seconds while the tab is visible; paging into older rows
turns it off. Copy puts the loaded lines on the clipboard as JSON lines.

The facet counts now honour every filter but their own, as the feed's do:
a request pivot narrows them, and a picked level does not zero its
siblings. A value in use stays offered even when the counts drop it.

"Saját napló" and the console draw a line with one shared component,
`LogLine`, whose phone layout gives the message its own full-width line
(sharing the code's line left it a few characters). The level groups
moved to lib/log.ts for both.

The console's own reads are logged at DEBUG on success, like the
healthcheck: at INFO its live tail filled the log with itself.
2026-09-19 01:54:48 +02:00
peter c3d88d9b20 refactor: component files export only components; lint fails on a warning
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.

- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
  context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
  component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
  following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
  plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
  ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
  and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.

npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
2026-09-17 02:59:09 +02:00
peter 5346efcd10 fix(boot): a new tab knows its account before anything renders
With Settings -> Appearance -> Startup turned off, every NEW tab or window still
opened on Home; only a same-tab reload honoured it. The provider tree mounted
before /api/me answered, so NavigationProvider's initializer could not read the
per-account opt-out, landed on Home and mirrored ?page=home into the address bar.
When App pinned the account a moment later, the re-hydrate re-read that mirror
and stayed on Home. The same mount-then-patch shape made a new tab start on
default filters, the default theme and the browser language, and correct each
after the first paint.

BootGate (main.tsx, above the providers) now owns the setup-status and /api/me
queries and mounts the tree only once the identity is settled: it pins the tab's
account and adopts the account's language in a layout effect, so the first frame
of the app is already right. The tree is keyed on the account id, which makes
"per-account state is read once, for this account" a structural guarantee.

With that, the whole re-hydrate machinery is gone: useAccountRehydrate, the
reactive active-account store, the markDirty guards, the pin-deferred saved-view
seed and the present-dimension bookkeeping of the shared Channels/Plex links.
PrefsProvider, FeedFiltersProvider, usePref and App's panel layouts seed from the
me cache instead of adopting it in an effect; the theme is applied in layout
effects. The app chunk is fetched in parallel with the probe, and the boot screen
only fades its label in after 400 ms, on one clock across the gate and the
Suspense fallback.

Module switches no longer flash a placeholder either. Measured frame by frame
in Chromium and WebKit, every first visit to a module showed "Loading..." for
17-70 ms: the lazy chunk's Suspense fallback, then often the first fetch's
LoadingState. Pages are now lazyWithPreload components, and App fetches the
chunks of every module the account can reach (plus the channel page) once the
first page is idle; a preloaded page renders synchronously and never suspends.
LoadingState, the one shared loading visual, fades in only after 300 ms, so a
fast load shows nothing in between and a slow one still gets its spinner.
2026-09-14 22:29:33 +02:00
peter f3c0b7be23 feat(e24): the Home surface — one question, one answer, both sources
E24 S2. A new `home` module in layout C+ (the mockup the owner picked): the
time-budget chips on top, one large hero that answers them, then the comfort
shelf and a cross-source resume rail. Home is the landing — deliberately OVER
the remembered last module, since landing on whatever you happened to close the
app on is the habit this epic exists to replace; `LS.startOnHome = "0"` opts an
account back out (Settings toggle lands in S4).

No third card component: each unified card carries the native feed / Plex
payload, so the rails render through the existing VideoCard (`cardsSmall`) and
PlexPosterCard, and `aspect` decides the tile shape without branching on source.
Plex playback hands off to the Plex module via a new `jumpTo` type — the player
lives there with its lazy hls.js and its provider tree, and mounting a second one
on the landing page to save one hand-off would be the expensive way round.

Four defects found by driving it in a real browser against the live account, each
now pinned by a mutation-checked test:

- **The reroll gave the same card back.** A new seed only re-draws the SURPRISE
  tier; the resume tier deterministically returns the most recently progressed
  thing that fits, so anyone with something half-finished pressed a dead button.
  `/pick` now takes an `exclude` list of `<source>:<id>` keys and the client keeps
  a rolling window of 8. Excluding a SHOW also excludes the episode it resolves
  to — otherwise the same suggestion comes back wearing a hat.
- **"Nothing to suggest yet" flashed over a full library** whenever a reroll was
  in flight: the new query key has no data for a beat. An empty state standing in
  for a loading one is the dishonesty the house rules name outright. The hero now
  falls back to the loaded payload while a pick is pending, and only trusts a null
  once the pick has RESOLVED.
- **A budget change replaced the page — chips included — with a spinner**, so the
  control you just clicked vanished under your cursor. `keepPreviousData`, the
  same reason the feed uses it.
- **The hero printed twice**, once as itself and once as the first resume tile.

Two smaller UI calls made from the same session: the budget caption sits NEXT to
its chips rather than in SegmentedToggle's justify-between mode, which on an 80rem
page throws the answers to the far edge; and the feed takes the YouTube mark its
own toolbar already uses, since Home now owns the house icon.

Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 780,
pytest 989. Verified end to end on localdev against the real account: budget
chips re-answer (matched on REMAINING time — a 51-minute video with 2:46 left is
a ten-minute answer), the reroll walks three distinct heroes, and a Plex show
tile opens its drill-down in the Plex module.
2026-09-06 04:14:14 +02:00
peter e17fb5bbf5 feat(playlists): total playtime in header + sticky-header fade fix (E9 S1)
Surface the playlist's total playtime beside the item count in the detail
header (12D) — sums every item's duration (formatRuntime "h m", matching the
approved mockup), hidden when nothing sums. Data is already client-side; no
backend change.

Pass fadeTop:false for the playlists page (12B) so the shell's top edge-fade
no longer sits over the already-sticky column header (tableHeader.tsx:257),
matching the channels/audit pages.
2026-09-02 03:39:17 +02:00
peter e90603d4db fix(feed): deep-review remediations for the filter-bar overhaul
Correctness / behaviour:
- Move the Personal Views modal from inside the collapsible filter rail to the
  feed page (moduleRegistry FeedPage), so opening it from the always-visible
  toolbar view chip works while the rail is unpinned (its default) instead of
  no-op'ing and latching modalOpen into a later stale pop-open.
- Migrate a persisted legacy global `tagMode` into per-category `tagModes` on the
  localStorage seed paths too (loadAccountFilters), matching the saved-view and
  URL paths — an upgrading user's topic any/all no longer silently reverts to 'or'.
- DateFilterSelect: reset the custom reveal on an external clear while the popover
  is closed (guard on !open, not maxAgeDays), so the trigger reverts to "Any time"
  instead of lingering on "Custom range" with empty fields.
- PersonalViewsModal delete: only advance the detail selection when the delete
  actually happened (remove() now resolves a boolean) — cancelling the confirm no
  longer switches the selected view.
- SavedViewsProvider: prefer the explicitly-applied (sticky) view among several
  that capture identical filters, so apply(B) isn't re-attributed to an earlier
  duplicate A (wrong "modified" chip name).

A11y:
- Give the clickable panel title a descriptive accessible name (titleActionLabel,
  wired to "Personal views") instead of announcing only "Filters".
- Give the "+N" overflow pill an aria-label ("N more filters").
- Use t("views.reorder") for the saved-view drag handle instead of a hardcoded
  English "reorder" literal.

Also extract the chip-fit decision into a pure, testable lib/chipOverflow.ts and
measure the inter-chip gap live (rem-safe) instead of a frozen 6px constant.
2026-08-22 06:33:48 +02:00
peter 1801a58f6b feat(layout): centre the header on each page's content width, centre the playlist table
Every page's floating header (search bar + sync chip) and the toolbar band now centre on the SAME
max-width the content column uses, instead of spanning the whole column while a max-w page centres
its grid. So the search bar lines up with the left edge of the cards/table on every page, and sits
centred with the content — not against the full page width.

- PageShell takes a `maxWidth`, published as --content-mw; the header adds max-w-[--content-mw] mx-auto
  inside its left-4 / right-(1rem+scrollbar) span, so it centres on that width (`none` = full width).
- moduleRegistry PAGE_CONTENT carries each page's content max width (plex 1600px, channels 96rem,
  playlists 64rem, downloads 56rem, notifications/users 48rem; feed stays full width) — one source.
- Playlists: its content (header, filter row, table) was left-aligned max-w-5xl → now mx-auto, so the
  table is centred and the header lines up with it.

Verified at 1920px: header left edge == content left edge — feed 340, channels 213, playlists 487.
2026-08-21 21:38:49 +02:00
peter a42e0a9e3e feat(app): the module registry drives the render (S6b DoD)
Replace App's page-render ternary and the three side-rail page conditionals with
a data-driven lookup: components/moduleRegistry maps each page to a prop-free
content component (+ optional side rail + shell fadeTop), and App renders
PAGE_CONTENT[page]/PAGE_RAIL[page] gated by moduleDef(page).gate (an unreachable
gated page falls back to the feed, as the old ternary did). Content modules and
the two rails are now prop-free: they read `me` via the new useMe() hook and
their module-local state from the scoped providers; FeedPage/MessagesPage are the
only wrappers (they wire the global feed scope / meId). App.tsx 796->431 lines,
zero `page===X` chrome conditionals — closes the Platform epic DoD. (Platform S6b.3)
2026-07-19 01:25:32 +02:00