Commit Graph
79 Commits
Author SHA1 Message Date
peter 943143ef45 perf(feed): stop the card-action + player-close feed refetch storm (E2 S5, PERF-2)
Marking a card watched/hidden/reset and closing the player each fired a full
`invalidateQueries(qk.feed())`, refetching EVERY loaded infinite-query page — 10
pages x a few quick marks = ~50 feed refetches, each a full scan (x PERF-1). The
optimistic `overrides` layer + the matchesView filter already render the correct
state (a hidden/watched card drops out instantly), so the refetch was pure waste.

Switch these paths to `refetchType: "none"`: the queries are marked stale but not
refetched now; the next filter change / remount pulls fresh server data. Applies
to the two Feed card-action paths (setState/clearState) and the PlayerModal
close (feed + playlists — progress is already persisted server-side, only the
on-screen resume bar waits for the next natural refetch).

Left as immediate refetch on purpose: channel/tag/playlist mutations (one-off, on
pages where the main feed is unmounted anyway) and the onboarding / notification-
undo paths (which must reveal the changed content right away). Gate green:
frontend tsc/eslint/knip/prettier/222 vitest (backend unchanged, 344 pytest).
2026-08-18 15:49:49 +02:00
peter 21a3aae700 perf(feed): collapse count + facets into one /feed/summary pass (E2 S4, PERF-13)
The FE fired /feed/count (1 pass) + /facets (3 passes) on every filter change =
4 full scans of the subscribed set. New /api/feed/summary does ONE grouped query
(the base filter with tags stripped, GROUP BY channel_id -> per-channel video
counts) and derives the total count + every tag's distinct-channel facet count in
Python on the ~few-hundred-row channel map, faithfully mirroring filtered_query's
channel-based tag logic + the /facets drill-down (own category dropped, unless a
conjunctive topic-AND). Returns {count, facets} — the same numbers the two old
endpoints returned.

FE: a single shared query (Feed reads .count, Sidebar reads .facets); same key +
filters, so React Query dedupes to ONE request. Invalidations repointed to
qk.feedSummary. The old /feed/count + /facets endpoints (and their now-unused FE
api/qk) are kept as the equivalence-test oracle + safety net; a later cleanup can
drop them.

New equivalence test (test_feed_views, 8 filter combos incl. cross-category,
disjunctive/conjunctive topic modes, search): /feed/summary count == /feed/count
and facets == /facets, byte-identical. Structural win: 2 requests / 4-6 queries
-> 1 request / 2-3 queries (the review measured 530 -> 144 ms). Gate green:
344 pytest, frontend tsc/eslint/knip/prettier/222 vitest.
2026-08-18 05:01:27 +02:00
peter 579af9cf89 refactor(ui): shared FilterChipGroup; unify feed filter chips (R13 S2c step 2)
New single-select FilterChipGroup primitive on the canonical rounded-full
"Filters bar" chip look (pill + shadow-xs + press feedback), plus an exported
filterChipClass so multi-select siblings share ONE geometry source of truth.

Migrations:
- feed watch-state chips (F1, Feed.tsx) -> FilterChipGroup, testIds preserved
- feed content-type toggles restyled to filterChipClass (stay multi-select) so
  the whole toolbar row is one chip look; wrapped in their own labelled group
- feed date presets + Custom (F3, Sidebar.tsx) -> FilterChipGroup with a
  behaviour-preserving dispatch (preset toggle-off + Custom editor intact)

Adds aria-label i18n (feed.showLabel/contentLabel, sidebar.datePresetLabel;
HU+EN) and FilterChipGroup.dom.test.tsx (6 tests). Self-E2E'd: watch switch,
date preset on/off toggle, Custom opens the range editor. gate green (vitest
136, tsc/eslint-0/knip/prettier). Branch not merged.
2026-08-15 00:35:22 +02:00
peter 440bdafa74 refactor(feed): drive the feed sort toolbar through the shared SortControl (R13 S1)
The feed's bespoke select + asc/desc button is now the generic SortControl,
fed a SortOption[] built from the feed vocabulary (relevance offered only with
a query; shuffle/relevance directionless). The feedSort wire codec
(parseSort/buildSort) stays as the server-boundary translation, and the
reshuffle button rides SortControl's `trailing` slot so shuffle keeps its
re-seed affordance. Behaviour-equivalent; ArrowUp/ArrowDown imports dropped.
2026-08-13 23:28:47 +02:00
peter 76f8de258c refactor(ui): R10 — .btn-primary recipe for the primary/action buttons
Add a paint-only .btn-primary recipe (solid accent bg + accent-fg text +
hover:opacity dim) in index.css @layer components, alongside the selection-paint
recipes (.pill/.chip/.is-current). Migrate ~38 primary/action buttons across 24
files off the hand-rolled 'bg-accent text-accent-fg hover:opacity-90', keeping
each site's geometry (padding/radius/font/gap/disabled/shadow).

Deliberately NOT migrated: accent BADGES/labels (spans), the chat message bubble,
selection toggles (tableHeader — R8 popover territory), and the round play FAB
(hover:scale, not the opacity-dim primary idiom). Three previously hover-less
primary buttons (SettingsPanel/SetupWizard/DraftSaveBar) gain the standard hover
dim for consistency.

Gate green (tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed a migrated
button computes to accent/accent-fg and both recipe rules load.
2026-08-11 04:04:06 +02:00
peter dcb91066f5 refactor(ui): R10 S4 — unify selected-paint into .pill/.is-current/.chip recipes
The ~14 hand-rolled "this is the selected/current/active one" idioms each
repeated the same `sel ? "bg-accent text-accent-fg border-accent" : "…"` inline
ternary, which had drifted. Three attribute-driven recipes in @layer components
now own that paint, keyed on the ARIA the control already carries: .pill
(aria-pressed segments/toggles), .is-current (aria-current nav/list items), and
.chip / .chip-solid (tag & filter tokens). The JSX just toggles the ARIA; the CSS
paints, so one control's selected look can't diverge from the next's. No
focus-visible in the recipes — the @layer base rule already rings every button/a.

Migrated 15 sites (Feed show/content, Channels tag filter, Sidebar or-and +
scope, PlexSidebar match, Tabs, SettingsPanel tab-rail, PlexPlaylistView layout
[soft], NavSidebar nav + settings, ActiveFilterChips, PlexSidebar RemovableChip,
ChannelTags, ChannelPicker). Deliberately left: PlexSidebar facet Chip and
PlexPlaylistView already covered; SavedViews row (paint split across two
elements), the scheme swatch (border-only on a coloured swatch), and
ChannelPicker's option rows (text-only selection) — noted for a follow-up.
2026-08-09 21:06:58 +02:00
peter 0559e1a503 refactor(input): R10 S3b — one input recipe, ring focus everywhere
Unify text inputs onto the form.tsx inputCls look (surface bg, rounded-lg, 2px accent
RING focus), replacing the settings-family border-focus style app-wide:
- deleted 3 duplicate local `const inputCls` (KeyGate/SettingsPanel/SetupWizard) → import
  the shared one; a 4th (Welcome, py-2.5) kept local but re-styled to match.
- ~30 inline settings inputs: default full-size fields → inputCls; size variants (pager/
  table-filter/chat textarea/plex/glass-card) keep their width/radius/padding but swap
  bg-card→bg-surface and border-focus→ring-focus. glass-card/bg-transparent bgs left as-is.
- refreshed the form.tsx doc-comment (the old two-style split is gone).

The ring is the more visible / a11y-correct focus cue, matching R9's focus foundation.
2026-08-09 17:43:42 +02:00
peter 70bda98868 build(deps): migrate Tailwind CSS v3.4 → v4
Root-fixes the opacity-modifier seed bug behind R10 S0: the theme colors were
bare `var(--x)` hex with no <alpha-value>, so `bg-accent/40`, `ring-accent/40`,
`border-border/60`, `text-muted/70`, … (~99 sites across 45 files) emitted no
CSS rule at all — latent no-ops. v4 implements the opacity modifier via
`color-mix`, so the modifier now works with the existing hex vars, no per-scheme
token duplication needed.

- `npx @tailwindcss/upgrade`: `@tailwind` directives → `@import "tailwindcss"`,
  JS config → CSS `@theme` (colors, --font-sans, the z-index tokens preserved),
  classname renames (rounded/shadow/blur scale, outline-none→outline-hidden,
  ring→ring-2) across 56 files; PostCSS plugin → @tailwindcss/postcss,
  autoprefixer removed (v4 prefixes for you).
- `@theme` → `@theme inline` (load-bearing): the color vars are redefined at
  runtime under html[data-scheme]/[data-theme]; inline resolves them at the use
  site so per-scheme opacity tints track the active scheme (the shadcn v4 pattern).
- Revert one codemod false-positive: it rewrote the English word "rounded" →
  "rounded-sm" inside a releaseNotes.ts prose sentence.

Gate green: tsc(app+node) / eslint 0-err / prettier / vitest 79 / knip.
Verified in-browser: bg-accent/15 now computes a scheme-tracking color at 0.15
alpha across all 6 schemes × 2 themes; the hand-rolled glass border/shadow/
radius/backdrop-filter render unchanged under v4.
2026-08-08 00:50:05 +02:00
peter 5de6be8fde refactor(api): literal-union the closed-value string fields (R7 S3a·1)
Video.status, Playlist.kind/source (+ PlaylistDetail), DownloadJob.job_kind
were bare `string`; type them as named unions verified against the backend
(VALID_STATES={new,watched,hidden}; kind user|watch_later; source local|
youtube; job_kind download|edit). VideoStatus then propagates through the
optimistic-override map, onState, and the VideoCard/VirtualFeed/PlayerModal
props — tsc now rejects a status typo (e.g. a stale "in_progress", which is a
derived filter, never a stored status) at every call site.
2026-07-27 22:40:11 +02:00
peter ad86d6d126 refactor(fe): query-key factory + migrate feed/channels/playlists (R7 S2a)
Introduce lib/queryKeys.ts as the single source of truth for React Query
keys — the R7 driver was ~200 hand-written key literals across 51 files
(`["feed"]` alone in 15+), where one typo silently orphans a key so an
invalidate never matches.

S2a migrates the three highest-traffic domains (feed/video, channels,
playlists) plus the shared me/my-status/tags roots: ~90 literal sites in
18 files now call qk.*(). Pure substitution — each factory fn returns the
byte-identical array the call sites used before, so TanStack's prefix
matching is unchanged (qk.feed() still invalidates qk.feed(filters)).
Co-invalidation clusters are deliberately NOT bundled into helpers (they
differ per site), so no query's refetch scope changes.

Nullable keys (playlist(selectedId), ytSearch(q)) accept null so a null
segment is preserved (["playlist", null]) while a no-arg call is the bare
prefix key — guard is `!== undefined`.

Gate green: typecheck, eslint (0 err), prettier, 68 vitest. Smoke: app
boots, feed/facets/tags/my-status all 200, no console errors. Factory
extended to plex/messaging/admin/downloads/notifications in S2b.
2026-07-27 21:48:35 +02:00
peter fc82c8b47a fix(r5): address the fourth /code-review high round — 6 findings
- Feed memoizes onClose (like Playlists already did): the inline closure re-ran
  PlayerModal's keydown effect on every Feed render, which since the debounce
  landed also flushed the volume write and re-stole focus
- the player reads its volume with readAccountMerged again — a partial stored
  object made the level undefined -> NaN -> a persisted null; applyVolume also
  refuses a non-finite level outright
- that read moved into a lazy useState initializer (was running per render)
- the two RSS except branches share one tail, so a counter can't drift again
- run_ffmpeg tests cover the SIGTERM->SIGKILL escalation and the lingering-
  process path; dropped an assertion that only restated the fixture
- storage.ts documents the actual { volume } shape (0 IS muted)
2026-07-24 00:45:42 +02:00
peter b5573b62d1 feat(ui): shared LoadingState — a centered accent spinner instead of plain loading text
Replaces the plain "Loading…" text in every R3 loading branch with a single shared LoadingState
(a spinning lucide Loader2 in the accent colour + label, centered, role=status/aria-live) — one
component so all loads read the same and can be swapped for skeletons later in one place. Applied
across Feed, Channels, ChannelDiscovery, AuditLog, NotificationsPanel, Messages, Scheduler, Stats,
PlexBrowse (grid + subviews), PlexPlaylistView, ConfigPanel, DownloadCenter, AdminUsers, ChatThread.
Left the small "load more" footer and lazy Suspense fallbacks as-is.
2026-07-22 04:35:02 +02:00
peter a393fd2fb1 fix(ui): R3 S1 review fixes — don't blank data on transient refetch errors
- The honest-state branch now guards on `isError && !data`, so a background/window-focus refetch
  failure keeps already-visible content instead of replacing the list with a full error screen
  (regression the bare `isError` introduced). Applied to AuditLog, Channels, ChannelDiscovery,
  PlexBrowse, PlexPlaylistView, Messages(people), Feed — matching the guard already used in
  Messages(conversations)/NotificationsPanel/Scheduler.
- Drop the unused DataTable loading/error/onRetry props (every table page uses the outer
  StateMessage branch, not the props) — reverts DataTable to its simple empty block.
- StateMessage announces errors with role=alert/aria-live=assertive (was polite for all tones).
- Remove the now-unused common.empty string (en+hu), left over from the removed QueryState render-prop.
2026-07-22 04:14:46 +02:00
peter d9a08892a5 feat(ui): R3 S1 — honest error states across the pages
The error state didn't exist as a concept: a failed main query rendered as an "empty" list
("No channels" while the request actually died), so a backend restart read as "everything is
empty". Add the convention that an empty state may NEVER show while a query is erroring.

- New components/QueryState.tsx: shared StateMessage (loading / error+Retry, house style, with
  role=status/aria-live — the app had zero live regions).
- DataTable gains loading/error/onRetry props (suppresses empty-text on error).
- isError branch (before the empty branch) added across the false-empty pages: Feed (retry),
  Channels, ChannelDiscovery, AuditLog, NotificationsPanel, Messages (conversations + people),
  Scheduler, Stats, PlexBrowse grid, PlexPlaylistView. ChannelPage's main content is Feed, now
  covered. common.loadError/empty strings (en+hu).
2026-07-22 03:58:51 +02:00
peter 58b9691467 i18n(a11y): translate the modal + Welcome close buttons and the feed fallback (C-3.22)
House policy is HU+EN for every user-facing string. Modal's shared close button was a
hardcoded title="Close" (in every modal) — now t("common.close") + an aria-label.
Welcome's lightbox close aria-label and Feed's "this video" notification fallback are
translated too (new feed.thisVideo, HU+EN). The other Close hardcodes the review
listed were already fixed since.
2026-07-21 23:13:53 +02:00
peter e006cc879f refactor(frontend): satisfy noUncheckedIndexedAccess (S3b)
Enable noUncheckedIndexedAccess and fix all 98 call sites it surfaced across 18
files. Every fix is behaviour-preserving — the flag flagged reads TypeScript
couldn't prove in-bounds, all of which were already guarded by a length/index check,
a findIndex result, or a non-empty invariant:

- read-once so a ternary's narrowing sticks (Feed overrides, linkify mention);
- non-null assertion AFTER an existing guard (PlayerModal/VideoEditor queue+cut-list
  indexing, modules step, useUndoable stacks) with a comment stating the invariant;
- nullish fallback where undefined is a real possibility (GlassTuner slider ?? def,
  ConfigPanel active group ?? [], descriptionLinks id ?? null);
- optional chaining where the entry genuinely can be absent (PlexBrowse IO entry);
- a non-empty tuple type for the RELATIVE_UNITS constant (encodes "always has [0]").

The single highest-leverage fix: PlayerModal's `active` became `queue?.[index] ??
video` (always Video), clearing 34 of the 45 errors in that file at once. Verified:
tsc/eslint/prettier clean, 56 vitest + 34 pytest + 17/17 e2e green (the e2e suite
exercises the feed/player/channel components touched here).
2026-07-21 04:11:19 +02:00
peter e257e2aca0 style(frontend): Prettier config + one-time repo-wide format pass
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing
hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass
(`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a
feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest
all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
2026-07-21 02:26:40 +02:00
peter dd921e3209 refactor(wizard): move onboarding wizard state into WizardProvider
Lift the onboarding wizard's open state, first-login auto-open effect, and the
wizard render itself out of App into WizardProvider. Feed, Channels, and
SettingsPanel trigger it via useWizard() instead of an onOpenWizard prop; App
and ChannelPage drop the prop plumbing. First step of S6b.3 — removing the
per-module props that block a uniform registry render. (Platform S6b.3)
2026-07-19 00:55:34 +02:00
peter 7d7e2c6146 test(e2e): lock the channel content-filter inherit + no-leak (bug 2)
Add feed-content-<key> testids to the toolbar's content pills and two channel
specs: the channel view inherits the feed's Live/Upcoming both ways, and a
content toggle made in the channel view does not leak back into the feed.
2026-07-18 18:27:17 +02:00
peter 02bd0d433b polish(channel): tighten toolbar-to-grid gap + restore top scroll fade
Two UAT nits on the channel page:
- the chip-row-to-cards gap was ~36px (toolbar pb-3 + wrapper pb-2 + grid
  pt-3 stacking); drop the wrapper/grid pads so the toolbar's own pb-3 is
  the only gap (~15px).
- restore the page scroller's TOP edge fade. It was dropped when the chrome
  was a sticky sub-header (the fade bit into it); now the chrome is a fixed
  band ABOVE the scroller, so the top fade correctly hints at cards
  scrolling up under the toolbar. Reverts the useScrollFade edges option.

Suite 15/15, tsc + knip clean.
2026-07-18 05:01:31 +02:00
peter ec807c8b31 feat(shell): channel chrome is fully fixed, only the grid scrolls
Reworks the channel detail per UAT: instead of the banner scrolling away
and a sticky sub-header (which felt odd to scroll and needed an opaque bar
that clashed with the design even at rest), the ENTIRE chrome — banner,
identity, tabs, toolbar — is fixed and only the video grid scrolls.

Mechanism: ChannelPage wraps its whole chrome in <PageToolbar> so it
portals into the shell's fixed band (like the feed toolbar); the
channelScoped Feed portals its toolbar into a slot at the bottom of that
chrome. No sticky, no opaque bar — the chrome is transparent over the page
bg because nothing scrolls under it (the band sits above the scroller).

E2E updated: the channel chrome (title + toolbar) stays at a fixed
viewport position while the grid scrolls. Suite 15/15, tsc clean.
2026-07-18 04:53:49 +02:00
peter 0e03e561eb feat(shell): sticky tabs+toolbar on the channel page
Generalise the fixed-chrome pattern to the channel detail page: the
banner + identity now scroll away, while the tab row and the channel
feed's toolbar stick to the top as a sticky sub-header — only the video
grid scrolls.

Mechanism: PageToolbarSlot (the toolbar portal target) is now an exported,
overridable context. ChannelPage provides its own sticky sub-header node
as the target, so the channelScoped Feed portals its toolbar up next to
the tabs instead of into the shell's top band. Feed always portals its
toolbar now (the slot decides where it lands).

Also: the page scroller now fades only its BOTTOM edge — the top is
bounded by the fixed/sticky chrome, so a top fade only bit into it.

E2E: adds a lock that the channel tabs/toolbar stay pinned while the
banner scrolls away; suite 15/15 on :5173, tsc + knip clean.
2026-07-18 04:45:11 +02:00
peter a6cbc45334 fix(feed): pin the feed toolbar in the shell's fixed band (bug 4)
The feed's show/content chips, source, count, sort and view switcher —
plus the active-filter chips — lived inside the scroller, so they
scrolled away with the cards; only the SearchBar strip stayed fixed.

Give PageShell a fixed toolbar band (filled by a module via the new
PageToolbar portal) above the scroller. On the feed page the toolbar +
chips portal into it and stay put — and removable — while cards scroll.
The channel-embedded Feed (channelScoped) keeps its inline toolbar,
which rides with the banner/tabs as before.

Verified both entry points at desktop + 1000px (toolbar wraps in-flow,
band grows, still pinned); E2E 11/11; tsc + knip clean.
2026-07-18 03:25:48 +02:00
peter a1bdfece6d refactor(nav): read navigation from context, drop the prop-drilling
VideoCard/PlayerModal read openChannel from useNavigationActions (the memo'd
VideoCard subscribes to the STABLE actions context, so a page/channel change
no longer re-renders it); NavSidebar/Header read page/setPage; ChannelPage
reads closeChannel; NotificationsPanel reads setPage. App sheds the matching
props, and Feed/VirtualFeed no longer thread onOpenChannel at all.
2026-07-18 02:19:52 +02:00
peter e7091158fd test(e2e): golden-master Playwright net for the Platform refactor
Locks the five flows the refactor must not break (feed, channel, player,
settings, nav) so later sprints can prove they changed nothing. Adds a thin
data-testid layer and a dedicated seeded non-demo account; the suite runs on
the baked :8080 stack (authoritative) or :5173. No product-behaviour change.
2026-07-18 00:35:57 +02:00
peter f702526cef refactor(feed): let the caller decide where the mode's name fits
The xl breakpoint was a fact about the FEED's toolbar, hard-coded into
the switcher that E4's managers are meant to reuse — their rows are far
emptier and would have hidden the label for no reason. It's a prop now,
defaulting to icon-only.

The two views spans collapse into one builder, which also fixes a
regression I'd just added: splitting them put the exact-count tooltip on
the number alone, so hovering the word gave nothing.
2026-07-17 02:02:35 +02:00
peter 78f21eb46b feat(feed): add the small-card, compact-row and tile views
Tiles answer the row view's wasted space: the actions move under the meta
into vertical slack the thumbnail already reserves, which frees ~168px of
width, so the block survives in a narrow column and the list can go
multi-column. Rows stay single-column on purpose — reading down one edge
is why that view exists.

The column count now comes from a per-view minimum width, so every
multi-column view reflows on the mechanism the card grid already used.
2026-07-16 03:17:30 +02:00
peter eae65456ac feat(feed): pick the feed's view from its own toolbar
The view mode moves out of Settings into a switcher next to sort, on the
feed and channel pages alike. It leaves the Settings prefs draft for its
own eager save: a toolbar click must not mark that page dirty and trip
its leave-page guard.

lib/feedView.ts owns the vocabulary and maps the old grid/list prefs
forward on read, so nobody's saved choice is lost.
2026-07-16 02:29:32 +02:00
peter c440a043f2 fix(feed): no active-filter chips on channel pages
A channel page renders the Feed from its own baseline — channel-scoped,
whole-catalog, show-all, live included — so measuring it against the feed's
defaults labelled all five as "filters you applied" when they are just the page
being itself. It also has no filter sidebar (App hides it), and revealing what
the collapsed panel hides is the chip row's only job.
2026-07-15 22:30:07 +02:00
peter 644d65a3fd feat(feed): removable active-filter chips above the feed
With the filter panel now collapsed to a tab, there was no way to see what was
narrowing the feed without opening it. Every applied filter is now a chip you
can drop on its own, with a Clear all when more than one is on.

The panel's count badge and the chip row read the SAME list (lib/useActiveFilters),
so they can't disagree about what's applied; that list also owns the reset both
"Clear all"s share. Replaces the hand-summed activeCount in Sidebar.
2026-07-15 22:11:44 +02:00
peter 837b48cbfa refactor(feed): lift the sort vocabulary out of Feed into lib/feedSort
The active-filter chips need to label a sort, and parseSort/SORT_MAP were
module-private in Feed. Pure move, no behaviour change.
2026-07-15 22:03:40 +02:00
peter 763d8533f2 fix(channel): hide the Source (search-results) filter on the channel-scoped feed
The Source selector (organic / include-search / search-only) is a global-catalog
concept — it picks how videos ENTERED the library. On a single channel's page the
view is pinned to librarySource=all and would only offer a near-empty, confusing
slice, so hide the selector there (the main feed keeps it).
2026-07-12 16:36:32 +02:00
peter 83f5b87424 fix(feed): repair 2 bugs found in the Phase 2 #2 review
BUG-1 (search.py, high): the scrape search source logged one VIDEOS_SEARCH
quota event PER continuation page inside the paging loop, but actions_today
counts events — so a single user search that pages N times consumed N against
search_daily_limit_per_user (its docstring even warns it only works for
once-per-action logging). Log exactly once per request after the loop instead;
the API source already logs once via record_usage (it never auto-pages).

BUG-2 (Feed.tsx, medium): the optimistic-override reset effect keyed on
query.dataUpdatedAt also fired on fetchNextPage (infinite scroll bumps
dataUpdatedAt while page 1 is unchanged), so a just-hidden card flashed back
mid-scroll. Guard with a page-count ref: only reset on a real refetch, not an
append. The two override-reset effects legitimately differ now (resolves the
would-be C-F1 "identical effects" cleanup).

tsc green, ruff clean, localdev boots healthy.
2026-07-11 17:43:18 +02:00
peter bafaa5192d chore(feed-ui): Phase 2 #2 frontend cleanup — hoist, drop wrapper, dedup caches
- Hoist withOverrides(loaded) into `merged` (was mapped twice per render for
  items and playerQueue).
- Remove the needless always-true {(<>…</>)} expression wrapper around the
  Source selector.
- Extract dropFeedCaches() — the feed/feed-count/facets removeQueries trio was
  duplicated across the back and clear-now handlers.

Behavior-neutral. tsc green.
2026-07-11 17:37:52 +02:00
peter b5af67af4e feat(player): persistent auto-advance + loop playback modes
Two saved-to-account playback settings on the player's prev/next bar (any queued
player — feed or playlist): Auto-advance (Off/Next/Prev/Random — what plays when a
video ends) and Loop (Off / One = repeat the current video / All = wrap the list
at its ends; a single-item list repeats). Stored in users.preferences
(playerAutoAdvance/playerLoop), read from the cached me + written via savePrefs so
they apply everywhere and survive reloads.

The feed's player queue is now the live filtered feed with the watch-state filter
NOT applied — marking the current video watched keeps it in the sequence (no
reindex/reload mid-play), while a hidden video still drops out. Removes the old
frozen-queue workaround and the boolean autoAdvance prop. i18n en/hu/de.
2026-07-05 00:20:50 +02:00
peter 7284f89910 feat(player): step through the feed, reachable native controls, hover-intent
Three in-app player refinements:
- Prev/next stepping: the modal now takes the feed's loaded order as a queue, with
  faint arrow zones flanking the card and Shift+Left/Right shortcuts (plain arrows
  seek ±5s). The queue is frozen at open so marking the current video watched can't
  drop it and reload a different one; auto-advance-on-end stays off for the feed
  (it isn't a playlist) — playlists keep theirs.
- Reachable native YouTube controls: the interaction overlay (wheel volume / click
  pause / keyboard focus) now covers only the centre band (top-[12%] bottom-[22%]),
  leaving the top-right cluster (volume/CC/settings) and the bottom bar (seek /
  More videos / fullscreen) clickable. Verified live: settings menu + fullscreen work.
- Hover-intent description: the title popover now waits ~400ms so a quick pass no
  longer flashes it.
2026-07-04 22:25:37 +02:00
peter 0c428abe9f perf: route- and modal-level code splitting (React.lazy)
The whole app shipped in one bundle, so the logged-out landing and every page
pulled all module code. Split into lazy chunks:
- main.tsx: lazy App + the public leaves (WatchPage, Privacy, Terms), so a
  public /watch share link never downloads the authenticated app.
- App.tsx: each module page (Feed, Channels, Playlists, Stats, Scheduler,
  Config, Users, Settings, Notifications, Messages, Downloads, ChannelPage) and
  the About/ReleaseNotes/Onboarding modals load on demand behind <Suspense>.
- Heavy modals lazy in their parents: PlayerModal (Feed, Playlists),
  DownloadDialog (DownloadButton — kept out of the feed chunk), and the
  VideoEditor/ShareDialog/ProfileEditor (DownloadCenter).
- Extracted focusAccessRequestsTab + the tab constants to lib/adminUsersTab so
  callers can pre-select the admin tab without statically importing the now
  lazy-loaded AdminUsers page (which would defeat the split).

Build now emits ~25 chunks. Landing no longer downloads any module code
(~350 KB deferred); dev landing Perf 93->95. Verified in a real browser: every
page + the video editor load with no console errors.
2026-07-04 19:43:50 +02:00
peter 1dd74fcda9 fix(a11y): accessible names for switches, swatches, selects and inputs
Lighthouse a11y across the authenticated module pages flagged: the clickable
Siftlode logo had aria-label='Feed' (accessible name didn't include its visible
text); the shared Switch, theme swatches, sort selects and several number/range
inputs had no accessible name/label. Give Switch role='switch'+aria-checked and
an optional label (passed at every call site), aria-label the swatches, selects
(feed/playlists sort) and the settings/config/scheduler inputs, and drop the
mismatched logo aria-label so its visible text is the name. All 11 module pages
now score 100 accessibility (settings 86->100, playlists 89->100, others 92-95->100).
2026-07-04 19:13:11 +02:00
peter 036491b88b feat(search): keep scrape-sourced YouTube results across a reload
A live YouTube search was always dropped on reload so it could never
re-spend quota. But the default scrape source costs no quota, so there's
no reason to bounce the user back to the feed — they can stay on their
results.

Feed now stamps history.state._ytScrape=true once a search resolves via
the scrape source; App restores _yt on reload only when that flag is set
(and re-runs the search, which is free). An api-source search is left
unmarked and still drops to the feed on reload, since re-fetching it would
cost ~100 units. The flag is cleared when a new search starts (Feed
re-stamps it once the new results' source is known).
2026-07-01 01:55:05 +02:00
peter ebfe462967 fix(search): keep Load more; the count selector sets the batch size
Clarified per intent: the results-count selector controls how many results each fetch gathers,
and Load more pulls another batch of that size (restored). The infinite query already threads the
count into every page, so Load more honours the selected size. Added a tooltip on the selector.
2026-07-01 01:17:04 +02:00
peter 18a6cdff6c feat(search): ephemeral results UX, count selector, channel blocklist (frontend)
- Live-search view: a results-count selector (20/40/60/100) replaces manual load-more (the free
  scrape source pages until that many are gathered); an 'these results are temporary' banner with
  a 'Clear now' button that discards them 'as if never added' (api.clearSearch) and returns to
  the feed.
- Channel blocklist: a Block/Unblock toggle + 'Blocked' badge on the channel page (blocked
  channels don't auto-explore and their videos are hidden), and a 'Blocked channels' section in
  the Channel manager with one-click unblock. ChannelDetail.blocked from the backend.
- Admin: a 'Purge discovery' button on the Scheduler page (immediate un-kept search/explore
  cleanup). EN/HU/DE throughout.
2026-07-01 01:00:32 +02:00
peter 607bef1400 polish(feed): one-row toolbar — count after Source, sort right-aligned
Collapse the feed toolbar's two rows into one: the video count sits right after the Source
dropdown, and the sort controls move onto the same row, pushed right with ml-auto (wraps
gracefully on narrow widths). Applies everywhere the Feed renders (main feed + channel page).
2026-06-30 04:56:08 +02:00
peter fb74a31dd3 fix(channel): drop channel-constant sort options on the channel page
Subscribers and Channel-priority sorts are meaningless when the feed is scoped to one channel
(both are constant across its videos), so hide them there via a channelScoped flag on Feed.
The main feed keeps all sorts.
2026-06-30 04:37:00 +02:00
peter 9810e43cf7 fix(player): open the channel page from the player without it bouncing back
Two bugs made clicking the channel name in the video modal just close the player:
- the second PlayerModal mount (main feed path) was missing onOpenChannel, so the handler
  hit its no-op early return;
- opening the channel synchronously pushed the _chan history entry, which the player's own
  useBackToClose teardown (history.back on unmount) then immediately popped. Now the open is
  deferred to a one-shot popstate listener that fires AFTER that teardown, so the channel
  entry lands at the feed level. Verified: player → channel name → channel page; Back → feed
  (player does not reappear).
2026-06-30 04:14:14 +02:00
peter 3db4aef653 feat(player): channel name opens the in-app channel page
The PlayerModal channel name now opens our channel page (closing the player first); a
small external-link icon beside it keeps the open-on-YouTube behaviour. Threaded
onOpenChannel from Feed into both PlayerModal mounts.
2026-06-30 03:12:44 +02:00
peter c8123dd9ae feat(channels): dedicated channel page + ephemeral explore UI
Frontend for the channel-explore feature:
- ChannelPage: banner/avatar/stats header, Subscribe/unsubscribe, an "exploring" badge
  while browsing an un-subscribed channel, Videos/About tabs. Reuses Feed scoped to the
  channel (scope=all + source=all so the per-user ephemeral videos show). Auto-ingests
  recent uploads on first visit (background, with a loading note) + "Load more from
  YouTube" to page deeper; skipped for demo / already-subscribed channels.
- App: openChannel/closeChannel as a Back-aware sub-view (history.state._chan, mirrors the
  YT-search _yt pattern); ChannelPage takes over the content column, nav rail stays.
- ChannelLink/cards/player: the channel name now opens our channel page (onChannelFilter →
  onOpenChannel); the in-card "only this channel" filter button is dropped (the page
  subsumes it). PlayerModal channel-name wiring follows in the next commit.
- api: channelDetail + exploreChannel; ChannelDetail/ExploreResult types.
- i18n EN/HU/DE: channel namespace, explore_cleanup scheduler labels, explore config group,
  channels_explore quota label.
2026-06-30 03:08:52 +02:00
peter 183e23055e fix(feed): reliably switch to relevance sort when a search starts
The relevance auto-select ran in a Feed effect that raced per-keystroke query
updates, so fast typing left the sort on the default. Move the switch into the
header input's onChange so it's set atomically with the query (only overriding
the default newest sort); the Feed effect now only reverts to newest when the
term is cleared. 'Back to feed' also sets relevance explicitly.
2026-06-30 01:36:12 +02:00
peter 12ee577660 feat(feed): Source filter in Mine, Relevance sort, auto search-source
Frontend for the Mine search finds + relevance search:
- The Source filter (organic / include search / search-only) now shows in Mine
  scope too, not just the Library.
- Returning from a YouTube search via 'Back to feed' switches the Source filter
  to 'search' so your just-found videos show in the feed you land on (filtered
  by the kept term).
- New 'Relevance' sort, offered while a search term is present and auto-selected
  when you start searching (reverts to newest when you clear it). EN/HU/DE.
2026-06-30 00:39:21 +02:00
peter 1eb580f6fa fix(search): make the YouTube-search view its own history entry
The live-search results view had no browser-history entry of its own, so a
player opened over the results sat directly on the feed page entry. Pressing
Back (e.g. the mouse back button over the player) could pop past both the
player and the search in one step, bouncing from the search results to the
normal feed instead of just closing the player.

The search is now a feed sub-view that owns a history entry (history.state._yt):
entering a search pushes it, the popstate handler derives ytSearch from it, and
"Back to feed" pops it. Back now steps player -> search -> feed: the first Back
closes only the player (results stay), the second returns to the normal feed.
A reload drops any stale _yt so the first Back can't resurrect a gone search.
2026-06-29 23:03:25 +02:00
peter dca339bd3d feat(search): drop quota wording when the live search source is scrape
The search response now reports its source; in scrape mode (zero quota) the
results banner and Load-more button drop the 'uses quota' wording. Adds the
search_source toggle's labels/hints and updates the per-user-limit hint to note
the cost only applies to the api source. EN/HU/DE.
2026-06-29 22:30:03 +02:00