Compare commits

...
30 Commits
Author SHA1 Message Date
peter 041cd526b7 Merge: YT feed filter-bar overhaul + 3 deep-review rounds
gate / frontend (push) Failing after 38s
gate / backend (push) Successful in 1m57s
Flattened, more compact YT feed filter rail and a responsive applied-filter row:
- Rail: removed the in-rail Clear all; Upload date is now a single-select dropdown
  (Any time + more presets + reveal-on-select Custom range); widgets are plain
  label + control rows (non-collapsible), with the "Your tags" title opening the
  tag manager.
- Sort + View moved into the applied-filter row (pinned right, above the grid)
  with responsive overflow: "+N" pill on wide lanes, a single count pill on narrow.
- Earlier branch work also merges here: typeable combobox filters, hidden-channel
  empty->Library hint, Mine/Library scope toggle in the header, per-category
  any/all tag match, and the saved-views redesign (popover + modal + chip).

Three USER-triggered deep-review rounds (trend 14 -> 4 -> 1); round 2 caught a real
coerce-on-seed migration bug. Gate-green: tsc, 397 vitest, lint, prettier, backend
pytest. Not yet released -- prod stays 0.71.0.
2026-08-22 07:09:25 +02:00
peter 9477fea3dc docs(test): correct the feed-views fixture comment on topic tag distribution
chA/chB each carry a different topic tag (T1 vs T2), not both — the old comment
implied they shared T1/T2. Assertions unchanged (equivalence test).
2026-08-22 07:04:00 +02:00
peter e4b19b5320 fix(feed): round-2 deep-review remediations (incl. a real coerce-on-seed bug)
The re-review of the fix round surfaced 4 findings; fixing the coverage one
exposed that the C1 migration was ineffective:
- FeedFiltersProvider.loadAccountFilters: run coerceTagModes on the RAW stored
  blob, not the object merged over DEFAULT_FILTERS. The merge injects
  DEFAULT_TAG_MODES (all "or"), which coerceTagModes prefers over the legacy
  scalar `tagMode` — so the migration never fired. Now a persisted legacy
  tagMode:'and' actually migrates into tagModes.topic (proven by a new test).
- test_feed_views: the tag_mode_other=and case was vacuous (only chC carried
  either "other" tag, so AND == OR). Give chA an "other" tag too so OR-other
  {chA,chC} differs from AND-other {chC} — the conjunctive path now narrows.
- DateFilterSelect: reset the custom reveal in useLayoutEffect (not useEffect)
  so the correction commits before paint — no one-frame "Custom range" flicker
  on the trigger after an external clear.
- SavedViewsProvider: extract the sticky-view tie-break to a pure, tested
  lib/savedViews.ts (preferSticky).

New tests: savedViews (preferSticky), FeedFiltersProvider.dom (legacy tagMode
migration on the localStorage/default-view seed).
2026-08-22 06:55:16 +02:00
peter 7692253636 test(feed): cover the filter-bar gaps deep-review flagged
- test_feed_views: extend _seed_tagged so a channel carries 2+ tags in a
  non-topic category, and add language-AND / other-AND equivalence cases — the
  per-category any/all generalization was only exercised for topic.
- tagModes.test: coerceTagModes migration (legacy global tagMode -> topic),
  per-category preservation, invalid-value coercion, defaults.
- urlState.dom.test: per-category tag-mode URL round-trip + legacy ?tagmode=and
  migration into the topic slot.
- Combobox.dom.test: the any/all match footer (hidden below 2 selections, reports
  a mode change at 2+).
- chipOverflow.test: the all/some/count fit boundaries and the +N reservation.
2026-08-22 06:33:48 +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 dd76e9dcf7 feat(feed): move sort + view into the chip row with responsive overflow
Sort and the view switcher are tightly bound to the grid, so pin them to the
right of the applied-filter row (directly above the cards) instead of the filter
toolbar; they render there on both the feed and channel-scoped views.

ActiveFilterChips now shares that row with the controls and trims itself so it
never crowds them, measuring fit off a hidden mirror of the full chip set:
- wide  -> show as many chips as fit, then a "+N" pill whose popover holds the rest;
- narrow -> collapse every chip into one "N filters" pill whose popover holds them all.
Both popovers list the (overflowed / all) chips, each removable, plus Clear all.
2026-08-22 05:42:45 +02:00
peter fca989c098 feat(feed): flatten the filter rail — dropdown upload-date, non-collapsible widgets, title triggers
Rework the feed filter rail into simpler label + control rows:
- Remove the rail's own "Clear all" (the chip row above the cards already
  surfaces one whenever any filter is active).
- Replace the Upload date chip group with a single-select dropdown
  (DateFilterSelect): an "Any time" clear, more presets (2 weeks / 3 months /
  2 years / 5 years) and a "Custom range" that reveals from/to on select; the
  trigger then reads the chosen range. Selection logic is the pure lib/dateFilter
  (applyDateChip -> selectDateChip, no toggle-off; tests updated).
- Widgets are no longer collapsible: PanelGroup gains `collapsible` (feed passes
  false) so groups render as always-open label + control rows; reorder + hide in
  edit mode stay. Other rails (Plex/Playlists) keep the collapsible islands.
- A non-collapsible group's title can act as its own trigger (PanelItem
  onTitleClick): "Your tags" opens the tag manager, replacing the inline Manage
  button.
2026-08-22 05:42:30 +02:00
peter 7ebbf45921 feat(feed): saved views as a header popover + Personal Views modal + a view chip
Retire the standalone 'Saved views' sidebar panel. The saved-views feature now
lives in three lighter surfaces, coordinated by a new SavedViewsProvider (query +
CRUD + the active/sticky view + modal state):

- the filter panel's header ICON opens a quick-pick popover (apply a view, active
  one checked, plus 'Manage views…');
- the header TITLE ('Filters') opens a new Personal Views modal — a master/detail
  manager: left = the reorderable list + a save-current-filters input, right = the
  selected view's name, its captured filters (shown as the same chips the toolbar
  row uses), default toggle, apply, and delete;
- a SelectedViewChip leads the applied-filter row, naming the active view with a
  warn-coloured 'modified' dot once the live filters diverge from it; clicking it
  reopens the picker.

SidePanel/FilterRail gain optional iconExpanded + onTitleClick slots (feed-only,
hidden for the demo account); the collapsed tab keeps its plain pin behaviour.
SavedViewsWidget is deleted and dropped from the panel layout.

Verified live: icon popover lists HH + Manage; applying HH surfaces the chip and
switches scope; removing a filter lights the dirty dot; the title opens the
master/detail modal with the captured-filters summary. 374 vitest, tsc, eslint
(0 err).
2026-08-22 04:03:58 +02:00
peter 829cadf5fb feat(feed): per-category any/all tag match, in each combobox's popover footer
The tag 'Match: Any/All' toggle was a single GLOBAL setting that only ever
affected topics, shown as its own row under the Topic widget. Make it
per-category — language, topic, and 'your tags' each get their own independent
any/all — and move it into each filter combobox's popover footer (like
SortControl's direction segment), reclaiming the standalone Match row.

Backend: the feed query already groups selected tags by category; replace the
single tag_mode with per-category tag_mode_language/topic/other params and apply
the conjunctive (HAVING all) branch per category. Mirrored across filtered_query,
feed_summary.passes(), and the /facets drill-down exclusion (kept in lockstep;
equivalence test updated to tag_mode_topic). No schema change.

Frontend: FeedFilters.tagMode -> tagModes {language,topic,other} (pure leaf
lib/api/tagModes with a coerceTagModes migration for the legacy global value);
serialized to the API and the shareable URL per category (legacy ?tagmode=and
still resolves into the topic slot); saved-view blobs migrate on read. Combobox
gains an optional matchMode/onMatchModeChange footer segment, shown once 2+
values are picked; the three tag comboboxes wire it to their category.

Verified: backend feed tests green (12, incl. the count/facets equivalence net);
live, the footer appears at 2+ selections, toggles aria-pressed, serializes
tagmode_topic=and to the URL, and the request carries tag_mode_topic=and (200).
374 vitest, tsc, ruff, eslint (0 err).
2026-08-22 03:48:23 +02:00
peter 8f08073564 feat(feed): move the Mine/Library scope toggle beside the header search box
The scope toggle used to sit at the top of the filter rail, which is buried when
the rail is collapsed/unpinned. Relocate it to the header, just right of the feed
search box — mirroring Plex's scope toggle placement — so the primary 'what am I
browsing' control is always visible.

New FeedScopeToggle adapter (SegmentedToggle over filters.scope, same as the Plex
one over its scope); rendered in Header on the feed page between the search box and
the sync chip. Removed the toggle from the Sidebar rail's now-unused `top` slot and
dropped the dead `top` prop from FilterRail. Same filters.scope field, so behaviour
is unchanged.

Verified live: the toggle sits beside the feed search and switches scope; the rail
no longer carries it.
2026-08-22 03:29:15 +02:00
peter af08a7850b feat(feed): guide a hidden-channel empty result to the Library
Filtering by a channel under the Mine scope returns nothing when that channel's
subscription is hidden (or you don't subscribe to it) — its videos only live in
the shared Library, since the Mine feed excludes hidden subscriptions by design.
Previously this left a bare 'No videos match these filters', which reads as a
bug when you know you're subscribed.

Add an empty-state hint + a 'View in Library' button (switches scope to all) when
there's a channel filter under Mine and zero results. Bilingual EN/HU.

Verified live: ?channel=<ColdFusion>&show=all under Mine shows the hint; the
button switches to Library and surfaces the 36 videos.
2026-08-22 03:25:49 +02:00
peter d2193ccd1b fix(combobox): merged input ref so the field is typeable by mouse
The <input> carried only floating-ui's reference ref, so the component's own
inputRef stayed null. The field container's onMouseDown then preventDefault()-ed
every press (its `e.target !== inputRef.current` guard was always true) and the
compensating inputRef.current?.focus() was a no-op — so clicking the box opened
the list but never focused the input, and mouse users could not type to filter.
Tab focus still worked, which is why it read as 'click, can't type'.

Feed a merged callback ref that assigns the node to both floating-ui's
setReference and inputRef. Fixes the Channel, Your tags, Topic, and Language
sidebar filters at once (all use Combobox); the channel list's 50-row cap is now
reachable past 50 via search. Single-select commit no longer re-focuses (that now
actually fires and would re-open the just-closed list). Regression test presses
the field chrome and asserts the input receives focus.

Verified live: clicking the Channel field focuses it and typing 'cold' narrows
the list to ColdFusion / wehatethecold.
2026-08-22 03:22:47 +02:00
peter cda70164ba Merge: toolbar/header UI-consistency refactor + a11y fixes
gate / frontend (push) Failing after 1m13s
gate / backend (push) Successful in 1m52s
Collects the toolbar/header/layout redesign and assorted bugfixes on the
bug/channel-back-header-overlap branch: new FilterSelect / ItemCount /
pill-SortControl primitives, removed ModuleName, the --sbw / --content-mw /
--rail-pinned-w chrome-alignment machinery, the centred search box, and the
two deep-review rounds (menu-role a11y, sort aria-pressed, rem rail threshold,
accessible trigger names, e2e helper fix, added coverage).

Not a release: VERSION stays 0.71.0 and this is not deployed to prod.
2026-08-22 02:07:20 +02:00
peter 5e1cc4d981 fix(a11y,test): deep-review round 2 — e2e helper attr, trigger names, observer test
Second gate round over the full branch surfaced 4 verified findings (1 was a
regression the round-1 role change introduced); a 5th (--sbw load jump) was
dropped as refuted (the header only mounts after auth+data, so --sbw is set
before it ever paints).

- e2e (medium): the round-1 switch of FilterSelect rows from role=option to
  menuitemcheckbox moved their state from aria-selected to aria-checked, but
  the Playwright helper expectContentType still asserted aria-selected, so the
  channel specs would fail whenever the e2e lane runs (not part of the fast
  gate, so it stayed green). Assert aria-checked.
- a11y (low): the FilterSelect and SortControl pill triggers set
  aria-label={label}, which overrides the visible "Label: value" text, so a
  screen reader announced only the group name, never the current selection.
  Carry the value (and, for sort, the direction) in the accessible name,
  matching ViewSwitcher. Updated the unit-test trigger name queries to match.
- tests (low): cover the PageShell font-scale MutationObserver recompute path
  (a post-mount root font-size change → --rail-pinned-w re-computes once, no
  loop) — previously only the pre-render and resize paths were exercised.

Verified live: the triggers now announce e.g. "Sort: Date, Descending" /
"Watch state: All". Gate green: tsc (app+node+e2e), 373 vitest, prettier,
eslint (0 err).
2026-08-22 01:06:22 +02:00
peter 4ee452b854 fix(a11y,layout): deep-review fixes — menu roles, sort aria-pressed, rem rail threshold
Epic-end deep-review of the branch surfaced six verified findings; fix them.

a11y (medium): FilterSelect and SortControl opened their AnchoredPopover in
mode:"listbox" but never passed a role, so the surface fell through to
role="menu" while the rows rendered role="option"+aria-selected — options
orphaned outside a listbox, so AT could not announce the selected value.
Switch both to the house menu pattern (as ViewSwitcher already uses): a menu
surface with menuitemradio (single) / menuitemcheckbox (multi) rows carrying
aria-checked, scoped by a role=group. Trigger stays a button+haspopup=menu.

a11y (low): the SortControl asc/desc direction segment signalled the active
direction by colour alone. Add aria-pressed so the current direction is
programmatically exposed, matching SegmentedToggle.

correctness (low): PageShell converted a rem maxWidth to px with a hardcoded
16 to decide the pinned-rail overlay threshold, but index.css scales the root
font by --font-scale (default 1.06 ~ 17px, slider 0.9-1.3), so the threshold
was wrong at every text size on the rem pages (playlists is the only one with
a pinned rail). Use the live root font-size, scale the 36rem slack the same
way, and recompute when --font-scale changes (a guarded MutationObserver — the
slider fires no resize).

tests: cover the two gaps the review flagged — ItemCount (plural, 1M+ overflow,
undefined icon-only, tooltip) and the PageShell rail-pinned-w/content-mw
threshold math incl. the font-scale scaling. Updated the FilterSelect/
SortControl role queries to the new menuitemradio/checkbox roles.

Verified in the running app: menu surfaces carry role=group + menuitemradio/
checkbox with 0 orphaned options, and the sort direction segment exposes
aria-pressed. Dropped two review findings (max-width duplication, pill-class
reuse) as refuted on adversarial verification. Gate green: tsc, 372 vitest,
prettier, eslint (0 err).
2026-08-21 22:41:57 +02:00
peter edbc9dede5 style(header): centre the search-box group over the page content
The floating header band already centres on --content-mw (the width the
page's cards use), but its search box + scope-toggle/sync-chip group was
packed to the left edge (default justify-start), leaving it offset from
the centred card grid on max-width pages (Plex/Channels/Playlists).

Add justify-center to the band so the group centres within it: once the
column is wider than the box's max-w, the box sits centred over the
cards instead of hugging the left edge. Shared header row, so it applies
to every module with a SearchBar. Verified at 1920px on Plex: group
centre == content-band centre (431px margin each side).
2026-08-21 22:08:23 +02:00
peter 53ca4e2849 feat(layout): a pinned filter rail no longer pushes content when there's room for it
On a page that centres its content at a max width, if the window is wide enough for the rail to fit
in the content's LEFT margin without overlapping it, pinning the rail now leaves it OVERLAY — the
content doesn't shift. It reverts to the old in-flow "push" only when there isn't room: a full-width
page (feed) or a window too narrow for the rail to clear the centred content.

PageShell computes --rail-pinned-w from the page's maxWidth vs the viewport (innerWidth >= maxWidth +
~576px → overlay 2.25rem, else in-flow 16.75rem) on a resize listener; the SidePanel slot reads it.

Verified at 1920px: pinning the playlists rail leaves the table at left 487 (moved 0); the rail sits
256px wide ending at 312 — 175px clear of the content. Feed (full width) still pins in-flow.
2026-08-21 21:53:24 +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 011acd919e feat(toolbar): lead the Plex toolbar with ItemCount too, unify its gaps
Plex browse now uses the same fixed-width ItemCount pill as the feed (its title count → "N items"),
placed as the leading element, and the toolbar gap tightened 2 → 1.5 to match. One consistent
toolbar shape across the source modules.
2026-08-21 20:37:36 +02:00
peter 45d8b47366 feat(toolbar): fixed-width ItemCount pill, lead the toolbar with it, align chrome to the cards
- New ItemCount: a generic, FIXED-WIDTH count pill (glass, non-interactive) — "192,050 items"
  (neutral noun, dynamic plural), min-w-[9rem] so a changing number never shifts the controls
  beside it; a million or more collapses to "1M+" with the exact figure in the title tooltip.
- Feed toolbar: ItemCount is the fixed leading element now; dropped the mx-1 separators so every
  gap is a uniform 1.5 (Content-types↔Source used to be wider than Watch-state↔Content-types).
- Chrome-to-card alignment (app-wide, via the shared Header + PageShell): the floating header and
  the toolbar band now span exactly the card grid's width — left-4, and a right edge of
  1rem + the scrollbar gutter (measured once into --sbw), so the fixed chrome lines up with the
  scrolled cards instead of running ~15px past them. Wraps cleanly to multiple rows on mobile.

Renames the count testid feed-result-count → item-count (E2Es updated).
2026-08-21 20:33:55 +02:00
peter c3820a0b44 refactor(header): remove the ModuleName module-switcher pill
The user no longer wants the header's "FEED" pill (module label + accent dot + ◀/▶ module-step
arrows). Drop it everywhere — the header now starts with the module's SearchBar; module switching
stays on the nav rail.

Deletes ModuleName.tsx, the now-unused stepModule() in lib/modules, and the Header's
module-order/label/setPage plumbing that only fed it.
2026-08-21 19:58:19 +02:00
peter c7b393901f style(type): correct the trigger text from 16px back to the view-switcher's real 14px
The earlier bump measured the view-switcher's BUTTON (16px, inherited) instead of its visible
label span (text-sm = 14px), and 14px is the size the user actually finds comfortable. Walk the
over-sized triggers back to text-sm: FilterSelect, SortControl, ViewSwitcher, and the Combobox
input (which was 14px before the bump anyway). The secondary text already at text-sm/text-xs
(count, chips, widget headers, section labels) was correct and stays.
2026-08-21 19:53:14 +02:00
peter f1e14c94c5 style(viewswitcher): unify the ViewSwitcher trigger with the FilterSelect pill
The user prefers FilterSelect's rounded-full glass pill over the ViewSwitcher's squarer
rounded-lg trigger. Give the ViewSwitcher trigger the same look — glass-card/glass-hover,
rounded-full, px-3 py-1, open→border-accent, and a 16px label (was text-sm) — so the two read
as one control family. Applies to every ViewSwitcher consumer (feed, channel manager, Plex
playlists).

Native <select>s, the Combobox input box, and playlist/list cards are a different control class
(form fields / variable-height inputs / cards) and keep their own shape.
2026-08-21 19:49:10 +02:00
peter ddb171fca3 style(type): raise the too-small toolbar and filter text toward the view-switcher size
The view-switcher's 16px reads comfortably; the toolbar and filter controls sat at 12px. Bring
the marked controls up to match, and lift the general scale on the shared filter surfaces.

- Toolbar (feed + channel + Plex, via the shared components): FilterSelect and SortControl trigger
  pills text-xs → text-base (16px, the view-switcher size); the result count → text-sm; the
  active-filter chips → text-sm with the "FILTERED BY" label text-2xs → text-xs.
- Filterbar: the Combobox picker input → text-base (matching the toolbar pills) + its chips → text-sm;
  the panel/widget headers (SidePanel collapsed-rail + panel title, PanelGroup widget titles) up one step.

Channel info and the manager tables were already at 14–16px (readable), so they were left as-is.
2026-08-21 19:37:11 +02:00
peter ec3bbc78dc style(layout): tighten the oversized vertical gaps around the page toolbars
The floating-header → toolbar → filter-chips → content stack carried ~2× the whitespace it
needed. Halve it, app-wide where the chrome is shared:
- PageShell: the --hdr-h gap below the floating header 8px → 4px (every page's header).
- Feed: toolbar wrapper pt-4 → pt-2, the toolbar's own pb-3 → pb-1.5, and the grid's top pt-2
  → pt-1. Measured on the feed: header→toolbar 24→12px, toolbar→chips 12→6px, chips→cards 12→8px.
- Plex / Playlists / ChannelDiscovery / DownloadCenter / Notifications / AdminUsers: toolbar
  wrapper pt-3 → pt-2, so every page's top band matches the tighter feed.

PageShell.dom.test updated for the 4px gap.
2026-08-21 19:13:38 +02:00
peter 1be1dc9d06 feat(sort): give SortControl the FilterSelect pill look with a footer direction segment
Rebuilds SortControl's internals (API unchanged, so all five consumers — feed, Plex,
playlists, and the two table/card ColumnSortControl sites — get it for free) as a labeled
"Label: key ↓ ▾" pill on the AnchoredPopover platform, matching FilterSelect. The listbox
lists the sort keys; an asc/desc segment sits in the panel footer (with the trailing slot —
feed reshuffle, playlists dirty-first — beside it).

Two-dimensional key+direction, so it stays its own component rather than reusing FilterSelect:
the trigger is a single plain button (no nested button), picking a key keeps the panel open so
key AND direction can be set in one visit, and picking "no sort" (tables' defaultLabel) closes
it. Directionless keys (shuffle) hide the segment and drop the trigger arrow.

Drops the now-redundant external "Sort" label spans in Feed and PlexBrowse (the trigger carries
the label now). Rewrites SortControl.dom.test for the popover UI.
2026-08-21 18:11:45 +02:00
peter 6109e0c6f1 feat(feed): convert the Source filter to the FilterSelect dropdown
Replaces the native <select> "Source" control with a labeled FilterSelect pill, matching
the Watch state / Content types pills — one consistent toolbar idiom. Single-select over
the three librarySource values (organic / all / search-only).

Sort and View are intentionally left as-is: Sort is a two-dimensional key+direction control
(plus reshuffle and a tables-only "no sort") shared across five surfaces, and View is already
on the same AnchoredPopover platform with an icon-only trigger tuned for this packed toolbar —
FilterSelect's text trigger would regress it. Neither fits FilterSelect's one-dimensional model.
2026-08-21 17:36:33 +02:00
peter 5851ddd329 feat(feed): compress the toolbar chip groups into labeled FilterSelect dropdowns
Adds a reusable FilterSelect: a "Label: value ▾" pill that opens a listbox (a checkmark
row for single-select, a checkbox row for multi), on the AnchoredPopover platform. It
replaces the two feed-toolbar chip walls:
  - Watch state (single-select, default All) — picking a row replaces + closes.
  - Content types (multi-select, default Normal; requireOne keeps at least one on) — rows
    toggle and the panel stays open.
The trigger summarises multi picks as "First +N". The component is source-agnostic, meant
for reuse anywhere a small known option set currently renders as a chip group.

FilterSelect takes a `testId` → the trigger gets it, each option gets `${testId}-${value}`,
so the feed/channel E2Es keep stable selectors. They now open the dropdown and assert the
option's aria-selected instead of the old inline aria-pressed chips (new expectContentType/
toggleContentType helpers).
2026-08-21 17:28:57 +02:00
peter ad0cf5827b feat(feed): use the Combobox picker for Channel/Your tags/Topic/Language filters
Replaces the hand-rolled channel search list and the flat tag chip walls with the
searchable, ARIA-correct Combobox built for the Plex filter bar (E5), so the YT feed
filters get the same input+listbox+chips UX with facet counts.

- ChannelPicker now wraps Combobox (selectedLabels keeps picks named); drops its bespoke
  input + scroll-fade list.
- Sidebar language/topic/tags: each Combobox owns only its slice of the shared
  filters.tags list (setTagSelection replaces that list's ids, leaves the rest). Topic
  keeps its any/all match toggle, Your tags keeps Manage. TagChip removed.
2026-08-21 17:01:41 +02:00
peter f6192aa6bf fix(feed): re-measure header on feed↔channel Back so --hdr-h can't stick at 8px
PageShell is reused (never remounted) across feed↔channel navigation — same
component position in App.tsx — so a one-shot useLayoutEffect kept its
ResizeObserver bound to the FIRST header node. On channel-open that node detached
and fired the observer with a 0 box, rewriting --hdr-h to 0+0+8=8px on the
still-mounted content div. Back restored the pt-(--hdr-h) pad class with that
stale value, sliding the content toolbar up under the header — stuck until reload.

Hold both the header and content nodes in state (callback refs) and key the
measurement effect to the live nodes, so it re-runs every time the header
mounts/unmounts; drop the stale pad when the header is absent (channel page).

Adds a regression test: header removed then restored must re-measure, not 8px.
2026-08-21 16:51:05 +02:00
70 changed files with 2742 additions and 1215 deletions
+11 -10
View File
@@ -117,7 +117,7 @@ def filtered_query(
user: User,
*,
tags: list[int],
tag_mode: str,
tag_modes: dict[str, str],
channel_ids: list[str],
q: str | None,
min_duration: int | None,
@@ -293,9 +293,10 @@ def filtered_query(
query = query.where(or_(func.unaccent(Video.title).ilike(like), channel_match))
if tags:
# AND across tag categories (e.g. language AND topic narrows), OR within a category; the any/all
# toggle controls multiple topic tags. ⚠️ feed_summary.passes() reimplements this exact rule in
# Python for the sidebar count/facets — keep the two in lockstep (equivalence-tested by
# AND across tag categories (e.g. language AND topic narrows), OR within a category — unless that
# category's mode is "and" (its per-category any/all toggle), which requires a channel to carry
# ALL its selected tags. ⚠️ feed_summary.passes() reimplements this exact rule in Python for the
# sidebar count/facets — keep the two in lockstep (equivalence-tested by
# test_feed_summary_matches_count_plus_facets).
by_category = tags_by_category(db, tags)
# Facet counting drops the category being counted so its own chips don't zero each
@@ -305,7 +306,7 @@ def filtered_query(
by_category.pop(exclude_tag_category, None)
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
for category, ids in by_category.items():
if category == "topic" and tag_mode == "and" and len(ids) > 1:
if tag_modes.get(category, "or") == "and" and len(ids) > 1:
sub = (
select(ChannelTag.channel_id)
.where(ChannelTag.tag_id.in_(ids), visible)
@@ -363,8 +364,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
(the tag's own category dropped, unless it's a conjunctive topic-AND — mirroring the
old /facets exclude_tag_category logic exactly).
Tag matching is channel-based, identical to filtered_query's tag block (AND across categories, OR
within, topic-AND requires ALL selected topics)."""
tag_mode = params.get("tag_mode", "or")
within, a category in "and" mode requires ALL its selected tags)."""
tag_modes = params.get("tag_modes", {})
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
# is present to facet over.
@@ -401,8 +402,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
for cat, sel in selected_by_cat.items():
if cat == exclude_cat:
continue
if cat == "topic" and tag_mode == "and" and len(sel) > 1:
if not sel <= ch_tags: # conjunctive: channel must carry ALL selected topics
if tag_modes.get(cat, "or") == "and" and len(sel) > 1:
if not sel <= ch_tags: # conjunctive: channel must carry ALL this category's selected tags
return False
elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag
return False
@@ -416,7 +417,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
for category in ("language", "topic", "other"):
# Drop this category's own selections so its chips keep independent counts; a conjunctive
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
exclude = None if (category == "topic" and tag_mode == "and") else category
exclude = None if tag_modes.get(category, "or") == "and" else category
for cid, ch_tags in channel_tags.items():
if not passes(ch_tags, exclude):
continue
+11 -3
View File
@@ -48,7 +48,11 @@ FINISH_MARGIN_SECONDS = 10
# Shared query parameters for /feed and /feed/count.
def _feed_params(
tags: list[int] = Query(default=[]),
tag_mode: str = "or",
# Per-category tag match: each of language / topic / other independently ORs (any) or ANDs (all)
# its own selected tags. Defaults to "or". (Superseded the single global `tag_mode`.)
tag_mode_language: str = "or",
tag_mode_topic: str = "or",
tag_mode_other: str = "or",
channel_ids: list[str] = Query(default=[]),
q: str | None = None,
min_duration: int | None = None,
@@ -65,7 +69,11 @@ def _feed_params(
) -> dict:
return {
"tags": tags,
"tag_mode": tag_mode,
"tag_modes": {
"language": tag_mode_language,
"topic": tag_mode_topic,
"other": tag_mode_other,
},
"channel_ids": channel_ids,
"q": q,
"min_duration": min_duration,
@@ -162,7 +170,7 @@ def get_facets(
# independent counts (you can OR more of them in). Conjunctive (AND, topics only)
# keeps them applied, so each remaining chip narrows to channels that ALSO have all
# already-selected topics — and tags that can't co-occur drop to zero (hidden).
conjunctive = category == "topic" and params.get("tag_mode") == "and"
conjunctive = params.get("tag_modes", {}).get(category) == "and"
base, _rank = filtered_query(
db,
user,
+18 -4
View File
@@ -96,6 +96,7 @@ def _seed_tagged(db, make_user):
"T1": Tag(user_id=None, name="Tech", category="topic"),
"T2": Tag(user_id=None, name="Gaming", category="topic"),
"O1": Tag(user_id=None, name="Favourite", category="other"),
"O2": Tag(user_id=None, name="Docs", category="other"),
# A USER-owned tag (+ a user-owned ChannelTag link below) so the visibility branch
# `ChannelTag.user_id == user.id` is exercised, not only the system `user_id IS NULL` one.
"U1": Tag(user_id=user.id, name="MyPin", category="other"),
@@ -104,8 +105,17 @@ def _seed_tagged(db, make_user):
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chA", "chB", "chC", "chD")}
db.add_all(chans.values())
db.flush()
# channel -> (system) tag keys
links = {"chA": ["L1", "T1"], "chB": ["L1", "T2"], "chC": ["L2", "T1", "O1"], "chD": []}
# channel -> (system) tag keys. chA carries two languages (L1+L2); chC two "other" tags (O1+O2)
# AND chA carries O1 too — so the per-category any/all match narrows NON-TRIVIALLY (AND != OR) for
# language and other, not only topic: e.g. other {O1,O2} → OR {chA,chC} vs AND {chC}; language
# {L1,L2} → OR {chA,chB,chC} vs AND {chA}. (For topic, chA and chB each carry a DIFFERENT topic tag
# — T1 vs T2 — so topic {T1,T2} → OR {chA,chB,chC} vs AND {} empty.)
links = {
"chA": ["L1", "L2", "T1", "O1"],
"chB": ["L1", "T2"],
"chC": ["L2", "T1", "O1", "O2"],
"chD": [],
}
for cid, tkeys in links.items():
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
for tk in tkeys:
@@ -127,8 +137,12 @@ _SUMMARY_CASES = [
"show=all&tags={L1}",
"show=all&tags={T1}",
"show=all&tags={L1}&tags={T1}",
"show=all&tags={T1}&tags={T2}&tag_mode=or",
"show=all&tags={T1}&tags={T2}&tag_mode=and",
"show=all&tags={T1}&tags={T2}&tag_mode_topic=or",
"show=all&tags={T1}&tags={T2}&tag_mode_topic=and",
# Per-category any/all is NOT topic-only — language and other must drill down identically too.
"show=all&tags={L1}&tags={L2}&tag_mode_language=or",
"show=all&tags={L1}&tags={L2}&tag_mode_language=and",
"show=all&tags={O1}&tags={O2}&tag_mode_other=and",
"show=all&tags={U1}", # user-owned tag (visibility == user.id branch)
"show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category
"show=all&q=clip",
+11 -27
View File
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { clickVisibleChannelLink, openApp } from "./helpers";
import { clickVisibleChannelLink, expectContentType, openApp, toggleContentType } from "./helpers";
// Locks channel-view entry + exit: opening a channel from a feed card replaces the view, and
// both the in-page Back and the browser Back return to the feed. (This orthogonal channel branch
@@ -68,29 +68,22 @@ test.describe("channel view", () => {
}) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
const feedLive = page.getByTestId("feed-content-includeLive");
await expect(feedLive).toHaveAttribute("aria-pressed", "false");
await expectContentType(page, "includeLive", false);
// Inherit OFF: the channel toolbar's Live/Upcoming stays off, not forced on.
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"false",
);
await expectContentType(page, "includeLive", false);
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("channel-title")).toHaveCount(0);
// Inherit ON: flip the feed's Live/Upcoming on, reopen the channel → it inherits the on state.
await feedLive.click();
await expect(feedLive).toHaveAttribute("aria-pressed", "true");
await toggleContentType(page, "includeLive");
await expectContentType(page, "includeLive", true);
await expect(page.getByTestId("video-card").first()).toBeVisible();
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"true",
);
await expectContentType(page, "includeLive", true);
});
// Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there
@@ -99,27 +92,18 @@ test.describe("channel view", () => {
test("a channel's content toggle does not leak into the feed", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"false",
);
await expectContentType(page, "includeLive", false);
// Turn Live/Upcoming ON inside the channel view.
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await page.getByTestId("feed-content-includeLive").click();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"true",
);
await toggleContentType(page, "includeLive");
await expectContentType(page, "includeLive", true);
// Back on the feed, its Live/Upcoming is still off — the channel's change stayed local.
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("channel-title")).toHaveCount(0);
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
"aria-pressed",
"false",
);
await expectContentType(page, "includeLive", false);
});
// Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays
@@ -127,7 +111,7 @@ test.describe("channel view", () => {
test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => {
await openApp(page);
await page.getByTestId("video-card-channel").first().click();
const count = page.getByTestId("feed-result-count");
const count = page.getByTestId("item-count");
const title = page.getByTestId("channel-title");
await expect(count).toBeVisible();
await expect(page.getByTestId("video-card").first()).toBeVisible();
+10 -6
View File
@@ -8,15 +8,18 @@ test.describe("feed", () => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
const count = page.getByTestId("feed-result-count");
const count = page.getByTestId("item-count");
await expect(count).toHaveText(/\d/);
const baseline = await count.innerText();
// Clean baseline: default filters, so no chips and "unwatched" is the active show.
// Clean baseline: default filters, so no chips and "unwatched" is the active show (the Watch-state
// pill summarises the current pick).
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("feed-show")).toContainText("Unwatched");
// Narrow to "watched" — the seed guarantees exactly one, a real deterministic change.
// Narrow to "watched" — open the Watch-state dropdown and pick it (single-select closes on pick).
// The seed guarantees exactly one, a real deterministic change.
await page.getByTestId("feed-show").click();
await page.getByTestId("feed-show-watched").click();
await expect(page.getByTestId("active-filter-chip")).toHaveCount(1);
await expect(count).not.toHaveText(baseline);
@@ -24,7 +27,7 @@ test.describe("feed", () => {
// Remove the chip → default show restored, chips gone.
await page.getByTestId("active-filter-chip").click();
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("feed-show")).toContainText("Unwatched");
});
test("scrolling virtualizes in fresh cards", async ({ page }) => {
@@ -52,7 +55,7 @@ test.describe("feed", () => {
// pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll.
test("the feed toolbar stays pinned while cards scroll", async ({ page }) => {
await openApp(page);
const count = page.getByTestId("feed-result-count");
const count = page.getByTestId("item-count");
await expect(count).toBeVisible();
const topBefore = await count.evaluate((el) => el.getBoundingClientRect().top);
@@ -73,6 +76,7 @@ test.describe("feed", () => {
expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
// Switch the show filter → a different result set → back to the top.
await page.getByTestId("feed-show").click();
await page.getByTestId("feed-show-all").click();
await expect.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop)).toBeLessThan(50);
});
+24
View File
@@ -33,6 +33,30 @@ export async function clickVisibleChannelLink(page: Page): Promise<boolean> {
return false;
}
/**
* Assert a content-type (e.g. "includeLive") is on/off via the Content-types FilterSelect dropdown.
* The options only exist while the dropdown is open, so this opens it, checks the option's
* `aria-checked` (the multi FilterSelect renders its rows as menuitemcheckbox), then closes it
* (Escape) so the floating panel can't block a later click.
*/
export async function expectContentType(page: Page, value: string, on: boolean): Promise<void> {
await page.getByTestId("feed-content").click();
await expect(page.getByTestId(`feed-content-${value}`)).toHaveAttribute(
"aria-checked",
on ? "true" : "false",
);
await page.keyboard.press("Escape");
await expect(page.getByTestId(`feed-content-${value}`)).toHaveCount(0);
}
/** Toggle a content-type via the Content-types FilterSelect dropdown, then close it. */
export async function toggleContentType(page: Page, value: string): Promise<void> {
await page.getByTestId("feed-content").click();
await page.getByTestId(`feed-content-${value}`).click();
await page.keyboard.press("Escape");
await expect(page.getByTestId(`feed-content-${value}`)).toHaveCount(0);
}
/** Open the in-app player from the first feed card (clicks its thumbnail link). */
export async function openFirstVideo(page: Page): Promise<void> {
await expect(page.getByTestId("video-card").first()).toBeVisible();
+15 -1
View File
@@ -136,6 +136,19 @@ export default function App() {
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
}, [meQuery.data?.id]);
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
// that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough.
useEffect(() => {
const probe = document.createElement("div");
probe.style.cssText =
"position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable";
document.body.appendChild(probe);
const w = probe.offsetWidth - probe.clientWidth;
document.body.removeChild(probe);
document.documentElement.style.setProperty("--sbw", `${w}px`);
}, []);
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
@@ -300,7 +313,7 @@ export default function App() {
const me = meQuery.data!;
const pageGate = moduleDef(page).gate;
const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
const { Component: PageContent, fadeTop } = PAGE_CONTENT[activePage];
const { Component: PageContent, fadeTop, maxWidth } = PAGE_CONTENT[activePage];
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
return (
@@ -366,6 +379,7 @@ export default function App() {
) : (
<PageShell
scrollKey={normalScrollKey}
maxWidth={maxWidth}
// Channels: the registry disables the top fade for its sticky-header table, but the card
// layout has no sticky header, so re-enable it there.
fadeTop={activePage === "channels" ? channelLayout === "cards" : (fadeTop ?? true)}
+234 -27
View File
@@ -1,51 +1,258 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { Filter, X } from "lucide-react";
import type { ActiveFilter } from "../lib/useActiveFilters";
import { decideChipLayout } from "../lib/chipOverflow";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
// open the panel just to see what you'd filtered by.
//
// The row is a single flex line that shares its width with the Sort/View controls pinned to its right,
// so the chips must never push those off-screen. When they'd overflow it trims responsively:
// - wide -> show as many chips as fit, then a "+N" pill whose popover holds the rest;
// - narrow -> collapse every chip into one "N filters" pill whose popover holds them all.
// Fit is measured off a hidden mirror of the full chip set (so slicing the visible lane can't feed
// back into the measurement and oscillate).
// Below this lane width an overflow collapses all the way to a single count pill (mobile), instead of
// the "fit some + N more" treatment used on wider lanes.
const NARROW_PX = 420;
const chipCls =
"chip group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-sm whitespace-nowrap hover:border-accent hover:bg-accent/20 transition";
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
const { t } = useTranslation();
return (
<button
data-testid={testId}
data-filter-key={f.key}
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className={chipCls}
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
</button>
);
}
// The "+N" / "N filters" pill and its popover listing the (overflowed / all) chips, each removable.
function OverflowPopover({
label,
ariaLabel,
chips,
onClearAll,
icon,
}: {
label: string;
/** Accessible name for the trigger + popover — describes what's inside (e.g. "3 more filters"),
* since the visible label can be a bare "+3". Defaults to `label`. */
ariaLabel?: string;
chips: ActiveFilter[];
onClearAll: () => void;
icon?: boolean;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-end",
});
const name = ariaLabel ?? label;
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={name}
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${
open ? "border-accent text-accent" : "hover:border-accent"
}`}
>
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
{label}
</button>
<PopoverSurface
popover={pop}
ariaLabel={name}
className="glass-menu min-w-48 max-w-72 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
<div role="group" aria-label={name} className="max-h-72 overflow-y-auto no-scrollbar">
{chips.map((f) => (
<PopoverItem
key={f.key}
role="menuitem"
onSelect={f.remove}
label={f.label}
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-fg transition"
>
<span className="flex-1 truncate">{f.label}</span>
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
</PopoverItem>
))}
</div>
<div className="mt-1 border-t border-border px-1 pt-1.5">
<button
type="button"
data-testid="active-filter-clear-all"
onClick={() => {
onClearAll();
setOpen(false);
}}
className="text-xs text-muted hover:text-fg underline underline-offset-2 transition"
>
{t("sidebar.clearAll")}
</button>
</div>
</PopoverSurface>
</>
);
}
export default function ActiveFilterChips({
filters,
onClearAll,
className,
}: {
filters: ActiveFilter[];
onClearAll: () => void;
className?: string;
}) {
const { t } = useTranslation();
if (filters.length === 0) return null;
const laneRef = useRef<HTMLDivElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const chipRefs = useRef<Array<HTMLSpanElement | null>>([]);
const plusRef = useRef<HTMLSpanElement | null>(null);
const clearRef = useRef<HTMLSpanElement | null>(null);
// How the row is currently laid out: all chips, a fit-count + "+N", or a single count pill.
const [layout, setLayout] = useState<{ mode: "all" | "some" | "count"; visible: number }>({
mode: "all",
visible: filters.length,
});
const n = filters.length;
// Re-measure whenever the chip set changes (labels or count) — captures both in one dep.
const chipsKey = filters.map((f) => f.key + f.label).join("|");
useLayoutEffect(() => {
const lane = laneRef.current;
const labelEl = labelRef.current;
if (!lane || !labelEl || n === 0) return;
const measure = () => {
const rightOf = (el: HTMLElement | null | undefined) =>
el ? el.offsetLeft + el.offsetWidth : 0;
const chips = chipRefs.current.slice(0, n);
const plusEl = plusRef.current;
// Measure the real rendered gap off the box between the last chip and the "+N" sample, so it
// scales with the text-size slider (gap-1.5 is rem) rather than being frozen in px.
const lastChip = chips[n - 1];
const gap = plusEl && lastChip ? Math.max(0, plusEl.offsetLeft - rightOf(lastChip)) : 6;
// "all" mode also renders a Clear all button after the chips (when n > 1), so reserve its width.
const clearW = n > 1 ? gap + (clearRef.current?.offsetWidth ?? 0) : 0;
setLayout(
decideChipLayout({
n,
labelRight: rightOf(labelEl),
chipRights: chips.map((c) => rightOf(c)),
plusW: plusEl?.offsetWidth ?? 0,
clearW,
avail: lane.clientWidth,
gap,
narrowPx: NARROW_PX,
}),
);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(lane);
return () => ro.disconnect();
}, [n, chipsKey]);
if (n === 0) return null;
const hiddenCount = n - layout.visible;
const overflowChips = filters.slice(layout.visible);
return (
<div
ref={laneRef}
data-testid="active-filter-chips"
className="flex flex-wrap items-center gap-1.5 pb-3"
className={`relative flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
aria-label={t("feed.chips.label")}
>
<span className="text-2xs uppercase tracking-wider text-muted mr-0.5">
{t("feed.chips.label")}
</span>
{filters.map((f) => (
<button
key={f.key}
data-testid="active-filter-chip"
data-filter-key={f.key}
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className="chip group pl-2.5 pr-1.5 py-1 text-xs hover:border-accent hover:bg-accent/20 transition"
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
</button>
))}
{filters.length > 1 && (
<button
data-testid="active-filter-clear-all"
onClick={onClearAll}
className="ml-0.5 text-xs text-muted hover:text-fg underline underline-offset-2 transition"
>
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree
and without test ids so it never doubles the visible chip count. */}
<div
aria-hidden="true"
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5"
>
<span ref={labelRef} className="text-xs uppercase tracking-wider text-muted mr-0.5">
{t("feed.chips.label")}
</span>
{filters.map((f, i) => (
<span
key={f.key}
ref={(el) => {
chipRefs.current[i] = el;
}}
className={chipCls}
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0" />
</span>
))}
<span ref={plusRef} className="chip inline-flex items-center gap-1 px-2.5 py-1 text-sm">
+{n}
</span>
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
{t("sidebar.clearAll")}
</button>
</span>
</div>
{/* Visible lane. */}
{layout.mode === "count" ? (
<OverflowPopover
label={t("feed.chips.count", { count: n })}
chips={filters}
onClearAll={onClearAll}
icon
/>
) : (
<>
<span className="text-xs uppercase tracking-wider text-muted mr-0.5 shrink-0">
{t("feed.chips.label")}
</span>
{filters.slice(0, layout.visible).map((f) => (
<ChipButton key={f.key} f={f} testId="active-filter-chip" />
))}
{layout.mode === "some" && hiddenCount > 0 && (
<OverflowPopover
label={`+${hiddenCount}`}
ariaLabel={t("feed.chips.moreCount", { count: hiddenCount })}
chips={overflowChips}
onClearAll={onClearAll}
/>
)}
{layout.mode === "all" && n > 1 && (
<button
data-testid="active-filter-clear-all"
onClick={onClearAll}
className="ml-0.5 shrink-0 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
>
{t("sidebar.clearAll")}
</button>
)}
</>
)}
</div>
);
+1 -1
View File
@@ -34,7 +34,7 @@ export default function AdminUsers() {
<>
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
<Tabs tabs={tabs} active={active} onChange={setTab} />
</div>
</PageToolbar>
+1 -1
View File
@@ -200,7 +200,7 @@ export default function ChannelDiscovery({
<>
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
<PageToolbar>
<div className="px-4 pt-3 max-w-384 mx-auto flex items-start justify-between gap-4">
<div className="px-4 pt-2 max-w-384 mx-auto flex items-start justify-between gap-4">
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
{layout === "cards" && (
+2 -2
View File
@@ -27,7 +27,7 @@ import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { api, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
@@ -231,7 +231,7 @@ export default function ChannelPage({
// the explored, per-user-private videos show — the backend gates them to this explorer).
const [filters, setFilters] = useState<FeedFilters>(() => ({
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
q: "",
sort: "newest",
scope: "all",
+16 -76
View File
@@ -1,11 +1,8 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { X } from "lucide-react";
import { api, type FeedFilters } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade";
import { inputCls } from "./ui/form";
import Combobox, { type ComboboxOption } from "./Combobox";
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
// across all of them. Until this existed the filter was settable only via a shared "?channel=" link:
@@ -22,86 +19,29 @@ export default function ChannelPicker({
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
// Same edge-fade affordance the nav rail and the panel body use to hint at more content.
const fade = useScrollFade();
const channelsQuery = useQuery({
queryKey: qk.channels(),
queryFn: api.channels,
staleTime: 5 * 60_000,
});
const q = query.trim().toLowerCase();
const all = channelsQuery.data ?? [];
const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all;
const picked = filters.channelIds;
const nameOf = (id: string) => all.find((c) => c.id === id)?.title ?? id;
const toggle = (id: string) =>
setFilters({
...filters,
channelIds: picked.includes(id) ? picked.filter((x) => x !== id) : [...picked, id],
});
// The list order isn't alphabetical — it's the user's channel priority — and Combobox preserves the
// options order, so it stays meaningful. selectedLabels keeps a picked channel named even if a later
// narrowing ever drops it from the visible rows.
const options: ComboboxOption[] = all.map((c) => ({ value: c.id, label: c.title ?? c.id }));
const selectedLabels = Object.fromEntries(all.map((c) => [c.id, c.title ?? c.id]));
return (
<>
{/* The picks sit above the list so they stay visible once the list scrolls away. */}
{picked.length > 0 && (
<div className="flex flex-wrap gap-1.5 pb-2">
{picked.map((id) => (
<button
key={id}
onClick={() => toggle(id)}
title={t("sidebar.channelRemove", { name: nameOf(id) })}
className="chip-solid inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full text-xs shadow-xs hover:opacity-90 transition"
>
<span className="truncate">{nameOf(id)}</span>
<X className="w-3 h-3 shrink-0" />
</button>
))}
</div>
)}
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("sidebar.channelSearch")}
aria-label={t("sidebar.channelSearch")}
className={inputCls}
/>
{/* The order isn't arbitrary and isn't alphabetical — say so, or the top of the list looks
random to anyone who hasn't set a priority. Only while unfiltered: a search reorders nothing
but does drop the rows that explain it. */}
{!q && all.length > 0 && (
<div className="px-2 pt-1.5 text-2xs text-muted">{t("sidebar.channelOrder")}</div>
)}
<div
ref={fade.ref}
style={fade.style}
className="mt-1.5 max-h-44 overflow-y-auto no-scrollbar flex flex-col gap-0.5"
>
{matches.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted">
{channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")}
</div>
) : (
matches.map((c) => {
const on = picked.includes(c.id);
return (
<button
key={c.id}
onClick={() => toggle(c.id)}
aria-pressed={on}
// shrink-0 is load-bearing: these are flex children in a max-height box, so without
// them they squash to ~12px each and clip their own text instead of the box scrolling.
className={`w-full shrink-0 text-left text-sm px-2 py-1.5 rounded-lg truncate transition ${
on ? "text-accent font-medium" : "text-muted hover:text-fg hover:bg-card"
}`}
>
{c.title ?? c.id}
</button>
);
})
)}
</div>
</>
<Combobox
label={t("sidebar.channelSearch")}
placeholder={t("sidebar.channelSearch")}
options={options}
selected={filters.channelIds}
onChange={(channelIds) => setFilters({ ...filters, channelIds })}
loading={channelsQuery.isLoading}
emptyText={t("sidebar.noChannelMatch")}
selectedLabels={selectedLabels}
/>
);
}
@@ -64,6 +64,19 @@ describe("Combobox", () => {
expect(document.getElementById(controls!)?.getAttribute("role")).toBe("listbox");
});
it("focuses the input when the field chrome is pressed (merged ref, not a dead inputRef)", () => {
// Regression: the input carried only floating-ui's ref, so `inputRef` stayed null. The field's
// onMouseDown then preventDefault()-ed every press (its `e.target !== inputRef.current` guard was
// always true) and its compensating inputRef.current?.focus() was a no-op → the box couldn't be
// typed into with the mouse. A merged ref feeds the node to both, so pressing the chrome focuses it.
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
const input = screen.getByRole("combobox");
const field = input.parentElement as HTMLElement;
expect(document.activeElement).not.toBe(input);
fireEvent.mouseDown(field); // press the field padding, not the input itself
expect(document.activeElement).toBe(input);
});
it("keeps the option list unmounted until the field is focused", () => {
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
@@ -192,6 +205,31 @@ describe("Combobox", () => {
expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces
});
it("shows the any/all match footer only at 2+ selections and reports a mode change", () => {
// The per-category any/all toggle: meaningless below 2 selections (any === all), so it appears
// only at >= 2, and clicking 'all' reports the mode change out.
const onMatchModeChange = vi.fn();
const props = {
label: "Topic",
options: OPTIONS,
onChange: vi.fn(),
matchMode: "or" as const,
onMatchModeChange,
};
// One selection: no footer.
const { rerender } = wrap(<Combobox {...props} selected={["Drama"]} />);
fireEvent.focus(screen.getByRole("combobox"));
expect(screen.queryByText("sidebar.all")).toBeNull();
// Two selections: footer appears with an any/all segment.
rerender(<FloatingTree>{<Combobox {...props} selected={["Drama", "Action"]} />}</FloatingTree>);
fireEvent.focus(screen.getByRole("combobox"));
const all = screen.getByText("sidebar.all");
expect(all).toBeTruthy();
fireEvent.click(all);
expect(onMatchModeChange).toHaveBeenCalledWith("and");
});
it("single-select: picking replaces the value and closes the list", () => {
const onChange = vi.fn();
wrap(
+54 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, X } from "lucide-react";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
@@ -55,6 +55,11 @@ export interface ComboboxProps {
/** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed)
* list — the collection picker passes the stored title here so a reloaded filter still names itself. */
selectedLabels?: Record<string, string>;
/** Optional "any/all" match segment in the popover footer (like SortControl's direction segment):
* "or" = a match on ANY selected value, "and" = must match ALL. Provide BOTH to show it; it appears
* only once 2+ values are selected (below that, any and all are the same). */
matchMode?: "or" | "and";
onMatchModeChange?: (mode: "or" | "and") => void;
}
export default function Combobox({
@@ -73,6 +78,8 @@ export default function Combobox({
maxVisible = 50,
renderOption,
selectedLabels,
matchMode,
onMatchModeChange,
}: ComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -83,7 +90,7 @@ export default function Combobox({
if (queryProp === undefined) setQueryState(q);
onQueryChange?.(q);
};
const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const fade = useScrollFade();
// Remember each value's display label as options flow through, so a selected chip can name itself
@@ -100,6 +107,19 @@ export default function Combobox({
onOpenChange?.(next);
};
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" });
// The input is the combobox reference for floating-ui AND our own focus target (the field's
// click-to-focus handler, and commit() re-focusing after a pick). A merged callback ref feeds the
// node to BOTH — without it `inputRef` stays null, so the field's onMouseDown guard (`e.target !==
// inputRef.current`) is always true and preventDefault() cancels the input's own click-focus, making
// the field impossible to type into with the mouse. Memoised so floating-ui's ref isn't re-attached
// every render.
const setInputRef = useCallback(
(node: HTMLInputElement | null) => {
pop.refs.setReference(node);
inputRef.current = node;
},
[pop.refs],
);
// `highlightIndex` = the row the user actually SEES highlighted (pointer hover, else keyboard); Enter
// commits THAT, not the bare keyboard `activeIndex` — otherwise arrow-then-hover-then-Enter would toggle
// a different option than the highlighted one, and a hover with no prior arrow (activeIndex still null)
@@ -129,11 +149,14 @@ export default function Combobox({
onChange(
selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value],
);
// Keep the field focused so the user can keep picking / typing; the list stays open.
inputRef.current?.focus();
} else {
// Single-select is final: close and DON'T re-focus — a refocus fires onFocus, which would
// immediately re-open the list (now that the merged ref makes focus() actually work).
onChange([value]);
setOpenAnd(false);
}
inputRef.current?.focus();
};
return (
@@ -154,7 +177,7 @@ export default function Combobox({
className="glass-card flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-lg border border-border px-1.5 py-1 transition focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/30"
>
{selected.map((v) => (
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-xs">
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-sm">
<span className="max-w-36 truncate">{labelFor(v)}</span>
<button
type="button"
@@ -172,7 +195,7 @@ export default function Combobox({
</span>
))}
<input
ref={pop.refs.setReference}
ref={setInputRef}
{...pop.getReferenceProps({
onChange: (e) => {
setQuery((e.target as HTMLInputElement).value);
@@ -266,6 +289,32 @@ export default function Combobox({
</>
)}
</div>
{/* Any/all match segment (like SortControl's direction footer) — only meaningful, so only shown,
once 2+ values are picked. mousedown-preventDefault keeps the input focused so the list stays
open in the combobox's focus model. */}
{matchMode != null && onMatchModeChange && selected.length >= 2 && (
<div className="mt-1 flex items-center gap-2 border-t border-border px-1 pt-2">
<span className="text-2xs text-muted">{t("sidebar.match")}</span>
<div className="ml-auto flex gap-1">
{(["or", "and"] as const).map((m) => (
<button
key={m}
type="button"
aria-pressed={matchMode === m}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onMatchModeChange(m)}
className={`rounded-md border px-2 py-0.5 text-2xs transition ${
matchMode === m
? "border-accent bg-accent/15 text-accent"
: "border-border text-muted hover:border-accent/50 hover:text-fg"
}`}
>
{t(m === "or" ? "sidebar.any" : "sidebar.all")}
</button>
))}
</div>
</div>
)}
</PopoverSurface>
</div>
);
@@ -0,0 +1,191 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays, Check, ChevronDown } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { type FeedFilters } from "../lib/api";
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
// reveals the from/to fields in the popover footer (reveal-on-select) and the trigger then reads the
// chosen range; the presets and Any time close on pick. Selection logic is the pure lib/dateFilter so
// it stays testable without mounting this. The popover hosts free-text (date) inputs, so it opens as a
// `hostsInput` menu and the inputs stop non-Escape keys from reaching the list navigation.
// yyyy-mm-dd -> a local Date at midnight (split so it isn't parsed as UTC, which would shift a day).
function parseDay(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
}
export default function DateFilterSelect({
filters,
setFilters,
presets,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
presets: readonly DatePreset[];
}) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const [customOpen, setCustomOpen] = useState<boolean>(!!(filters.dateFrom || filters.dateTo));
// If the range is cleared elsewhere (chip-row Clear all / removing the date chip) while this popover
// is closed, drop the custom reveal so the trigger reverts to "Any time" instead of lingering on
// "Custom range" with empty fields. Guarded on !open so it never closes a freshly-picked Custom the
// user hasn't filled in yet — that stays open until they close it (handled by onOpenChange below).
// useLayoutEffect (not useEffect) so the reset commits BEFORE paint — otherwise the trigger flashes
// one stale "Custom range" frame between the external clear and this reset.
useLayoutEffect(() => {
if (!open && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
}, [open, customOpen, filters.dateFrom, filters.dateTo]);
const key = deriveDateKey(filters, customOpen, presets);
const fmt = (s: string) => {
const d = parseDay(s);
return d
? new Intl.DateTimeFormat(i18n.language, { month: "short", day: "numeric" }).format(d)
: s;
};
const rangeSummary = () => {
const { dateFrom: f, dateTo: to } = filters;
if (f && to) return `${fmt(f)} – ${fmt(to)}`;
if (f) return t("sidebar.date.fromShort", { date: fmt(f) });
if (to) return t("sidebar.date.untilShort", { date: fmt(to) });
return t("sidebar.date.customRange");
};
const summary =
key === ""
? t("sidebar.date.anyTime")
: key === "custom"
? rangeSummary()
: t("sidebar.datePreset." + presets.find((p) => String(p.days) === key)!.key);
const options = [
{ value: "any", label: t("sidebar.date.anyTime") },
...presets.map((p) => ({ value: String(p.days), label: t("sidebar.datePreset." + p.key) })),
{ value: "custom", label: t("sidebar.date.customRange") },
];
const pop = useAnchoredPopover({
open,
onOpenChange: (next) => {
setOpen(next);
// Opened Custom but committed no range → drop the reveal on close so it doesn't stick.
if (!next && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
},
mode: "menu",
placement: "bottom-start",
hostsInput: true,
});
const pick = (value: string) => {
const change = selectDateChip(value, filters);
setCustomOpen(change.customOpen);
setFilters(change.filters);
if (value !== "custom") setOpen(false); // Custom stays open to reveal the range fields
};
const dateInputCls =
"bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40";
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
className={`glass-card glass-hover flex w-full items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="flex-1 truncate text-left text-fg">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={t("sidebar.datePresetLabel")}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
<div role="group" aria-label={t("sidebar.datePresetLabel")}>
{options.map((o) => {
const on = o.value === key || (o.value === "any" && key === "");
return (
<PopoverItem
key={o.value}
role="menuitemradio"
checked={on}
onSelect={() => pick(o.value)}
label={o.label}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
<span className="flex-1 truncate">{o.label}</span>
</PopoverItem>
);
})}
</div>
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. The inputs
stop non-Escape keys so the date-segment arrows act on the field, not the menu's list nav. */}
{key === "custom" && (
<div className="mt-1 flex flex-col gap-1.5 border-t border-border px-1 pt-2">
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.from")}</span>
<input
type="date"
value={filters.dateFrom ?? ""}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
onChange={(e) =>
setFilters({
...filters,
dateFrom: e.target.value || undefined,
maxAgeDays: undefined,
})
}
className={dateInputCls}
/>
</label>
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.to")}</span>
<input
type="date"
value={filters.dateTo ?? ""}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
onChange={(e) =>
setFilters({
...filters,
dateTo: e.target.value || undefined,
maxAgeDays: undefined,
})
}
className={dateInputCls}
/>
</label>
{(filters.dateFrom || filters.dateTo) && (
<button
type="button"
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
className="text-2xs text-muted hover:text-accent self-end"
>
{t("sidebar.clearDates")}
</button>
)}
</div>
)}
</PopoverSurface>
</>
);
}
+1 -1
View File
@@ -135,7 +135,7 @@ export default function DownloadCenter() {
<>
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-4xl w-full mx-auto">
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
<div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<button
+124 -109
View File
@@ -13,6 +13,7 @@ import {
Grid3x3,
Info,
LayoutGrid,
Library,
List,
RefreshCw,
Rows3,
@@ -36,11 +37,13 @@ import { type SortOption } from "../lib/columnSort";
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
import SelectedViewChip from "./SelectedViewChip";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import { useWizard } from "./WizardProvider";
import SortControl from "./SortControl";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import FilterSelect from "./FilterSelect";
import ItemCount from "./ItemCount";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
@@ -567,124 +570,109 @@ export default function Feed({
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
}));
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
// same controls render on both the feed and channel-scoped rows.
const sortView = (
<>
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
onChange={(next) => {
if (!next) return; // the feed is always sorted — no "no sort" option is offered
setFilters({
...filters,
sort: buildSort(next.key as SortKey, next.dir),
seed: next.key === "shuffle" ? rollSeed() : undefined,
});
}}
label={t("feed.sortLabel")}
ascLabel={t("feed.dirAsc")}
descLabel={t("feed.dirDesc")}
trailing={
sortKey === "shuffle" ? (
<button
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
title={t("sidebar.reshuffle")}
aria-label={t("sidebar.reshuffle")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
<RefreshCw className="w-4 h-4" />
</button>
) : undefined
}
/>
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
options={FEED_VIEWS.map((id) => ({
id,
label: t("feed.view." + id),
icon: VIEW_ICON[id],
}))}
onChange={setView}
label={t("feed.viewLabel")}
// The mode's name only earns its place once the window is wide.
labelClassName="hidden xl:inline"
/>
</>
);
const toolbar = (
<div className="pb-3">
<div className="pb-1.5">
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
<FilterChipGroup
ariaLabel={t("feed.showLabel")}
options={SHOW_IDS.map((id) => ({
value: id,
label: t("sidebar.show." + id),
testId: `feed-show-${id}`,
}))}
value={filters.show}
onChange={(show) => setFilters({ ...filters, show })}
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
never shoves the controls beside it (see ItemCount). */}
<ItemCount count={summaryQuery.data?.count} />
{/* Watch state is single-select (always exactly one); content types are an independent
multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
<FilterSelect
label={t("feed.showLabel")}
testId="feed-show"
options={SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }))}
selected={[filters.show]}
onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })}
/>
<FilterSelect
label={t("feed.contentLabel")}
testId="feed-content"
multiple
options={CONTENT.map((c) => ({ value: c.key, label: t(c.label) }))}
selected={CONTENT.filter((c) => filters[c.key]).map((c) => c.key)}
onChange={(next) => {
const patch = { ...filters };
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
setFilters(patch);
}}
/>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
{/* Content-type toggles are MULTI-select (independent booleans) — same chip look via
filterChipClass, but not a single-select group. */}
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("feed.contentLabel")}>
{CONTENT.map((c) => {
const on = filters[c.key];
return (
<button
key={c.key}
data-testid={`feed-content-${c.key}`}
onClick={() => setFilters({ ...filters, [c.key]: !on })}
aria-pressed={on}
className={filterChipClass}
>
{t(c.label)}
</button>
);
})}
</div>
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept —
it selects how videos ENTERED the library. On a channel-scoped view we already show all
of this one channel's videos (librarySource is pinned to "all"), so the selector would
only offer a near-empty, confusing slice — hide it here. */}
{!channelScoped && (
<>
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
<span>{t("feed.source.label")}</span>
<select
value={filters.librarySource ?? "organic"}
onChange={(e) =>
setFilters({
...filters,
librarySource: e.target.value as FeedFilters["librarySource"],
})
}
title={t("feed.source.tip")}
className="bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
>
<option value="organic">{t("feed.source.organic")}</option>
<option value="all">{t("feed.source.all")}</option>
<option value="search">{t("feed.source.only")}</option>
</select>
</label>
</>
)}
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
{summaryQuery.data
? t("feed.videoCount", {
count: summaryQuery.data.count,
formattedCount: summaryQuery.data.count.toLocaleString(),
})
: " "}
</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
onChange={(next) => {
if (!next) return; // the feed is always sorted — no "no sort" option is offered
<FilterSelect
label={t("feed.source.label")}
testId="feed-source"
options={[
{ value: "organic", label: t("feed.source.organic") },
{ value: "all", label: t("feed.source.all") },
{ value: "search", label: t("feed.source.only") },
]}
selected={[filters.librarySource ?? "organic"]}
onChange={(next) =>
setFilters({
...filters,
sort: buildSort(next.key as SortKey, next.dir),
seed: next.key === "shuffle" ? rollSeed() : undefined,
});
}}
label={t("feed.sortLabel")}
ascLabel={t("feed.dirAsc")}
descLabel={t("feed.dirDesc")}
trailing={
sortKey === "shuffle" ? (
<button
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
title={t("sidebar.reshuffle")}
aria-label={t("sidebar.reshuffle")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
<RefreshCw className="w-4 h-4" />
</button>
) : undefined
librarySource: next[0] as FeedFilters["librarySource"],
})
}
/>
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
options={FEED_VIEWS.map((id) => ({
id,
label: t("feed.view." + id),
icon: VIEW_ICON[id],
}))}
onChange={setView}
label={t("feed.viewLabel")}
// This toolbar is packed — show/content chips, source, count, sort — so the mode's name
// only earns its place once the window is wide.
labelClassName="hidden xl:inline"
/>
</div>
)}
</div>
</div>
);
return (
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-2"}>
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-1"}>
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
@@ -692,19 +680,46 @@ export default function Feed({
baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you
applied" when it's just the page being itself. */}
<PageToolbar>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-4 pb-1"}>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
{toolbar}
{!channelScoped && (
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
/>
)}
{/* The controls row sits directly above the grid: the applied-filter chips flow from the left
(overflowing to a "+N"/count pill so they never crowd the controls), while Sort + View stay
pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you
applied"), but still get the right-pinned Sort/View. */}
<div className="flex items-center gap-2 pb-2">
{!channelScoped && (
<>
{/* The active personal view (if any) leads the applied-filter row. */}
<SelectedViewChip />
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
className="flex-1"
/>
</>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
</div>
</div>
</PageToolbar>
{items.length === 0 ? (
<div className="py-16 text-center text-muted">
<p>{t("feed.noMatches")}</p>
{/* A channel filter under the "Mine" scope returns nothing when that channel is hidden from
your subscriptions (or you don't subscribe to it) — its videos only live in the shared
Library. Point there rather than leaving a bare "no matches". */}
{filters.channelIds.length > 0 && filters.scope === "my" && (
<div className="mt-4">
<p className="text-sm">{t("feed.channelScopeHint")}</p>
<button
onClick={() => setFilters({ ...filters, scope: "all" })}
className="mt-3 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<Library className="w-4 h-4" />
{t("feed.viewInLibrary")}
</button>
</div>
)}
{filters.q.trim() && !isDemo && (
<button
onClick={() => onYtSearch(filters.q.trim())}
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
//
// loadAccountFilters is the localStorage seed path for the feed filters. It must migrate a persisted
// legacy global `tagMode` (older builds wrote it) into the per-category `tagModes` — the same coercion
// the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost.
import { describe, it, expect, beforeEach } from "vitest";
import { loadAccountFilters } from "./FeedFiltersProvider";
import { accountKey, LS } from "../lib/storage";
const ID = 7;
beforeEach(() => localStorage.clear());
describe("loadAccountFilters — legacy tagMode migration on seed", () => {
it("migrates a persisted legacy tagMode:'and' from the last-session blob into topic", () => {
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ q: "hi", tagMode: "and" }));
const f = loadAccountFilters(ID);
expect(f.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
expect(f.q).toBe("hi"); // other fields preserved through the merge
});
it("migrates a legacy tagMode:'and' from the default-view mirror when no last-session blob exists", () => {
localStorage.setItem(
accountKey(LS.defaultViewFilters, ID)!,
JSON.stringify({ tagMode: "and" }),
);
expect(loadAccountFilters(ID).tagModes.topic).toBe("and");
});
it("leaves tagModes at defaults when nothing is stored", () => {
expect(loadAccountFilters(ID).tagModes).toEqual({ language: "or", topic: "or", other: "or" });
});
it("preserves an already-migrated per-category tagModes blob", () => {
localStorage.setItem(
accountKey(LS.filters, ID)!,
JSON.stringify({ tagModes: { language: "and", topic: "or", other: "and" } }),
);
expect(loadAccountFilters(ID).tagModes).toEqual({
language: "and",
topic: "or",
other: "and",
});
});
});
@@ -9,10 +9,16 @@ import {
} from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
import {
api,
getActiveAccount,
coerceTagModes,
DEFAULT_TAG_MODES,
type FeedFilters,
} from "../lib/api";
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror";
import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage";
import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
import { useNavigation } from "./NavigationProvider";
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
@@ -32,7 +38,7 @@ import { useNavigation } from "./NavigationProvider";
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
@@ -49,7 +55,7 @@ const FILTERS_KEY = LS.filters;
// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
// post-login effect loads the real ones once the id arrives.
function loadAccountFilters(id: number | null): FeedFilters {
export function loadAccountFilters(id: number | null): FeedFilters {
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The
// starred default view only SEEDS a fresh account that has never stored filters: it drives the
@@ -62,10 +68,19 @@ function loadAccountFilters(id: number | null): FeedFilters {
} catch {
/* localStorage may be unavailable */
}
if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS);
// Migrate a persisted legacy global `tagMode` into the per-category `tagModes` on every seed path,
// matching what the saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes
// MUST run on the RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects
// DEFAULT_TAG_MODES (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
if (hasStored && fKey) {
const raw = readJSON<Partial<FeedFilters>>(fKey, {});
return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) };
}
const dvKey = accountKey(LS.defaultViewFilters, id);
const def = dvKey ? readJSON<FeedFilters | null>(dvKey, null) : null;
if (def) return { ...DEFAULT_FILTERS, ...def };
const def = dvKey ? readJSON<Partial<FeedFilters> | null>(dvKey, null) : null;
if (def) {
return { ...DEFAULT_FILTERS, ...def, tagModes: coerceTagModes(def) };
}
return { ...DEFAULT_FILTERS };
}
@@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import { Library, User } from "lucide-react";
import SegmentedToggle from "./SegmentedToggle";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
// The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header
// search box — mirroring PlexScopeToggle's placement, so the primary "what am I browsing" control is
// always in reach instead of buried in the (possibly collapsed) filter rail. Same `filters.scope`
// field, so behaviour is unchanged. The `pointer-events-auto`/`shrink-0` wrapper matches the other
// header pills: the header band is `pointer-events-none`, and the pill must not collapse in the flex row.
export default function FeedScopeToggle() {
const { t } = useTranslation();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
return (
<div className="pointer-events-auto shrink-0">
<SegmentedToggle
size="md"
ariaLabel={t("header.scope.label")}
options={[
{
value: "my",
label: t("header.scope.my"),
icon: <User className="w-3.5 h-3.5" />,
title: t("header.scope.myTip"),
},
{
value: "all",
label: t("header.scope.all"),
icon: <Library className="w-3.5 h-3.5" />,
title: t("header.scope.allTip"),
},
]}
value={filters.scope}
onChange={(scope) => setFilters({ ...filters, scope })}
/>
</div>
);
}
+22 -8
View File
@@ -8,10 +8,9 @@ import PanelGroups, { type PanelItem } from "./PanelGroups";
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
// the layout default, edit hint); each rail supplies only what actually differs — its identity
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content above the
// groups (`top`, e.g. the feed's scope toggle) or below (`children`, e.g. the feed's tag manager).
// `kind` is the PanelId (also the layout key), so `top`/`children` default to nothing for the rails
// that need neither.
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content below the
// groups (`children`, e.g. the feed's tag manager). `kind` is the PanelId (also the layout key), so
// `children` defaults to nothing for the rails that need none.
export default function FilterRail({
kind,
title,
@@ -23,7 +22,10 @@ export default function FilterRail({
layout,
setLayout,
items,
top,
collapsible = true,
iconExpanded,
onTitleClick,
titleActionLabel,
children,
}: {
kind: PanelId;
@@ -36,7 +38,11 @@ export default function FilterRail({
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
items: PanelItem[];
top?: ReactNode;
/** Passed to PanelGroups: false renders plain always-open label + control rows (no collapse). */
collapsible?: boolean;
iconExpanded?: ReactNode;
onTitleClick?: () => void;
titleActionLabel?: string;
children?: ReactNode;
}) {
const { t } = useTranslation();
@@ -53,9 +59,17 @@ export default function FilterRail({
onReset={() => setLayout(defaultLayout(kind))}
editHint={t("sidebar.editHint")}
actions={actions}
iconExpanded={iconExpanded}
onTitleClick={onTitleClick}
titleActionLabel={titleActionLabel}
>
{top}
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
<PanelGroups
items={items}
layout={layout}
setLayout={setLayout}
editing={editing}
collapsible={collapsible}
/>
{children}
</SidePanel>
);
@@ -0,0 +1,92 @@
// @vitest-environment jsdom
//
// Contract of the labeled-pill FilterSelect. Labels are plain props (no i18n). Built on the floating-ui
// AnchoredPopover platform, so we stub the two observers autoUpdate wires and wrap in a FloatingTree.
// floating-ui never positions in jsdom → the surface stays visibility:hidden, so the menuitem role
// queries pass `{ hidden: true }`. Single-select rows are `menuitemradio`, multi-select `menuitemcheckbox`
// (a menu surface with checkable items, not a listbox of orphaned options).
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import FilterSelect, { type FilterSelectOption } from "./FilterSelect";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(cleanup);
const STATUS: FilterSelectOption[] = [
{ value: "all", label: "All" },
{ value: "new", label: "New" },
];
const TYPES: FilterSelectOption[] = [
{ value: "n", label: "Normal" },
{ value: "s", label: "Shorts" },
];
const tree = (ui: React.ReactNode) => render(<FloatingTree>{ui}</FloatingTree>);
describe("FilterSelect", () => {
it("renders a labeled trigger `Label: value` and keeps options unmounted until opened", () => {
tree(<FilterSelect label="Status" options={STATUS} selected={["all"]} onChange={vi.fn()} />);
const trigger = screen.getByRole("button", { name: /^Status/ });
expect(trigger.textContent).toContain("Status:");
expect(trigger.textContent).toContain("All");
expect(screen.queryByRole("menuitemradio", { hidden: true })).toBeNull();
});
it("single: picking a row replaces the selection and closes", () => {
const onChange = vi.fn();
tree(<FilterSelect label="Status" options={STATUS} selected={["all"]} onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: /^Status/ }));
const rows = screen.getAllByRole("menuitemradio", { hidden: true });
expect(rows.map((o) => o.textContent)).toEqual(["All", "New"]);
expect(rows[0]!.getAttribute("aria-checked")).toBe("true"); // "All" is the current selection
fireEvent.click(rows[1]!); // New
expect(onChange).toHaveBeenCalledWith(["new"]);
expect(screen.queryByRole("menuitemradio", { hidden: true })).toBeNull();
});
it("multiple: toggling a row adds it and keeps the panel open", () => {
const onChange = vi.fn();
tree(
<FilterSelect label="Type" multiple options={TYPES} selected={["n"]} onChange={onChange} />,
);
fireEvent.click(screen.getByRole("button", { name: /^Type/ }));
fireEvent.click(screen.getAllByRole("menuitemcheckbox", { hidden: true })[1]!); // Shorts
expect(onChange).toHaveBeenCalledWith(["n", "s"]);
expect(screen.queryAllByRole("menuitemcheckbox", { hidden: true }).length).toBeGreaterThan(0); // still open
});
it("multiple + requireOne: the last remaining selection can't be turned off", () => {
const onChange = vi.fn();
tree(
<FilterSelect label="Type" multiple options={TYPES} selected={["n"]} onChange={onChange} />,
);
fireEvent.click(screen.getByRole("button", { name: /^Type/ }));
fireEvent.click(screen.getAllByRole("menuitemcheckbox", { hidden: true })[0]!); // Normal — the only one selected
expect(onChange).not.toHaveBeenCalled();
});
it("multiple: the trigger summarises extra picks as `First +N`", () => {
tree(
<FilterSelect
label="Type"
multiple
options={TYPES}
selected={["n", "s"]}
onChange={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: /^Type/ }).textContent).toContain("Normal +1");
});
});
+142
View File
@@ -0,0 +1,142 @@
import { useState } from "react";
import { ChevronDown, Check } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A compact "labeled pill → dropdown" filter control: the trigger reads `Label: value ▾`, and clicking
// it opens a menu of options (facet counts optional). It replaces a wall of individual toolbar chips
// with one pill per filter group, in two flavours:
// - single (default): picking a row replaces the choice and closes — always exactly one active.
// - multiple: rows toggle independently and the panel stays open; `requireOne` blocks clearing the last.
// Built on the same AnchoredPopover platform as ActionMenu/Combobox (arrows/typeahead/Escape/anchor-flip);
// reusable anywhere a small, known set of options currently renders as a chip group. See FilterSelect.dom.test.
export interface FilterSelectOption {
value: string;
label: string;
/** A facet count shown at the row's end. */
count?: number;
disabled?: boolean;
}
export interface FilterSelectProps {
/** Visible group name shown as the trigger prefix (`Label: value`) and the accessible name. */
label: string;
options: readonly FilterSelectOption[];
selected: string[];
onChange: (next: string[]) => void;
/** Toggle a subset (default false = pick exactly one). */
multiple?: boolean;
/** Multi only: keep at least one selected — the last active row can't be turned off. Default true. */
requireOne?: boolean;
/** Test hook: the trigger gets `data-testid={testId}`; each option gets `${testId}-${value}`. */
testId?: string;
}
export default function FilterSelect({
label,
options,
selected,
onChange,
multiple = false,
requireOne = true,
testId,
}: FilterSelectProps) {
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
});
const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? v;
// Summarise the trigger value. Multi lists the first chosen (in options order, so it's stable) + "+N".
const chosen = options.filter((o) => selected.includes(o.value));
const first = chosen[0];
const summary = multiple
? first
? `${first.label}${chosen.length > 1 ? ` +${chosen.length - 1}` : ""}`
: "—"
: labelOf(selected[0] ?? options[0]?.value ?? "");
const commit = (value: string) => {
if (!multiple) {
onChange([value]);
setOpen(false);
return;
}
const has = selected.includes(value);
if (has) {
if (requireOne && selected.length === 1) return; // don't let the last one go
onChange(selected.filter((x) => x !== value));
} else {
onChange([...selected, value]);
}
};
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
data-testid={testId}
// Carry the current value in the accessible name (not just the group label), matching
// ViewSwitcher — a screen reader announces "Watch state: Unwatched", not a bare "Watch state".
aria-label={`${label}: ${summary}`}
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
<span className="text-muted">{label}:</span>
<span className="text-fg">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
{/* Single = one exclusive choice (radios), multi = independent toggles (checkboxes). Both are
menuitem-family roles scoped by a group, so AT announces the checked state — the surface is a
menu (see the mode above), never a bare listbox with orphaned options. */}
<div role="group" aria-label={label}>
{options.map((o) => {
const on = selected.includes(o.value);
return (
<PopoverItem
key={o.value}
role={multiple ? "menuitemcheckbox" : "menuitemradio"}
checked={on}
disabled={o.disabled}
onSelect={() => commit(o.value)}
label={o.label}
data-testid={testId ? `${testId}-${o.value}` : undefined}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
{multiple ? (
<span
className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border ${
on ? "border-accent bg-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="h-2.5 w-2.5" />}
</span>
) : (
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
)}
<span className="flex-1 truncate">{o.label}</span>
{o.count != null && (
<span className="shrink-0 text-2xs tabular-nums text-muted">{o.count}</span>
)}
</PopoverItem>
);
})}
</div>
</PopoverSurface>
</>
);
}
+14 -22
View File
@@ -2,20 +2,18 @@ import { forwardRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Youtube } from "lucide-react";
import type { Me } from "../lib/api";
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
import ModuleName from "./ModuleName";
import SearchBar from "./SearchBar";
import PlexSearchBox from "./PlexSearchBox";
import PlexScopeToggle from "./PlexScopeToggle";
import FeedScopeToggle from "./FeedScopeToggle";
import SyncStatus from "./SyncStatus";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import { useNavigation } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useChannels, useChannelsActions } from "./ChannelsProvider";
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
// sync chip at the right end.
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
// has one, and the per-user sync chip at the right end.
// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly
// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which
// owns both the header slot and the padded content wrapper (E5 S7).
@@ -30,7 +28,6 @@ const Header = forwardRef<
>(function Header({ me, onYtSearch }, ref) {
const { t } = useTranslation();
const { page } = useNavigation();
const { setPage } = useNavigationActions();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// Each module's search term lives in its own provider (shared with that module's rail/content):
@@ -40,12 +37,6 @@ const Header = forwardRef<
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
const { search: channelsSearch } = useChannels();
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
// it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
// dynamic width so the pill is sized to the widest reachable title in the current language.
const order = moduleOrder(me);
const moduleLabels = order.map((p) => t(moduleLabelKey[p]));
const multiModule = order.length > 1;
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
@@ -99,17 +90,18 @@ const Header = forwardRef<
// slim and/or no filter sidebar is present — no hard-coded offset.
<div
ref={ref}
className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2"
// left-4 / right = 1rem + the scrollbar gutter defines the full content column; max-w-[--content-mw]
// + mx-auto then centres the header band on the SAME width the page's content uses, so on a max-width
// page (Plex/Channels/Playlists) the band lines up with the centred grid, not the whole column. `none`
// (feed) = full width. Matches the toolbar band below it. `justify-center` then centres the search-box
// group (search + scope toggle / sync chip) within that band, so the box sits centred over the cards
// once the column is wider than the box's max-w — not hugged to the left edge.
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] mx-auto max-w-[var(--content-mw,none)] z-chrome flex flex-wrap items-center justify-center gap-x-3 gap-y-2"
>
<ModuleName
label={t(moduleLabelKey[page])}
labels={moduleLabels}
onPrev={() => setPage(stepModule(me, page, -1))}
onNext={() => setPage(stepModule(me, page, 1))}
canPrev={multiModule}
canNext={multiModule}
/>
{search}
{/* Mine/Library scope sits right of the feed search box — relocated here from the filter sidebar
so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
{page === "feed" && <FeedScopeToggle />}
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. */}
{page === "plex" && me.plex_enabled && <PlexScopeToggle />}
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
//
// Contract of the fixed-width count pill. Uses the REAL i18n instance (not a key-passthrough) so the
// plural selection (itemCount_one/_other) and the "1M+" overflow interpolation are actually exercised —
// those are the two branches worth guarding. English is forced so the singular/plural strings are stable.
import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import "../i18n"; // side-effect: initialise the shared i18next instance
import i18n from "i18next";
import ItemCount from "./ItemCount";
beforeAll(async () => {
await i18n.changeLanguage("en");
});
afterEach(cleanup);
const text = () => screen.getByTestId("item-count").textContent;
describe("ItemCount", () => {
it("uses the singular string at 1 and the plural above it", () => {
render(<ItemCount count={1} />);
expect(text()).toBe("1 item");
});
it("pluralises and thousands-separates a normal count", () => {
render(<ItemCount count={192045} />);
// Separators come from toLocaleString (runtime-locale dependent) — compare against it, not a fixed
// en-US string, so the test asserts ItemCount's formatting path rather than the CI locale.
expect(text()).toBe(`${(192045).toLocaleString()} items`);
});
it("collapses a count of a million or more to 1M+, with the exact figure in the tooltip", () => {
render(<ItemCount count={1_000_000} />);
expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent
expect(screen.getByTestId("item-count").getAttribute("title")).toBe(
(1_000_000).toLocaleString(),
);
});
it("shows the icon-only fixed box (empty label) while the count is undefined", () => {
render(<ItemCount />);
expect(text()).toBe(""); // the Layers icon carries no text
expect(screen.getByTestId("item-count").getAttribute("title")).toBeNull();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { useTranslation } from "react-i18next";
import { Layers } from "lucide-react";
// At a million and above we stop printing the exact figure — six digits is the readable ceiling, so
// the pill shows "1M+" and the tooltip carries the precise number.
const OVERFLOW_AT = 1_000_000;
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count in the same glass pill as
// the filter dropdowns, minus the chevron and the interactivity. The fixed width is the point — a
// changing number must never shove the controls beside it, so the box is sized to the widest value
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
// so any surface can reuse it for whatever it counts.
export default function ItemCount({ count }: { count?: number }) {
const { t } = useTranslation();
// `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just
// show the icon until the number arrives.
const label =
count == null
? ""
: t("common.itemCount", {
count,
formattedCount: count >= OVERFLOW_AT ? "1M+" : count.toLocaleString(),
});
return (
<span
data-testid="item-count"
title={count?.toLocaleString()}
className="glass-card inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-muted min-w-[9rem] whitespace-nowrap"
>
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="tabular-nums">{label}</span>
</span>
);
}
-90
View File
@@ -1,90 +0,0 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react";
// The floating module-name pill in the top header. The label area is sized to the WIDEST
// reachable module title (measured live, in the current language only — so dropping a locale or
// gaining/losing a module just changes the input, nothing is hard-coded), which keeps the label
// column a constant width: the name never shifts and the arrows stay put when paging between
// modules. Carries the back/forward arrows that step cyclically through the active modules, plus
// the accent dot (the former PageTitle "eyebrow" look, folded in here).
export default function ModuleName({
label,
labels,
onPrev,
onNext,
canPrev,
canNext,
}: {
label: string;
// Every reachable module's title in the current language — drives the fixed label width.
labels: string[];
onPrev: () => void;
onNext: () => void;
canPrev: boolean;
canNext: boolean;
}) {
const { t } = useTranslation();
const labelRef = useRef<HTMLSpanElement>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [labelW, setLabelW] = useState<number>();
// Measure the widest label using the label span's own rendered font (respects font-scale,
// tracking and the uppercase transform). Re-runs whenever the label set changes — i.e. on a
// language switch or an account switch that changes which modules are reachable.
useLayoutEffect(() => {
const el = labelRef.current;
if (!el || labels.length === 0) return;
const cs = getComputedStyle(el);
const canvas = canvasRef.current ?? (canvasRef.current = document.createElement("canvas"));
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
const ls = parseFloat(cs.letterSpacing) || 0;
const upper = cs.textTransform === "uppercase";
let max = 0;
for (const s of labels) {
const txt = upper ? s.toUpperCase() : s;
const w = ctx.measureText(txt).width + ls * txt.length;
if (w > max) max = w;
}
setLabelW(Math.ceil(max) + 2);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [labels.join("")]);
const arrow =
"p-1 rounded-lg text-muted transition hover:text-fg hover:bg-card disabled:opacity-25 disabled:pointer-events-none";
return (
<div className="pointer-events-auto shrink-0 max-w-[55vw] h-11 px-2.5 rounded-2xl glass-menu flex items-center gap-1.5">
<button
onClick={onPrev}
disabled={!canPrev}
title={t("header.prevModule")}
aria-label={t("header.prevModule")}
className={arrow}
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={onNext}
disabled={!canNext}
title={t("header.nextModule")}
aria-label={t("header.nextModule")}
className={arrow}
>
<ChevronRight className="w-4 h-4" />
</button>
<span className="w-px h-5 bg-border/70 mx-1 shrink-0" />
<span className="min-w-0 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-fg">
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" />
<span
ref={labelRef}
className="min-w-0 truncate"
style={labelW ? { width: labelW } : undefined}
>
{label}
</span>
</span>
</div>
);
}
@@ -123,7 +123,7 @@ export default function NotificationsPanel() {
<>
{/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl p-4 flex items-center gap-3">
<Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
+87 -1
View File
@@ -43,11 +43,97 @@ describe("PageShell --hdr-h (E5 S7)", () => {
Object.defineProperty(hdr, "offsetTop", { value: 12, configurable: true });
Object.defineProperty(hdr, "offsetHeight", { value: 96, configurable: true });
roCb?.();
expect(content.style.getPropertyValue("--hdr-h")).toBe("116px"); // 12 + 96 + 8
expect(content.style.getPropertyValue("--hdr-h")).toBe("112px"); // 12 + 96 + 4
});
it("adds no `--hdr-h` pad when there is no header", () => {
const { container } = render(<PageShell>content</PageShell>);
expect(container.querySelector('[class*="pt-(--hdr-h)"]')).toBeNull();
});
it("re-measures when the header is removed then restored (feed→channel→back)", () => {
// PageShell is REUSED across feed↔channel navigation (same component position in App.tsx) — it
// never remounts, so the header DOM node is swapped under a stable PageShell. The measurement
// must re-run on that swap; a one-shot effect kept observing the FIRST node and, when it detached,
// rewrote --hdr-h to a stale 4px that stuck on Back. Regression for the channel-Back header overlap.
const hdr = () => <div data-testid="hdr">H</div>;
const { container, rerender } = render(<PageShell header={hdr()}>content</PageShell>);
const measure = (top: number, h: number) => {
const el = container.querySelector('[data-testid="hdr"]') as HTMLElement;
Object.defineProperty(el, "offsetTop", { value: top, configurable: true });
Object.defineProperty(el, "offsetHeight", { value: h, configurable: true });
roCb?.();
};
measure(12, 52);
const content = () => container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement;
expect(content().style.getPropertyValue("--hdr-h")).toBe("68px"); // 12 + 52 + 4
rerender(<PageShell>content</PageShell>); // → channel page: header gone
rerender(<PageShell header={hdr()}>content</PageShell>); // → Back: header returns as a NEW node
measure(12, 52); // fires the NEW header's observer
expect(content().style.getPropertyValue("--hdr-h")).toBe("68px"); // re-measured, not the stale 4px
});
});
// The pinned-rail overlay decision: a rail pins as OVERLAY (--rail-pinned-w 2.25rem, the toolbar keeps a
// slim gutter and content stays put) once the window is wide enough that the centred content leaves room
// for the rail in its left margin (maxWidth + ~36rem); below that it pins in-flow (16.75rem). The rem→px
// conversion must use the LIVE root font-size, not a hardcoded 16, so it stays correct at every text size.
describe("PageShell --rail-pinned-w / --content-mw", () => {
const root = document.documentElement;
const setWidth = (w: number) =>
Object.defineProperty(window, "innerWidth", { value: w, configurable: true });
afterEach(() => {
root.style.removeProperty("font-size");
root.style.removeProperty("--content-mw");
root.style.removeProperty("--rail-pinned-w");
});
it("overlays the rail once the window clears maxWidth + 36rem (at 16px/rem)", () => {
setWidth(1600); // 64rem*16 + 36rem*16 = 1024 + 576 = 1600 → exactly roomy
render(<PageShell maxWidth="64rem">content</PageShell>);
expect(root.style.getPropertyValue("--content-mw")).toBe("64rem");
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("2.25rem");
});
it("keeps the rail in-flow just below the threshold", () => {
setWidth(1599);
render(<PageShell maxWidth="64rem">content</PageShell>);
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
});
it("never overlays on a full-width page (no maxWidth)", () => {
setWidth(3000);
render(<PageShell>content</PageShell>);
expect(root.style.getPropertyValue("--content-mw")).toBe("none");
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
});
it("recomputes on resize", () => {
setWidth(1599);
render(<PageShell maxWidth="64rem">content</PageShell>);
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
setWidth(1600);
window.dispatchEvent(new Event("resize"));
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("2.25rem");
});
it("scales the rem threshold by the live root font-size, not a hardcoded 16", () => {
root.style.fontSize = "17px"; // as the --font-scale 1.06 default renders it
setWidth(1650); // roomy at 16px/rem (>1600) but NOT at 17px/rem (needs 100rem*17 = 1700)
render(<PageShell maxWidth="64rem">content</PageShell>);
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
});
it("recomputes at runtime when --font-scale changes the root font, without looping", async () => {
setWidth(1650); // roomy at 16px/rem (1650 >= 1600) → overlay
render(<PageShell maxWidth="64rem">content</PageShell>);
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("2.25rem");
// The text-size slider changes the root font-size with NO resize event — the MutationObserver
// must recompute. At 17px/rem the threshold is 100rem*17 = 1700 > 1650, so the rail goes in-flow.
// (If the observer's self-write guard were broken this would loop forever and time out.)
root.style.fontSize = "17px";
await new Promise((r) => setTimeout(r, 0)); // flush the MutationObserver microtask
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
});
});
+73 -20
View File
@@ -4,7 +4,6 @@ import {
isValidElement,
useContext,
useLayoutEffect,
useRef,
useState,
type ReactElement,
type ReactNode,
@@ -53,6 +52,7 @@ export default function PageShell({
children,
scrollKey,
fadeTop = true,
maxWidth,
}: {
header?: ReactNode;
banners?: ReactNode;
@@ -61,43 +61,96 @@ export default function PageShell({
scrollKey?: string;
// Off when this page's scroll region starts with a sticky header — see PageScroller.
fadeTop?: boolean;
// The content column's max width (e.g. "1600px"); the header + toolbar centre on the same width via
// --content-mw. Omit for a full-width page (feed). See index.css --content-mw.
maxWidth?: string;
}) {
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
// Publish this page's content max-width so the floating header (and toolbar band) can centre on the
// same width the content column uses. Global (one page shell renders at a time). Also decide whether
// a PINNED filter rail should stay OVERLAY (keep the toolbar's fixed leading gutter, don't push the
// centred content) or go in-flow: overlay only when the centred content leaves a wide-enough left
// margin for the rail to sit in — else pinning would overlap the content, so it pushes (in-flow).
useLayoutEffect(() => {
const root = document.documentElement;
root.style.setProperty("--content-mw", maxWidth ?? "none");
// Room the pinned rail needs: its own width (~16rem) on each side of the centred content, plus the
// nav rail (~4rem) = ~36rem. Below that, or on a full-width page (no maxWidth), pin in-flow (w-67).
let lastPxPerRem = 0;
const apply = () => {
// Convert the rem maxWidth (and the 36rem slack) against the LIVE root font-size, not a hardcoded
// 16: index.css scales the root by --font-scale (default 1.06 ≈ 17px, slider 0.9–1.3), and the page's
// own content wrapper is a rem `max-w-*`, so a fixed 16 mis-sizes the threshold at every text size.
const pxPerRem = parseFloat(getComputedStyle(root).fontSize) || 16;
lastPxPerRem = pxPerRem;
const mwPx = maxWidth
? maxWidth.endsWith("rem")
? parseFloat(maxWidth) * pxPerRem
: parseFloat(maxWidth)
: null;
const roomy = mwPx != null && window.innerWidth >= mwPx + 36 * pxPerRem;
root.style.setProperty("--rail-pinned-w", roomy ? "2.25rem" : "16.75rem");
};
apply();
window.addEventListener("resize", apply);
// The text-size slider changes --font-scale (hence the root font-size) WITHOUT firing resize, so the
// rem→px threshold would go stale. Re-run when the root font actually changes; the pxPerRem guard keeps
// our own --rail-pinned-w write (a rem string, no font change) from re-triggering the observer.
const fontObserver = new MutationObserver(() => {
if ((parseFloat(getComputedStyle(root).fontSize) || 16) !== lastPxPerRem) apply();
});
fontObserver.observe(root, { attributes: true, attributeFilter: ["style"] });
return () => {
window.removeEventListener("resize", apply);
fontObserver.disconnect();
root.style.removeProperty("--content-mw");
root.style.removeProperty("--rail-pinned-w");
};
}, [maxWidth]);
// The floating header pads the content below it by `--hdr-h`. That must equal the header's REAL
// height, which grows as the flex-wrap header wraps to 2–3 rows on a narrow window — a fixed pad
// would let the content toolbar slide up under it (E5 S7). Measure the header (a ref forwarded onto
// its root box) and write its height straight onto the content wrapper that consumes `--hdr-h`, so
// the two live together with no assumption about the header's DOM parent.
const headerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
// would let the content toolbar slide up under it (E5 S7). Measure the header and write its height
// straight onto the content wrapper that consumes `--hdr-h`.
//
// Both nodes are held in STATE (callback refs), NOT plain refs behind a one-shot `useLayoutEffect([])`:
// PageShell is reused — never remounted — across feed↔channel navigation (same component position in
// App.tsx), so the header DOM node is swapped out from under a stable PageShell. A one-shot effect keeps
// observing the FIRST header node; when that node detaches on channel-open, its ResizeObserver fires with
// a 0 box and rewrites --hdr-h to `0+0+4`=4px on the (still-mounted) content div. On Back the pad class
// returns with that stale 4px → the content toolbar slides up under the header, stuck until reload. Keying
// the measurement to the live node re-runs it every time the header mounts/unmounts, so it stays correct.
const [headerEl, setHeaderEl] = useState<HTMLDivElement | null>(null);
const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const h = headerRef.current;
const c = contentRef.current;
if (!h || !c) return;
// `+ 8` = the header's own gap-y-2 row gap (0.5rem), reused as the gap below it.
const apply = () => c.style.setProperty("--hdr-h", `${h.offsetTop + h.offsetHeight + 8}px`);
if (!headerEl || !contentEl) {
// No header (channel page) → drop any stale pad so a leftover value can't linger.
contentEl?.style.removeProperty("--hdr-h");
return;
}
// `+ 4` = a tight gap below the floating header before the content toolbar starts.
const apply = () =>
contentEl.style.setProperty("--hdr-h", `${headerEl.offsetTop + headerEl.offsetHeight + 4}px`);
apply();
const ro = new ResizeObserver(apply);
ro.observe(h);
return () => {
ro.disconnect();
c.style.removeProperty("--hdr-h");
};
}, []);
ro.observe(headerEl);
return () => ro.disconnect();
}, [headerEl, contentEl]);
const measuredHeader = isValidElement(header)
? cloneElement(header as ReactElement<{ ref?: Ref<HTMLDivElement> }>, {
ref: headerRef,
ref: setHeaderEl,
})
: header;
return (
<PageToolbarSlot.Provider value={toolbarSlot}>
{measuredHeader}
<div
ref={contentRef}
ref={setContentEl}
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
>
{banners}
<div ref={setToolbarSlot} className="shrink-0" />
{/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the
scrollbar-gutter's width past the scrolled cards on the right. Pad it back to card width. */}
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
{children}
</PageScroller>
+51 -21
View File
@@ -1,29 +1,37 @@
import { type ReactNode, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Eye, EyeOff, GripVertical } from "lucide-react";
import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// One collapsible "island" card inside a SidePanel: a titled glass-card with per-group collapse,
// and — in the panel's edit mode — a drag handle (reorder) and a hide toggle. Generalized from
// the feed sidebar's WidgetCard so all three panels share the same island look and behavior.
// One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag
// handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three
// panels share the same island look and behavior. `collapsible` groups (the default) get a per-group
// collapse chevron; non-collapsible groups render as a plain always-open label + control row, and
// their title can instead act as a trigger via `onTitleClick` (e.g. feed "Your tags" -> tag manager).
export default function PanelGroup({
id,
title,
editing,
collapsible = true,
collapsed,
hidden,
onToggleCollapse,
onToggleHidden,
onTitleClick,
titleActionLabel,
children,
}: {
id: string;
title: string;
editing: boolean;
collapsible?: boolean;
collapsed: boolean;
hidden: boolean;
onToggleCollapse: () => void;
onToggleHidden: () => void;
onTitleClick?: () => void;
titleActionLabel?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
@@ -32,7 +40,13 @@ export default function PanelGroup({
disabled: !editing,
});
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };
const showBody = !editing && !collapsed;
// Non-collapsible groups are always open (outside edit mode); collapsible ones honour `collapsed`.
const showBody = !editing && (!collapsible || !collapsed);
// A non-collapsible group's title acts as a trigger when one is provided.
const titleIsTrigger = !editing && !collapsible && !!onTitleClick;
const titleCls =
"flex-1 min-w-0 flex items-center gap-1 text-left select-none text-sm uppercase tracking-wide";
return (
<div
@@ -53,13 +67,27 @@ export default function PanelGroup({
<GripVertical className="w-4 h-4" />
</button>
)}
<button
onClick={editing ? undefined : onToggleCollapse}
disabled={editing}
className="flex-1 min-w-0 flex items-center text-left select-none text-xs uppercase tracking-wide text-muted enabled:hover:text-fg transition"
>
<span className="truncate">{title}</span>
</button>
{collapsible && !editing ? (
<button
onClick={onToggleCollapse}
className={`${titleCls} text-muted hover:text-fg transition`}
>
<span className="truncate">{title}</span>
</button>
) : titleIsTrigger ? (
<button
onClick={onTitleClick}
title={titleActionLabel}
className={`${titleCls} group text-muted hover:text-accent transition`}
>
<span className="truncate">{title}</span>
<Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" />
</button>
) : (
<span className={`${titleCls} text-muted`}>
<span className="truncate">{title}</span>
</span>
)}
{editing ? (
<button
onClick={onToggleHidden}
@@ -69,15 +97,17 @@ export default function PanelGroup({
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
) : (
<button
onClick={onToggleCollapse}
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
className="text-muted hover:text-fg"
>
<ChevronDown
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
/>
</button>
collapsible && (
<button
onClick={onToggleCollapse}
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
className="text-muted hover:text-fg"
>
<ChevronDown
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
/>
</button>
)
)}
</div>
{showBody && <div className="px-3 pb-3">{children}</div>}
+17 -1
View File
@@ -17,7 +17,16 @@ import {
import type { PanelLayout } from "../lib/panelLayout";
import PanelGroup from "./PanelGroup";
export type PanelItem = { id: string; title: string; body: ReactNode };
export type PanelItem = {
id: string;
title: string;
body: ReactNode;
/** When set (and the panel is non-collapsible), the group's title becomes a button firing this —
* e.g. the feed's "Your tags" opens the tag manager. */
onTitleClick?: () => void;
/** Accessible name / tooltip for the title action (what onTitleClick does). */
titleActionLabel?: string;
};
// Renders a panel's islands in the user's saved order, with drag-to-reorder + hide + per-group
// collapse driven by `layout` (see lib/panelLayout). `items` holds only the currently AVAILABLE
@@ -29,11 +38,15 @@ export default function PanelGroups({
layout,
setLayout,
editing,
collapsible = true,
}: {
items: PanelItem[];
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
editing: boolean;
/** When false, groups render as plain (always-open) label + control rows — no collapse chevron.
* Reorder + hide (in edit mode) stay. Default true (the collapsible-island look). */
collapsible?: boolean;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
@@ -76,10 +89,13 @@ export default function PanelGroups({
id={id}
title={byId.get(id)!.title}
editing={editing}
collapsible={collapsible}
collapsed={!!layout.collapsed[id]}
hidden={!!layout.hidden[id]}
onToggleCollapse={() => toggleCollapse(id)}
onToggleHidden={() => toggleHidden(id)}
onTitleClick={byId.get(id)!.onTitleClick}
titleActionLabel={byId.get(id)!.titleActionLabel}
>
{byId.get(id)!.body}
</PanelGroup>
@@ -0,0 +1,222 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { BookmarkPlus, GripVertical, Play, Star, Trash2 } from "lucide-react";
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { SavedView } from "../lib/api";
import Modal from "./Modal";
import { useSavedViews, asFilters } from "./SavedViewsProvider";
import { useActiveFilters } from "../lib/useActiveFilters";
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
// reorder) plus a "save the current filters" input; the right column shows the selected view's name,
// what filters it captures, its default flag, apply, and delete. Opened from the filter panel's title
// or the saved-views popover; all mutations flow through SavedViewsProvider.
function ViewRow({
view,
selected,
active,
onSelect,
}: {
view: SavedView;
selected: boolean;
active: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: view.id,
});
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={`flex items-center gap-1.5 rounded-lg pr-1.5 ${isDragging ? "opacity-60" : ""} ${
selected ? "bg-accent/15" : "hover:bg-card"
}`}
>
<button
{...attributes}
{...listeners}
aria-label={t("views.reorder")}
className="cursor-grab p-1.5 text-muted hover:text-fg"
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
onClick={onSelect}
className={`flex-1 min-w-0 truncate py-1.5 text-left text-sm ${
selected ? "text-accent" : "text-fg"
}`}
>
{view.name}
</button>
{view.is_default && <Star className="h-3 w-3 shrink-0 fill-current text-accent" />}
{active && <span className="mr-1 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />}
</div>
);
}
export default function PersonalViewsModal() {
const { t } = useTranslation();
const { views, activeViewId, apply, create, rename, toggleDefault, remove, reorder, closeModal } =
useSavedViews();
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null);
const selected = views.find((v) => v.id === selectedId) ?? null;
const [draftName, setDraftName] = useState("");
// The selected view's captured filters, as the same human labels the toolbar chip row shows.
const captured = useActiveFilters(
selected ? asFilters(selected.filters) : ({ tags: [], channelIds: [] } as never),
() => {},
);
async function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const ids = views.map((v) => v.id);
const next = arrayMove(ids, ids.indexOf(active.id as number), ids.indexOf(over.id as number));
await reorder(next);
}
async function onCreate() {
const name = draftName.trim();
if (!name) return;
if (await create(name)) setDraftName("");
}
return (
<Modal title={t("views.manageTitle")} onClose={closeModal} maxWidth="max-w-2xl">
<div className="grid min-h-[20rem] grid-cols-[minmax(0,14rem)_1fr] gap-4">
{/* Left: the list + create */}
<div className="flex flex-col border-r border-border/60 pr-4">
<div className="flex-1 space-y-1 overflow-y-auto">
{views.length === 0 && (
<p className="px-1 py-2 text-2xs leading-snug text-muted">{t("views.empty")}</p>
)}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext
items={views.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
{views.map((v) => (
<ViewRow
key={v.id}
view={v}
selected={v.id === selectedId}
active={v.id === activeViewId}
onSelect={() => setSelectedId(v.id)}
/>
))}
</SortableContext>
</DndContext>
</div>
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 pt-2">
<input
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onCreate()}
placeholder={t("views.namePlaceholder")}
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/>
<button
onClick={onCreate}
disabled={!draftName.trim()}
title={t("views.save")}
className="inline-flex shrink-0 items-center gap-1 rounded-lg btn-primary px-2.5 py-1.5 text-sm font-semibold disabled:opacity-40"
>
<BookmarkPlus className="h-4 w-4" />
</button>
</div>
</div>
{/* Right: the selected view's detail */}
{selected ? (
<div className="flex flex-col gap-3">
<input
key={selected.id}
defaultValue={selected.name}
onBlur={(e) => {
const next = e.target.value.trim();
if (next && next !== selected.name) void rename(selected.id, next);
}}
className="rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/>
<div>
<div className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted">
{t("views.capturedFilters")}
</div>
{captured.length === 0 ? (
<p className="text-xs text-muted">{t("views.capturedNone")}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{captured.map((f) => (
<span
key={f.key}
className="rounded-full bg-accent/10 px-2 py-0.5 text-2xs text-accent"
>
{f.label}
</span>
))}
</div>
)}
</div>
<div className="mt-auto flex items-center gap-2 border-t border-border/60 pt-3">
<button
onClick={() => apply(selected)}
className="inline-flex items-center gap-1.5 rounded-lg btn-primary px-3 py-1.5 text-sm font-semibold"
>
<Play className="h-3.5 w-3.5" />
{t("views.apply")}
</button>
<button
onClick={() => void toggleDefault(selected)}
title={t("views.default")}
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition ${
selected.is_default
? "border-accent text-accent"
: "border-border text-muted hover:text-fg"
}`}
>
<Star className={`h-3.5 w-3.5 ${selected.is_default ? "fill-current" : ""}`} />
{t("views.default")}
</button>
<button
onClick={async () => {
const next = views.find((v) => v.id !== selected.id);
// Only move the selection if the delete actually happened (remove awaits a confirm
// and resolves false on cancel) — otherwise cancelling would still switch the pane.
if (await remove(selected)) setSelectedId(next?.id ?? null);
}}
title={t("views.delete")}
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-danger transition hover:border-danger"
>
<Trash2 className="h-3.5 w-3.5" />
{t("views.delete")}
</button>
</div>
</div>
) : (
<div className="flex items-center justify-center text-sm text-muted">
{t("views.selectPrompt")}
</div>
)}
</div>
</Modal>
);
}
+2 -2
View File
@@ -571,7 +571,7 @@ export default function Playlists() {
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-2">
<div className="px-4 pt-2 pb-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-start mb-4">
<div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
@@ -757,7 +757,7 @@ export default function Playlists() {
accessibility={{ screenReaderInstructions, announcements }}
>
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
<div className="max-w-5xl">
<div className="max-w-5xl mx-auto">
{/* table-fixed, not auto: every column but the title has a declared width, so the
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
min-content, and a nowrap title cell's min-content is the entire title. */}
+4 -10
View File
@@ -29,6 +29,7 @@ import SegmentedToggle from "./SegmentedToggle";
import PlexDiscover from "./PlexDiscover";
import { PageToolbar } from "./PageShell";
import ActiveFilterChips from "./ActiveFilterChips";
import ItemCount from "./ItemCount";
import { StateMessage } from "./QueryState";
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider";
@@ -388,18 +389,11 @@ export default function PlexBrowse() {
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
while only the poster grid scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto flex items-center gap-2">
<div className="px-4 pt-2 pb-1 max-w-[1600px] mx-auto flex items-center gap-1.5">
<ItemCount count={browseQ.isLoading ? undefined : total} />
{viewToggle}
<p className="text-xs text-muted">
{browseQ.isLoading
? " "
: dq
? t("plex.searchCount", { count: total })
: t("plex.count", { count: total })}
</p>
{/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */}
<div className="ml-auto flex items-center gap-2">
<span className="hidden sm:inline text-xs text-muted">{t("plex.filter.sort")}</span>
<div className="ml-auto flex items-center gap-1.5">
<SortControl
options={sortOptions}
sort={sortState}
@@ -0,0 +1,84 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Bookmark, Check, Settings2 } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { useSavedViews } from "./SavedViewsProvider";
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
// their trigger: click → a popover list of the user's saved views (apply on click, the active one
// checked) + a "Manage views…" entry that opens the Personal Views modal, where the editing lives.
export default function SavedViewsPopover({
trigger,
className,
ariaLabel,
placement = "bottom-start",
}: {
trigger: ReactNode;
className?: string;
ariaLabel?: string;
placement?: "bottom-start" | "bottom-end";
}) {
const { t } = useTranslation();
const { views, activeViewId, apply, openModal } = useSavedViews();
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement });
const label = ariaLabel ?? t("views.saved");
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={label}
className={className ?? "shrink-0 flex text-accent transition hover:text-fg"}
>
{trigger}
</button>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-56 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
{views.length === 0 ? (
<p className="px-2 py-1.5 text-2xs leading-snug text-muted">{t("views.empty")}</p>
) : (
<div role="group" aria-label={label}>
{views.map((v) => (
<PopoverItem
key={v.id}
role="menuitemradio"
checked={v.id === activeViewId}
onSelect={() => {
apply(v);
setOpen(false);
}}
label={v.name}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
v.id === activeViewId ? "text-fg" : "text-muted"
}`}
>
<Bookmark className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="flex-1 truncate">{v.name}</span>
{v.id === activeViewId && <Check className="h-3.5 w-3.5 shrink-0 text-accent" />}
</PopoverItem>
))}
</div>
)}
<div className="mt-1 border-t border-border pt-1">
<PopoverItem
role="menuitem"
onSelect={() => {
openModal();
setOpen(false);
}}
label={t("views.manage")}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted transition hover:text-fg"
>
<Settings2 className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="flex-1">{t("views.manage")}</span>
</PopoverItem>
</div>
</PopoverSurface>
</>
);
}
@@ -0,0 +1,184 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
api,
getActiveAccount,
coerceTagModes,
type FeedFilters,
type SavedView,
} from "../lib/api";
import { filtersToParams } from "../lib/urlState";
import { preferSticky } from "../lib/savedViews";
import { notifyActionError } from "../lib/actionErrors";
import { accountKey, LS, writeJSON } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
// A saved view's `filters` is an opaque JSON blob from the DB — the FeedFilters type says nothing about
// what an older build wrote there, so coerce the array/shape fields (and migrate the legacy global
// tagMode) before the blob reaches app state or a serializer. NOT a migration: a view saved against a
// dropped field just loses that filter.
export const asFilters = (raw: FeedFilters): FeedFilters => ({
...raw,
tags: raw.tags ?? [],
channelIds: raw.channelIds ?? [],
tagModes: coerceTagModes(raw),
});
// A stable signature of a filter set (only non-default keys, shuffle seed ignored) so a view can be
// matched against the live filters regardless of key order or transient fields.
const keyOf = (f: FeedFilters) => filtersToParams(asFilters(f)).toString();
// Mirror the default view's filters to localStorage so the app can seed them on next load before the
// network returns (read by FeedFiltersProvider).
function syncDefaultMirror(views: SavedView[]): void {
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
if (!key) return;
const def = views.find((v) => v.is_default);
writeJSON(key, def ? def.filters : null);
}
export interface SavedViewsContext {
views: SavedView[];
loading: boolean;
/** The view whose saved filters EXACTLY match the live filters (null if none) — highlighted as active. */
activeViewId: number | null;
/** The view to name in the chip: the exact match, else the last-applied ("sticky") one it diverged from. */
displayView: SavedView | null;
/** True when a sticky view is shown but the live filters no longer match it (the chip's "modified" dot). */
isDirty: boolean;
modalOpen: boolean;
openModal: () => void;
closeModal: () => void;
/** Apply a view's filters and remember it as the active/sticky one. */
apply: (v: SavedView) => void;
/** Forget the active/sticky view (the chip disappears) without touching the filters. */
clearActive: () => void;
/** Save the CURRENT filters as a new view; returns success so a caller can keep its editor open on fail. */
create: (name: string) => Promise<boolean>;
rename: (id: number, name: string) => Promise<boolean>;
toggleDefault: (v: SavedView) => Promise<void>;
/** Delete a view after confirming; resolves true only when it was actually deleted (false on cancel/fail). */
remove: (v: SavedView) => Promise<boolean>;
reorder: (nextIds: number[]) => Promise<void>;
}
const Ctx = createContext<SavedViewsContext | null>(null);
export function SavedViewsProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
const viewsQuery = useQuery({ queryKey: qk.savedViews(), queryFn: api.savedViews });
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
const [stickyId, setStickyId] = useState<number | null>(null);
const [modalOpen, setModalOpen] = useState(false);
// Keep the default-view mirror (read on load) in sync with the server state.
useEffect(() => {
if (viewsQuery.data) syncDefaultMirror(viewsQuery.data);
}, [viewsQuery.data]);
const currentKey = keyOf(filters);
const activeView = useMemo(
// When several views capture identical filters, prefer the one the user explicitly applied
// (sticky) so apply(B) isn't silently re-attributed to an earlier duplicate A; else the first.
() =>
preferSticky(
views.filter((v) => keyOf(v.filters) === currentKey),
stickyId,
),
[views, currentKey, stickyId],
);
// An exact match becomes the sticky selection, so filters that happen to equal a view (deep-link,
// manual, or an applied view) name that view in the chip and keep naming it after a later tweak.
useEffect(() => {
if (activeView) setStickyId(activeView.id);
}, [activeView]);
const stickyView = useMemo(
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
[views, stickyId],
);
const displayView = activeView ?? stickyView;
const isDirty = displayView != null && activeView == null;
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() });
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
try {
await fn();
return true;
} catch (e) {
notifyActionError(e, failMsg);
return false;
} finally {
refresh();
}
}
const value: SavedViewsContext = {
views,
loading: viewsQuery.isLoading,
activeViewId: activeView?.id ?? null,
displayView,
isDirty,
modalOpen,
openModal: () => setModalOpen(true),
closeModal: () => setModalOpen(false),
apply: (v) => {
setFilters(asFilters(v.filters));
setStickyId(v.id);
},
clearActive: () => setStickyId(null),
create: async (name) => {
const clean = name.trim();
if (!clean) return false;
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
const { seed: _seed, ...rest } = filters;
return runViewMutation(() => api.createView(clean, rest), t("views.actionFailed"));
},
rename: async (id, name) => {
const next = name.trim();
if (!next) return false;
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
},
toggleDefault: (v) =>
runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }),
t("views.actionFailed"),
).then(() => undefined),
remove: async (v) => {
const ok = await confirm({
message: t("views.deleteConfirm", { name: v.name }),
confirmLabel: t("views.delete"),
danger: true,
});
if (!ok) return false;
if (stickyId === v.id) setStickyId(null);
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
},
reorder: async (nextIds) => {
// Optimistic reorder so the drop sticks visually before the round-trip; the refetch reverts to
// the server's truth on failure (with a toast, so it doesn't silently snap back).
qc.setQueryData<SavedView[]>(
qk.savedViews(),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
);
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
},
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useSavedViews(): SavedViewsContext {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
return ctx;
}
@@ -1,344 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { BookmarkPlus, Check, GripVertical, Pencil, Star, Trash2, X } from "lucide-react";
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
import { filtersToParams } from "../lib/urlState";
import { notifyActionError } from "../lib/actionErrors";
import { accountKey, LS, writeJSON } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
// Compact, order-independent signature of a filter set (reuses the share serializer, which
// emits only non-default values and ignores the transient shuffle seed). Used to highlight the
// saved view that matches the feed's current filters.
const keyOf = (f: FeedFilters) => filtersToParams(asFilters(f)).toString();
// A saved view's `filters` is a JSON blob straight from the DB — the FeedFilters type says nothing
// about what an older build actually wrote there, so coerce the array fields before the blob reaches
// app state or a serializer. Deliberately NOT a migration: a view saved against a dropped field just
// loses that filter.
const asFilters = (raw: FeedFilters): FeedFilters => ({
...raw,
tags: raw.tags ?? [],
channelIds: raw.channelIds ?? [],
});
function syncDefaultMirror(views: SavedView[]): void {
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
if (!key) return;
const def = views.find((v) => v.is_default);
writeJSON(key, def ? def.filters : null);
}
export default function SavedViewsWidget({
filters,
onApply,
}: {
filters: FeedFilters;
onApply: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const viewsQuery = useQuery({ queryKey: qk.savedViews(), queryFn: api.savedViews });
const views = viewsQuery.data ?? [];
const [naming, setNaming] = useState(false);
const [draftName, setDraftName] = useState("");
const [renameId, setRenameId] = useState<number | null>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
// Keep the default-view mirror (read on load) in sync with the server state.
useEffect(() => {
if (viewsQuery.data) syncDefaultMirror(viewsQuery.data);
}, [viewsQuery.data]);
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() });
// Run a saved-view mutation with uniform feedback: on failure surface the server's reason instead
// of a silent swallow, and always refetch afterwards (which also reverts an optimistic change to
// the server's truth). Returns whether it succeeded, so callers can gate success-only side effects
// (e.g. closing an editor) and keep the input open — with the user's text — on failure.
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
try {
await fn();
return true;
} catch (e) {
notifyActionError(e, failMsg);
return false;
} finally {
refresh();
}
}
const currentKey = keyOf(filters);
async function save() {
const name = draftName.trim();
if (!name) return;
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
const { seed: _seed, ...clean } = filters;
// Close the naming input only on success, so a failed create keeps the user's typed name.
if (await runViewMutation(() => api.createView(name, clean), t("views.actionFailed"))) {
setNaming(false);
setDraftName("");
}
}
async function rename(id: number, name: string) {
const next = name.trim();
if (!next) {
setRenameId(null);
return;
}
// Close the rename editor only on success, so a failed rename stays open for a retry.
if (await runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"))) {
setRenameId(null);
}
}
async function toggleDefault(v: SavedView) {
await runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }),
t("views.actionFailed"),
);
}
async function remove(v: SavedView) {
const ok = await confirm({
message: t("views.deleteConfirm", { name: v.name }),
confirmLabel: t("views.delete"),
danger: true,
});
if (!ok) return;
await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
}
async function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const ids = views.map((v) => v.id);
const oldIndex = ids.indexOf(active.id as number);
const newIndex = ids.indexOf(over.id as number);
if (oldIndex < 0 || newIndex < 0) return;
const next = arrayMove(ids, oldIndex, newIndex);
// Optimistic reorder so the drop sticks visually before the round-trip.
qc.setQueryData<SavedView[]>(
qk.savedViews(),
next.map((id) => views.find((v) => v.id === id)!),
);
// On failure runViewMutation's refetch reverts the optimistic order to the server's truth;
// without the toast the drop would silently snap back with no explanation.
await runViewMutation(() => api.reorderViews(next), t("views.reorderFailed"));
}
return (
<div className="space-y-2">
{views.length === 0 && !naming && (
<p className="text-2xs text-muted leading-snug">{t("views.empty")}</p>
)}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={views.map((v) => v.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-1">
{views.map((v) => (
<ViewRow
key={v.id}
view={v}
active={keyOf(v.filters) === currentKey}
renaming={renameId === v.id}
onApply={() => onApply(asFilters(v.filters))}
onStartRename={() => setRenameId(v.id)}
onRename={(name) => rename(v.id, name)}
onCancelRename={() => setRenameId(null)}
onToggleDefault={() => toggleDefault(v)}
onDelete={() => remove(v)}
/>
))}
</div>
</SortableContext>
</DndContext>
{naming ? (
<div className="flex items-center gap-1">
<input
autoFocus
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void save();
if (e.key === "Escape") setNaming(false);
}}
placeholder={t("views.namePlaceholder")}
maxLength={80}
className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
onClick={() => void save()}
title={t("views.saveAction")}
className="p-1 rounded-lg text-muted hover:text-accent hover:bg-card transition"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={() => setNaming(false)}
title={t("views.cancel")}
className="p-1 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<button
onClick={() => {
setDraftName("");
setNaming(true);
}}
className="w-full inline-flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<BookmarkPlus className="w-3.5 h-3.5" />
{t("views.save")}
</button>
)}
</div>
);
}
function ViewRow({
view,
active,
renaming,
onApply,
onStartRename,
onRename,
onCancelRename,
onToggleDefault,
onDelete,
}: {
view: SavedView;
active: boolean;
renaming: boolean;
onApply: () => void;
onStartRename: () => void;
onRename: (name: string) => void;
onCancelRename: () => void;
onToggleDefault: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: view.id,
});
const [draft, setDraft] = useState(view.name);
// Reset the edit draft to the current name each time rename mode opens (so a second rename
// doesn't start from a stale value).
useEffect(() => {
if (renaming) setDraft(view.name);
}, [renaming, view.name]);
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
if (renaming) {
return (
<div ref={setNodeRef} style={style} className="flex items-center gap-1">
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onRename(draft);
if (e.key === "Escape") onCancelRename();
}}
maxLength={80}
className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
onClick={() => onRename(draft)}
title={t("views.saveAction")}
className="p-1 rounded-lg text-muted hover:text-accent hover:bg-card transition"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={onCancelRename}
title={t("views.cancel")}
className="p-1 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
return (
<div
ref={setNodeRef}
style={style}
className={`group flex items-center gap-0.5 rounded-lg pr-1 ${
active ? "bg-accent/10" : "hover:bg-card"
}`}
>
<button
{...attributes}
{...listeners}
title={t("views.reorder")}
className="p-1 text-muted/60 hover:text-muted cursor-grab touch-none shrink-0"
>
<GripVertical className="w-3.5 h-3.5" />
</button>
<button
onClick={onApply}
title={t("views.apply")}
aria-current={active ? "true" : undefined}
className={`flex-1 min-w-0 text-left text-xs py-1 truncate ${
active ? "text-accent font-medium" : "text-fg"
}`}
>
{view.name}
</button>
<div className="flex items-center shrink-0">
<button
onClick={onToggleDefault}
title={view.is_default ? t("views.isDefault") : t("views.makeDefault")}
className={`p-1 rounded transition ${
view.is_default ? "text-accent" : "text-muted reveal hover:text-accent"
}`}
>
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
</button>
<button
onClick={onStartRename}
title={t("views.rename")}
className="p-1 rounded-sm text-muted reveal hover:text-fg transition"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={onDelete}
title={t("views.delete")}
className="p-1 rounded-sm text-muted reveal hover:text-danger transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { Bookmark, ChevronDown } from "lucide-react";
import SavedViewsPopover from "./SavedViewsPopover";
import { useSavedViews } from "./SavedViewsProvider";
// The active personal view, shown as the leading pill before the active-filter chip row. Styled like
// the toolbar's FilterSelect pills; a warn-coloured dot appears when the live filters have diverged
// from the saved view. Clicking it opens the saved-views picker to switch views. Renders nothing when
// no view is active (nothing applied and the filters match no saved view).
export default function SelectedViewChip() {
const { t } = useTranslation();
const { displayView, isDirty } = useSavedViews();
if (!displayView) return null;
return (
<SavedViewsPopover
ariaLabel={t("views.saved")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-fg transition hover:border-accent/60"
trigger={
<>
<Bookmark className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />
<span className="max-w-40 truncate">{displayView.name}</span>
{isDirty && (
<span
className="h-1.5 w-1.5 shrink-0 rounded-full bg-warn"
title={t("views.modified")}
aria-label={t("views.modified")}
/>
)}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</>
}
/>
);
}
+30 -7
View File
@@ -20,6 +20,9 @@ export default function SidePanel({
onReset,
editHint,
actions,
iconExpanded,
onTitleClick,
titleActionLabel,
children,
}: {
title: string;
@@ -34,6 +37,13 @@ export default function SidePanel({
editHint?: string;
// Extra header controls shown when NOT editing (e.g. the feed's Clear all + Share view).
actions?: ReactNode;
// Optional override for the icon in the EXPANDED header only (the collapsed tab keeps the plain
// `icon`) — the feed passes its saved-views popover trigger here so the header icon opens the picker.
iconExpanded?: ReactNode;
// When set, the expanded-header title becomes a button (the feed opens its Personal Views modal).
onTitleClick?: () => void;
// Accessible name / tooltip describing what onTitleClick does (the plain title text alone doesn't).
titleActionLabel?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
@@ -44,14 +54,16 @@ export default function SidePanel({
return (
// Layout slot: reserves only the tab's gutter when unpinned, so the expanded card overlays
// content instead of pushing it. Pinned reserves the full width (in-flow), as before.
// content instead of pushing it. Pinned reserves --rail-pinned-w: 16.75rem (in-flow, pushes) on a
// full-width page or a narrow window, but 2.25rem (overlay, doesn't push) when the centred content
// leaves room for the rail to sit in its left margin — see PageShell. Default 16.75rem = old w-67.
<div
// Focus is tracked container-wide (keyboard: tab anywhere inside holds it open). HOVER is NOT
// on this full-height slot — that made the whole column next to the nav rail a live target, so
// merely approaching the rail popped the panel open. Hover now lives only on the visible tab
// handle + the expanded card below, so only pointing AT the tab opens it.
{...peek.focusHandlers}
className={`hidden md:block relative shrink-0 transition-[width] ${pinned ? "w-67" : "w-9"}`}
className={`hidden md:block relative shrink-0 transition-[width] ${pinned ? "w-[var(--rail-pinned-w,16.75rem)]" : "w-9"}`}
>
{/* The tab: a small handle flush against the nav rail, flat on the docked edge. It stays
mounted while the card is open — swapping it out from under the pointer would drop the very
@@ -74,7 +86,7 @@ export default function SidePanel({
</span>
)}
<span
className="text-2xs font-semibold uppercase tracking-[0.18em] text-muted"
className="text-xs font-semibold uppercase tracking-[0.18em] text-muted"
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
>
{title}
@@ -97,10 +109,21 @@ export default function SidePanel({
>
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</button>
<span className="shrink-0 flex text-accent">{icon}</span>
<span className="flex-1 min-w-0 truncate text-xs font-bold uppercase tracking-[0.12em]">
{title}
</span>
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
{onTitleClick ? (
<button
onClick={onTitleClick}
title={titleActionLabel}
aria-label={titleActionLabel}
className="flex-1 min-w-0 truncate text-left text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
>
{title}
</button>
) : (
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
{title}
</span>
)}
{count > 0 && (
<span
title={t("sidebar.activeCount", { count })}
+77 -223
View File
@@ -2,20 +2,20 @@ import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Library, Pencil, SlidersHorizontal, User } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api";
import { SlidersHorizontal } from "lucide-react";
import { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { useDebounced } from "../lib/useDebounced";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
import { useActiveFilters } from "../lib/useActiveFilters";
import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget";
import SavedViewsPopover from "./SavedViewsPopover";
import { useSavedViews } from "./SavedViewsProvider";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups";
import { type MediaSourceKind } from "../lib/mediaSource";
import ChannelPicker from "./ChannelPicker";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import DateFilterSelect from "./DateFilterSelect";
import Combobox, { type ComboboxOption } from "./Combobox";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useMe } from "../lib/useMe";
@@ -24,44 +24,15 @@ import { useMe } from "../lib/useMe";
const DATE_PRESETS: { days: number; key: string }[] = [
{ days: 1, key: "24h" },
{ days: 7, key: "1week" },
{ days: 14, key: "2weeks" },
{ days: 30, key: "1month" },
{ days: 90, key: "3months" },
{ days: 180, key: "6months" },
{ days: 365, key: "1year" },
{ days: 730, key: "2years" },
{ days: 1825, key: "5years" },
];
function TagChip({
tag,
count,
active,
onClick,
}: {
tag: Tag;
count: number;
active: boolean;
onClick: () => void;
}) {
const { t } = useTranslation();
return (
<button
data-testid="filter-tag-chip"
data-tag={tag.name}
onClick={onClick}
title={t("sidebar.channelCount", { count })}
aria-pressed={active}
className={filterChipClass}
>
<span>{tag.name}</span>
<span
className={`tabular-nums text-3xs leading-none px-1 py-0.5 rounded-full ${
active ? "bg-accent-fg/20 text-accent-fg" : "bg-muted/15 text-muted"
}`}
>
{count}
</span>
</button>
);
}
export default function Sidebar({
layout,
setLayout,
@@ -75,6 +46,7 @@ export default function Sidebar({
}) {
const { t } = useTranslation();
const isDemo = useMe().is_demo;
const { openModal } = useSavedViews();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
@@ -107,32 +79,36 @@ export default function Sidebar({
const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic");
const userTags = tags.filter((t) => !t.system);
const [customDates, setCustomDates] = useState(false);
const [tagManagerOpen, setTagManagerOpen] = useState(false);
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives
// the panel layout key. `FilterRail` owns the customize `editing` state now.
const source: MediaSourceKind = "feed";
function toggleTag(id: number) {
const has = filters.tags.includes(id);
// The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns
// only its own slice: its selection replaces the ids that belong to its list, leaving the others.
const tagOption = (tg: Tag): ComboboxOption => ({
value: String(tg.id),
label: tg.name,
count: chipCount(tg),
});
const selectedIn = (list: Tag[]): string[] =>
filters.tags.filter((id) => list.some((tg) => tg.id === id)).map(String);
function setTagSelection(list: Tag[], next: string[]) {
const inList = new Set(list.map((tg) => tg.id));
setFilters({
...filters,
tags: has ? filters.tags.filter((t) => t !== id) : [...filters.tags, id],
tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)],
});
}
// Each tag category (language/topic/other) has its OWN any/all match, set from its combobox footer.
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
// disagree about what's applied.
const activeCount = useActiveFilters(filters, setFilters).length;
const active = activeCount > 0;
function clearAll() {
setCustomDates(false);
setFilters(clearedFilters(filters));
}
const available: Record<string, boolean> = {
savedviews: !isDemo,
date: true,
channel: true,
language: languages.length > 0,
@@ -142,155 +118,51 @@ export default function Sidebar({
function widgetBody(id: string): ReactNode {
switch (id) {
case "savedviews":
return <SavedViewsWidget filters={filters} onApply={setFilters} />;
case "channel":
return <ChannelPicker filters={filters} setFilters={setFilters} />;
case "date": {
// Single-select across the presets + Custom; pure logic lives in lib/dateFilter.
const dateKey = deriveDateKey(filters, customDates, DATE_PRESETS);
case "date":
return (
<>
<FilterChipGroup
ariaLabel={t("sidebar.datePresetLabel")}
value={dateKey}
options={[
...DATE_PRESETS.map((p) => ({
value: String(p.days),
label: t("sidebar.datePreset." + p.key),
})),
{ value: "custom", label: t("sidebar.custom") },
]}
onChange={(key) => {
const change = applyDateChip(key, filters, customDates);
if (change.customDates !== undefined) setCustomDates(change.customDates);
if (change.filters) setFilters(change.filters);
}}
/>
{(customDates || filters.dateFrom || filters.dateTo) && (
<div className="flex flex-col gap-1.5 mt-2">
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.from")}</span>
<input
type="date"
value={filters.dateFrom ?? ""}
onChange={(e) =>
setFilters({
...filters,
dateFrom: e.target.value || undefined,
maxAgeDays: undefined,
})
}
className="bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
</label>
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.to")}</span>
<input
type="date"
value={filters.dateTo ?? ""}
onChange={(e) =>
setFilters({
...filters,
dateTo: e.target.value || undefined,
maxAgeDays: undefined,
})
}
className="bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
</label>
{(filters.dateFrom || filters.dateTo) && (
<button
onClick={() =>
setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })
}
className="text-2xs text-muted hover:text-accent self-end"
>
{t("sidebar.clearDates")}
</button>
)}
</div>
)}
</>
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
);
}
case "language": {
const chips = visibleChips(languages);
return (
<div className="flex flex-wrap gap-1.5">
{chips.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
count={chipCount(tg)}
active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)}
/>
))}
{facetsReady && chips.length === 0 && (
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
)}
</div>
<Combobox
label={t("sidebar.widget.language")}
options={visibleChips(languages).map(tagOption)}
selected={selectedIn(languages)}
onChange={(next) => setTagSelection(languages, next)}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.language}
onMatchModeChange={(m) => setTagMode("language", m)}
/>
);
}
case "topic": {
const chips = visibleChips(topics);
return (
<>
<SegmentedToggle
label={t("sidebar.match")}
title={t("sidebar.tagModeTooltip")}
ariaLabel={t("sidebar.match")}
options={[
{ value: "or", label: t("sidebar.any") },
{ value: "and", label: t("sidebar.all") },
]}
value={filters.tagMode}
onChange={(tagMode) => setFilters({ ...filters, tagMode })}
/>
<div className="flex flex-wrap gap-1.5">
{chips.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
count={chipCount(tg)}
active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)}
/>
))}
{facetsReady && chips.length === 0 && (
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
)}
</div>
</>
<Combobox
label={t("sidebar.widget.topic")}
options={visibleChips(topics).map(tagOption)}
selected={selectedIn(topics)}
onChange={(next) => setTagSelection(topics, next)}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.topic}
onMatchModeChange={(m) => setTagMode("topic", m)}
/>
);
}
case "tags": {
const tagChips = visibleChips(userTags);
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
// body is just the picker.
return (
<div className="flex flex-wrap items-center gap-1.5">
{tagChips.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
count={chipCount(tg)}
active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)}
/>
))}
{facetsReady && tagChips.length === 0 && (
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
)}
<button
onClick={() => setTagManagerOpen(true)}
title={t("sidebar.manageTags")}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<Pencil className="w-3 h-3" />
{t("sidebar.manageTags")}
</button>
</div>
<Combobox
label={t("sidebar.widget.tags")}
options={visibleChips(userTags).map(tagOption)}
selected={selectedIn(userTags)}
onChange={(next) => setTagSelection(userTags, next)}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.other}
onMatchModeChange={(m) => setTagMode("other", m)}
/>
);
}
default:
@@ -302,19 +174,15 @@ export default function Sidebar({
// the actual render order is the user's saved layout (PanelGroups orders by it).
const items: PanelItem[] = defaultLayout(source)
.order.filter((id) => available[id])
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
const actions = (
<>
<button
onClick={clearAll}
disabled={!active}
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1 whitespace-nowrap"
>
{t("sidebar.clearAll")}
</button>
</>
);
.map((id) => ({
id,
title: t("sidebar.widget." + id),
body: widgetBody(id),
// The widgets aren't collapsible, so a widget's title can act as its own trigger: "Your tags"
// opens the tag manager (replacing the old inline Manage button).
onTitleClick: id === "tags" ? () => setTagManagerOpen(true) : undefined,
titleActionLabel: id === "tags" ? t("sidebar.manageTags") : undefined,
}));
return (
<FilterRail
@@ -327,31 +195,17 @@ export default function Sidebar({
layout={layout}
setLayout={setLayout}
items={items}
actions={actions}
top={
/* Scope: your own subscriptions (Mine) vs the shared Library. */
<SegmentedToggle
ariaLabel={t("header.scope.label")}
fullWidth
size="md"
options={[
{
value: "my",
label: t("header.scope.my"),
icon: <User className="w-3.5 h-3.5" />,
title: t("header.scope.myTip"),
},
{
value: "all",
label: t("header.scope.all"),
icon: <Library className="w-3.5 h-3.5" />,
title: t("header.scope.allTip"),
},
]}
value={filters.scope}
onChange={(scope) => setFilters({ ...filters, scope })}
/>
// The feed widgets are simple label + control rows, not collapsible islands.
collapsible={false}
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
// full manager modal. Hidden for the demo account (no per-user views). Feed rail only.
iconExpanded={
!isDemo && source === "feed" ? (
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
) : undefined
}
onTitleClick={!isDemo && source === "feed" ? openModal : undefined}
titleActionLabel={!isDemo && source === "feed" ? t("views.manageTitle") : undefined}
>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
@@ -1,12 +1,29 @@
// @vitest-environment jsdom
//
// The rendering integration of SortControl (its pickSort/heal decision logic is covered in
// columnSort.test.ts). Labels are plain props here — no i18n needed.
import { describe, it, expect, vi, afterEach } from "vitest";
// columnSort.test.ts). Labels are plain props here — no i18n needed. It's a floating-ui popover now
// (a labeled pill + a menu of `menuitemradio` sort keys with a footer direction segment), so we stub
// the two autoUpdate observers and wrap in a FloatingTree; rows only exist while open → role queries
// pass { hidden: true }.
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import SortControl from "./SortControl";
import type { SortOption, SortState } from "../lib/columnSort";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
const OPTS: SortOption[] = [
{ key: "date", label: "Date", defaultDir: "desc" },
{ key: "title", label: "Title" },
@@ -16,67 +33,71 @@ const OPTS: SortOption[] = [
function renderControl(sort: SortState, extra: Record<string, unknown> = {}) {
const onChange = vi.fn();
render(
<SortControl
options={OPTS}
sort={sort}
onChange={onChange}
label="Sort"
ascLabel="Ascending"
descLabel="Descending"
{...extra}
/>,
<FloatingTree>
<SortControl
options={OPTS}
sort={sort}
onChange={onChange}
label="Sort"
ascLabel="Ascending"
descLabel="Descending"
{...extra}
/>
</FloatingTree>,
);
return { onChange };
}
const openPanel = () => fireEvent.click(screen.getByRole("button", { name: /^Sort/ }));
afterEach(cleanup);
describe("SortControl", () => {
it("renders one option per SortOption, no empty option without defaultLabel", () => {
it("renders one option per SortOption, no no-sort row without defaultLabel", () => {
renderControl({ key: "title", dir: "asc" });
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
"Date",
"Title",
"Shuffle",
]);
openPanel();
expect(
screen.getAllByRole("menuitemradio", { hidden: true }).map((o) => o.textContent),
).toEqual(["Date", "Title", "Shuffle"]);
});
it("adds a leading no-sort option when defaultLabel is set (tables)", () => {
renderControl(null, { defaultLabel: "None" });
const none = screen.getByRole("option", { name: "None" }) as HTMLOptionElement;
expect(none.value).toBe("");
openPanel();
expect(
screen.getAllByRole("menuitemradio", { hidden: true }).map((o) => o.textContent),
).toEqual(["None", "Date", "Title", "Shuffle"]);
});
it("falls back to the first option (not a blank select) for an unavailable key without defaultLabel", () => {
it("summarises the trigger with the first option (not blank) for an unavailable key without defaultLabel", () => {
renderControl({ key: "ghost", dir: "asc" });
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe("date");
expect(screen.getByRole("button", { name: /^Sort/ }).textContent).toContain("Date");
});
it("picking a new option fires onChange at that option's defaultDir", () => {
const { onChange } = renderControl({ key: "title", dir: "asc" });
fireEvent.change(screen.getByRole("combobox"), { target: { value: "date" } });
openPanel();
fireEvent.click(screen.getAllByRole("menuitemradio", { hidden: true })[0]!); // Date
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "desc" });
});
it("shows the direction toggle for a directional sort and flips it on click", () => {
it("shows the direction segment for a directional sort and sets asc on click", () => {
const { onChange } = renderControl({ key: "date", dir: "desc" });
const toggle = screen.getByRole("button", { name: "Descending" });
fireEvent.click(toggle);
openPanel();
fireEvent.click(screen.getByText("Ascending"));
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "asc" });
});
it("hides the direction toggle for a directionless sort", () => {
it("hides the direction segment for a directionless sort", () => {
renderControl({ key: "shuffle", dir: "desc" });
expect(screen.queryByRole("button")).toBeNull();
openPanel();
expect(screen.queryByText("Ascending")).toBeNull();
expect(screen.queryByText("Descending")).toBeNull();
});
it("renders the trailing adornment", () => {
renderControl(
{ key: "shuffle", dir: "desc" },
{
trailing: <button>Reshuffle</button>,
},
);
expect(screen.getByRole("button", { name: "Reshuffle" })).toBeTruthy();
it("renders the trailing adornment in the footer", () => {
renderControl({ key: "shuffle", dir: "desc" }, { trailing: <button>Reshuffle</button> });
openPanel();
expect(screen.getByText("Reshuffle")).toBeTruthy();
});
});
+137 -40
View File
@@ -1,13 +1,19 @@
import { type ReactNode } from "react";
import { ArrowDown, ArrowUp } from "lucide-react";
import { useState, type ReactNode } from "react";
import { ArrowDown, ArrowUp, Check, ChevronDown } from "lucide-react";
import { pickSort, type SortOption, type SortState } from "../lib/columnSort";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// The one sort control for every content surface (YT feed, Plex library, channel manager + its card
// view, channel discovery): a dropdown of `SortOption`s + an asc/desc toggle, driven by the shared
// `SortState`. Presentation-agnostic to its container — a table feeds column-derived options via
// `ColumnSortControl`, the feed/Plex declare their own vocabulary. `defaultLabel` opts a view into a
// "no sort" choice (tables); omit it for always-sorted views (feed/Plex). `trailing` is an adornment
// slot (the feed's reshuffle button) so the shuffle affordance stays out of the generic control.
// view, channel discovery, playlists): a labeled dropdown pill matching FilterSelect — `Label: key ↓ ▾`
// — that opens a listbox of `SortOption`s with an asc/desc segment in the panel footer. Two-dimensional
// (key + direction) unlike FilterSelect, so it stays its own component. Driven by the shared `SortState`.
// `defaultLabel` opts a view into a "no sort" choice (tables); omit it for always-sorted views
// (feed/Plex). `trailing` is an adornment slot (the feed's reshuffle, playlists' dirty-first toggle)
// rendered in the footer beside the direction segment.
//
// Direction lives in the footer, not on the trigger, on purpose: the trigger stays a single plain
// <button> (the popover reference) with no nested button — picking a key keeps the panel open so key
// AND direction can be set in one visit; picking "no sort" closes it (nothing left to direct).
export default function SortControl({
options,
sort,
@@ -21,55 +27,146 @@ export default function SortControl({
options: SortOption[];
sort: SortState;
onChange: (next: SortState) => void;
/** Accessible name for the dropdown (e.g. "Sort"). */
/** Accessible name + visible prefix for the trigger (e.g. "Sort"). */
label: string;
ascLabel: string;
descLabel: string;
/** When set, a leading "no sort / original order" option is offered (tables). Omit for
* always-sorted views (feed/Plex), which never surface a null sort. */
defaultLabel?: string;
/** Rendered after the direction toggle (e.g. the feed's reshuffle button). */
/** Rendered in the panel footer beside the direction segment (feed reshuffle, playlists dirty-first). */
trailing?: ReactNode;
}) {
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-end",
});
// A stored sort can name an option this surface no longer offers; show it as unsorted rather than
// letting an arrow claim a sort that `sortRows`/the server won't actually apply.
const active = sort && options.some((o) => o.key === sort.key) ? sort : null;
const activeOpt = active ? options.find((o) => o.key === active.key) : undefined;
const showArrow = !!activeOpt && !activeOpt.directionless;
// An always-sorted view (no `defaultLabel`) must still show a real option when the current key isn't
// offered (a stale/out-of-scope sort) — fall back to the first option rather than a blank select
// (which also trips React's controlled-value warning). Views with a "no sort" option keep it.
const selectValue = active?.key ?? (defaultLabel === undefined ? (options[0]?.key ?? "") : "");
// Trigger summary: the active option's label, or the "no sort" text, or (a stale/out-of-scope sort on
// an always-sorted view) the first option's label rather than a blank pill.
const summary = activeOpt?.label ?? defaultLabel ?? options[0]?.label ?? "";
const segCls = (on: boolean) =>
`flex flex-1 items-center justify-center gap-1 rounded-md border px-2 py-1 text-2xs transition ${
on
? "border-accent bg-accent/15 text-accent"
: "border-border text-muted hover:border-accent/50 hover:text-fg"
}`;
return (
<div className="flex items-center gap-1">
<select
value={selectValue}
onChange={(e) => onChange(pickSort(options, active, e.target.value))}
aria-label={label}
className="bg-surface border border-border rounded-lg px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// Carry the active sort key AND direction in the accessible name (the visible arrow is
// aria-hidden), matching ViewSwitcher — a screen reader announces "Sort: Date, Descending",
// not a bare "Sort".
aria-label={`${label}: ${summary}${
showArrow && active ? `, ${active.dir === "asc" ? ascLabel : descLabel}` : ""
}`}
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
{defaultLabel !== undefined && <option value="">{defaultLabel}</option>}
{options.map((o) => (
<option key={o.key} value={o.key}>
{o.label}
</option>
))}
</select>
{showArrow && active && (
<button
onClick={() => onChange({ key: active.key, dir: active.dir === "asc" ? "desc" : "asc" })}
title={active.dir === "asc" ? ascLabel : descLabel}
aria-label={active.dir === "asc" ? ascLabel : descLabel}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
{active.dir === "asc" ? (
<ArrowUp className="w-4 h-4" />
<span className="text-muted">{label}:</span>
<span className="text-fg">{summary}</span>
{showArrow &&
active &&
(active.dir === "asc" ? (
<ArrowUp className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
) : (
<ArrowDown className="w-4 h-4" />
<ArrowDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
))}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
{/* One exclusive choice of sort key (radios), scoped by a group so AT announces which is
checked — the surface is a menu (see the mode above), never a listbox with orphaned options. */}
<div role="group" aria-label={label}>
{defaultLabel !== undefined && (
<PopoverItem
role="menuitemradio"
checked={active === null}
onSelect={() => {
onChange(null);
setOpen(false); // "no sort" is a final choice — nothing left to direct
}}
label={defaultLabel}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
active === null ? "text-fg" : "text-muted"
}`}
>
<Check
className={`h-3.5 w-3.5 shrink-0 ${active === null ? "text-accent" : "opacity-0"}`}
/>
<span className="flex-1 truncate">{defaultLabel}</span>
</PopoverItem>
)}
</button>
)}
{trailing}
</div>
{options.map((o) => {
const on = active?.key === o.key;
return (
<PopoverItem
key={o.key}
role="menuitemradio"
checked={on}
onSelect={() => onChange(pickSort(options, active, o.key))} // keep open to set direction
label={o.label}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
<span className="flex-1 truncate">{o.label}</span>
</PopoverItem>
);
})}
</div>
{((showArrow && active) || trailing) && (
<div
role="group"
aria-label={label}
className="mt-1 flex items-center gap-1.5 border-t border-border px-1 pt-2"
>
{showArrow && active && (
<div className="flex flex-1 gap-1">
<button
type="button"
aria-pressed={active.dir === "asc"}
onClick={() => onChange({ key: active.key, dir: "asc" })}
className={segCls(active.dir === "asc")}
>
<ArrowUp className="h-3 w-3" aria-hidden="true" />
{ascLabel}
</button>
<button
type="button"
aria-pressed={active.dir === "desc"}
onClick={() => onChange({ key: active.key, dir: "desc" })}
className={segCls(active.dir === "desc")}
>
<ArrowDown className="h-3 w-3" aria-hidden="true" />
{descLabel}
</button>
</div>
)}
{trailing}
</div>
)}
</PopoverSurface>
</>
);
}
+5 -3
View File
@@ -65,12 +65,14 @@ export default function ViewSwitcher<T extends string>({
// nicety for wide windows, not the accessible name.
aria-label={`${label}: ${current.label}`}
title={`${label}: ${current.label}`}
className="shrink-0 inline-flex items-center gap-1 pl-2 pr-1 py-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
className={`shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
<CurrentIcon className="w-4 h-4 shrink-0" />
<span className={clsx("text-sm whitespace-nowrap", labelClassName)}>{current.label}</span>
<span className={clsx("whitespace-nowrap", labelClassName)}>{current.label}</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
className={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
<PopoverSurface
+30 -17
View File
@@ -7,6 +7,8 @@ import { useFeedView, useFeedViewActions } from "./FeedViewProvider";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import Sidebar from "./Sidebar";
import PlaylistsRail from "./PlaylistsRail";
import PersonalViewsModal from "./PersonalViewsModal";
import { useSavedViews } from "./SavedViewsProvider";
// The RENDER half of the module registry: which component (and side rail) each page renders. The
// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar /
@@ -45,18 +47,25 @@ function FeedPage() {
const { setView } = useFeedViewActions();
const { ytSearch } = useNavigation();
const { enterYtSearch, exitYtSearch } = useNavigationActions();
// The Personal Views modal lives at the feed-page level (always mounted while on the feed, unmounts
// on navigation) rather than inside the collapsible filter rail — otherwise opening it from the
// always-visible toolbar view chip while the rail is unpinned would render nothing and latch open.
const { modalOpen } = useSavedViews();
return (
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={ytSearch}
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
/>
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={ytSearch}
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
/>
{modalOpen && !me.is_demo && <PersonalViewsModal />}
</>
);
}
@@ -75,21 +84,25 @@ export type PageDef = {
// Channels + Audit render a table with a sticky header at the scroller top; the shell's top fade
// would sit on it and flip on with the first scroll, so they turn it off. Defaults to on.
fadeTop?: boolean;
// The content column's max width. The header + toolbar centre on this same width (via --content-mw)
// so the floating chrome lines up with the centred content instead of spanning the whole column.
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
maxWidth?: string;
};
// page id → the component App renders in the content column (+ its shell chrome config).
export const PAGE_CONTENT: Record<Page, PageDef> = {
feed: { Component: FeedPage },
channels: { Component: Channels, fadeTop: false },
playlists: { Component: Playlists },
plex: { Component: PlexBrowse },
notifications: { Component: NotificationsPanel },
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
playlists: { Component: Playlists, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "1600px" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
messages: { Component: MessagesPage },
downloads: { Component: DownloadCenter },
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
stats: { Component: Stats },
scheduler: { Component: Scheduler },
config: { Component: ConfigPanel },
users: { Component: AdminUsers },
users: { Component: AdminUsers, maxWidth: "48rem" },
audit: { Component: AuditLog, fadeTop: false },
settings: { Component: SettingsPanel },
};
+2
View File
@@ -12,6 +12,8 @@
"loading": "Loading…",
"language": "Language",
"somethingWrong": "Something went wrong.",
"itemCount_one": "{{formattedCount}} item",
"itemCount_other": "{{formattedCount}} items",
"accessRequestsTitle": "Access requests",
"accessRequestsMessage": "{{count}} pending — review them on the Users page.",
"accessRequestsReview": "Review",
+6
View File
@@ -3,6 +3,10 @@
"label": "Filtered by",
"remove": "Remove filter: {{name}}",
"contentNone": "No content types",
"count_one": "{{count}} filter",
"count_other": "{{count}} filters",
"moreCount_one": "{{count}} more filter",
"moreCount_other": "{{count}} more filters",
"maxAge_one": "Last {{count}} day",
"maxAge_other": "Last {{count}} days"
},
@@ -16,6 +20,8 @@
"demoEmptyBody": "The demo account has no subscriptions of its own — but the whole shared library is yours to explore, filter and sort.",
"browseLibrary": "Browse the library",
"noMatches": "No videos match these filters.",
"channelScopeHint": "This channel isn't in your subscriptions feed — it may be hidden. Its videos are in the shared library.",
"viewInLibrary": "View in Library",
"videoCount_one": "{{formattedCount}} video",
"videoCount_other": "{{formattedCount}} videos",
"sortLabel": "Sort",
+11 -1
View File
@@ -67,9 +67,19 @@
"datePreset": {
"24h": "24h",
"1week": "1 week",
"2weeks": "2 weeks",
"1month": "1 month",
"3months": "3 months",
"6months": "6 months",
"1year": "1 year"
"1year": "1 year",
"2years": "2 years",
"5years": "5 years"
},
"date": {
"anyTime": "Any time",
"customRange": "Custom range",
"fromShort": "From {{date}}",
"untilShort": "Until {{date}}"
},
"channelSearch": "Search channels…",
"noChannelMatch": "No channel matches.",
+9 -1
View File
@@ -12,5 +12,13 @@
"deleteConfirm": "Delete the saved view “{{name}}”?",
"reorder": "Drag to reorder",
"actionFailed": "Couldn't update your saved views — please try again.",
"reorderFailed": "Couldn't save the new order — please try again."
"reorderFailed": "Couldn't save the new order — please try again.",
"saved": "Saved views",
"manage": "Manage views…",
"manageTitle": "Personal views",
"capturedFilters": "Captured filters",
"capturedNone": "No filters — matches every video.",
"default": "Default",
"selectPrompt": "Select a view to see and edit it.",
"modified": "Modified — the current filters differ from this saved view."
}
+2
View File
@@ -12,6 +12,8 @@
"loading": "Betöltés…",
"language": "Nyelv",
"somethingWrong": "Hiba történt.",
"itemCount_one": "{{formattedCount}} elem",
"itemCount_other": "{{formattedCount}} elem",
"accessRequestsTitle": "Hozzáférési kérések",
"accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.",
"accessRequestsReview": "Áttekintés",
+6
View File
@@ -3,6 +3,10 @@
"label": "Szűrve",
"remove": "Szűrő eltávolítása: {{name}}",
"contentNone": "Nincs tartalomtípus",
"count_one": "{{count}} szűrő",
"count_other": "{{count}} szűrő",
"moreCount_one": "még {{count}} szűrő",
"moreCount_other": "még {{count}} szűrő",
"maxAge_one": "Utolsó {{count}} nap",
"maxAge_other": "Utolsó {{count}} nap"
},
@@ -16,6 +20,8 @@
"demoEmptyBody": "A demo fióknak nincsenek saját feliratkozásai — de a teljes közös könyvtár a tiéd: böngészd, szűrd és rendezd kedvedre.",
"browseLibrary": "Könyvtár böngészése",
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
"channelScopeHint": "Ez a csatorna nincs a feliratkozásaid feedjében – lehet, hogy el van rejtve. A videói a közös könyvtárban vannak.",
"viewInLibrary": "Megnézés a Könyvtárban",
"videoCount_one": "{{formattedCount}} videó",
"videoCount_other": "{{formattedCount}} videó",
"sortLabel": "Rendezés",
+11 -1
View File
@@ -67,9 +67,19 @@
"datePreset": {
"24h": "24 óra",
"1week": "1 hét",
"2weeks": "2 hét",
"1month": "1 hónap",
"3months": "3 hónap",
"6months": "6 hónap",
"1year": "1 év"
"1year": "1 év",
"2years": "2 év",
"5years": "5 év"
},
"date": {
"anyTime": "Bármikor",
"customRange": "Egyéni tartomány",
"fromShort": "{{date}}-től",
"untilShort": "{{date}}-ig"
},
"channelSearch": "Csatornák keresése…",
"noChannelMatch": "Nincs találat.",
+9 -1
View File
@@ -12,5 +12,13 @@
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
"reorder": "Húzd az átrendezéshez",
"actionFailed": "Nem sikerült frissíteni a mentett nézeteket — próbáld újra.",
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra."
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra.",
"saved": "Mentett nézetek",
"manage": "Nézetek kezelése…",
"manageTitle": "Személyes nézetek",
"capturedFilters": "Rögzített szűrők",
"capturedNone": "Nincs szűrő — minden videóra illeszkedik.",
"default": "Alapértelmezett",
"selectPrompt": "Válassz egy nézetet a megtekintéshez és szerkesztéshez.",
"modified": "Módosítva — a jelenlegi szűrők eltérnek a mentett nézettől."
}
+11
View File
@@ -98,6 +98,17 @@
place since App and Playlists both reference it. */
--hdr-h: 4.25rem;
/* Width of the app scroller's scrollbar gutter (scrollbar-gutter: stable reserves it on the right).
Measured once at startup (App) and written here; the floating header and the toolbar band pad
their right edge by it so the fixed chrome lines up with the scrolled cards, not 15px past them.
Defaults to 0 for overlay-scrollbar platforms where the gutter is empty. */
--sbw: 0px;
/* Max width of a page's content column (cards / table). The floating header + toolbar band read it
so they centre on the SAME max-width the content uses, instead of spanning the whole column while
a max-w page centres its grid. Each page sets it via PageShell's `maxWidth`; `none` = full width. */
--content-mw: none;
/* ===== Glass tunables — every value that materially drives the look lives here so it can be
tuned in ONE place. ===== */
/* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome
+2
View File
@@ -31,6 +31,8 @@ import { setupApi } from "./api/setup";
export type * from "./api/setup";
import { feedApi } from "./api/feed";
export type * from "./api/feed";
export type * from "./api/tagModes";
export { DEFAULT_TAG_MODES, TAG_CATEGORIES, coerceTagModes } from "./api/tagModes";
import { channelsApi } from "./api/channels";
export type * from "./api/channels";
import { syncApi } from "./api/sync";
+7 -2
View File
@@ -2,6 +2,7 @@
// the shared FeedFilters into the query string the feed + facet endpoints both consume; they stay
// module-local (no consumer names them). Only touches core's req/beacon.
import { req, beacon } from "./core";
import { TAG_CATEGORIES, type TagModes } from "./tagModes";
// Per-user watch status stored server-side (VALID_STATES in routes/feed.py). "in_progress" and
// "unwatched" are NOT statuses — they're derived feed filters (status "new" + a resume position),
@@ -54,7 +55,8 @@ export interface FeedResponse {
export interface FeedFilters {
tags: number[];
tagMode: "or" | "and";
/** Per-category tag match (language / topic / other each independently any/all). */
tagModes: TagModes;
q: string;
sort: string;
// Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
@@ -83,7 +85,10 @@ export interface FeedFilters {
function filterParams(f: FeedFilters): URLSearchParams {
const p = new URLSearchParams();
f.tags.forEach((t) => p.append("tags", String(t)));
p.set("tag_mode", f.tagMode);
// Only emit the categories set to "and"; the backend defaults each to "or".
for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and");
}
if (f.q) p.set("q", f.q);
p.set("scope", f.scope);
p.set("show_normal", String(f.includeNormal));
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { coerceTagModes, DEFAULT_TAG_MODES } from "./tagModes";
// coerceTagModes runs on every persisted saved-view blob (SavedViewsProvider.asFilters), on the URL
// parse, and on the localStorage seed — the single migration point for the legacy global `tagMode`.
describe("coerceTagModes", () => {
it("defaults to all-'or' for empty/undefined input", () => {
expect(coerceTagModes(undefined)).toEqual(DEFAULT_TAG_MODES);
expect(coerceTagModes({})).toEqual(DEFAULT_TAG_MODES);
expect(coerceTagModes(null)).toEqual(DEFAULT_TAG_MODES);
});
it("migrates a legacy global tagMode:'and' into the topic slot only", () => {
expect(coerceTagModes({ tagMode: "and" })).toEqual({
language: "or",
topic: "and",
other: "or",
});
});
it("ignores a legacy tagMode:'or' (stays all-'or')", () => {
expect(coerceTagModes({ tagMode: "or" })).toEqual(DEFAULT_TAG_MODES);
});
it("preserves an explicit per-category tagModes object", () => {
expect(coerceTagModes({ tagModes: { language: "and", topic: "or", other: "and" } })).toEqual({
language: "and",
topic: "or",
other: "and",
});
});
it("an explicit per-category value wins over the legacy scalar", () => {
// A blob carrying both should honour the new per-category topic, not the legacy global.
expect(coerceTagModes({ tagMode: "and", tagModes: { topic: "or" } }).topic).toBe("or");
});
it("coerces invalid/garbage values to 'or'", () => {
expect(coerceTagModes({ tagModes: { topic: "x" as unknown as "or" } }).topic).toBe("or");
expect(coerceTagModes({ tagModes: { language: 1 as unknown as "or" } }).language).toBe("or");
});
});
+24
View File
@@ -0,0 +1,24 @@
// Per-category tag match — the small, dependency-free vocabulary shared by the feed filter model, the
// URL (de)serializer, the sidebar comboboxes, and the saved-view migration. Kept in its own leaf (no
// imports) so node-env tests and any module can pull it in without dragging the HTTP/i18n machinery.
/** A tag category's match: "or" = a channel with ANY selected tag passes, "and" = it must carry ALL. */
export type TagMode = "or" | "and";
/** The three tag categories, each with its OWN independent match mode. */
export type TagCategory = "language" | "topic" | "other";
export type TagModes = Record<TagCategory, TagMode>;
export const TAG_CATEGORIES: readonly TagCategory[] = ["language", "topic", "other"];
export const DEFAULT_TAG_MODES: TagModes = { language: "or", topic: "or", other: "or" };
/** Normalise a persisted/shared filter's tag match into the per-category shape, migrating the legacy
* global `tagMode` (which only ever affected topics) into the topic slot. */
export function coerceTagModes(raw: unknown): TagModes {
const r = (raw ?? {}) as { tagModes?: Partial<TagModes>; tagMode?: unknown };
const legacyTopic = r.tagMode === "and" ? "and" : undefined;
const pick = (cat: TagCategory): TagMode => {
const v = r.tagModes?.[cat];
if (v === "and" || v === "or") return v;
return cat === "topic" && legacyTopic ? "and" : "or";
};
return { language: pick("language"), topic: pick("topic"), other: pick("other") };
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { decideChipLayout, type ChipFitInput } from "./chipOverflow";
// Build measurer-style right edges for `n` chips of a fixed width, starting after a label, with a gap.
function rights(labelRight: number, widths: number[], gap: number): number[] {
const out: number[] = [];
let cursor = labelRight;
for (const w of widths) {
cursor += gap + w;
out.push(cursor);
}
return out;
}
const base = (over: Partial<ChipFitInput> = {}): ChipFitInput => {
const gap = 6;
const labelRight = 60;
const widths = [50, 50, 50]; // 3 chips
return {
n: 3,
labelRight,
chipRights: rights(labelRight, widths, gap),
plusW: 40,
clearW: 6 + 60,
avail: 1000,
gap,
narrowPx: 420,
...over,
};
};
describe("decideChipLayout", () => {
it("shows all chips when the whole set plus Clear all fits", () => {
expect(decideChipLayout(base({ avail: 1000 }))).toEqual({ mode: "all", visible: 3 });
});
it("empty chip set is a no-op 'all' with 0 visible", () => {
expect(decideChipLayout(base({ n: 0, chipRights: [] }))).toEqual({ mode: "all", visible: 0 });
});
it("collapses to a single count pill on a narrow lane when overflowing", () => {
// last chip right edge is ~228; avail below NARROW and below that -> count.
expect(decideChipLayout(base({ avail: 200, narrowPx: 420 }))).toEqual({
mode: "count",
visible: 0,
});
});
it("fits as many chips as leave room for the +N pill on a wide-but-tight lane", () => {
// chip right edges: 116, 172, 228; clearW 66. all-fit needs 228+66=294 <= avail; keep avail below
// that (so not 'all') but >= narrowPx (so not 'count'). Loop reserves gap(6)+plus(40)=46 per chip:
// chip2 228+46=274 > 250 -> dropped; chip1 218 <= 250 -> visible 2.
expect(decideChipLayout(base({ avail: 250, narrowPx: 200 }))).toEqual({
mode: "some",
visible: 2,
});
});
it("reserves the +N pill width so a later chip is dropped when it wouldn't fit beside it", () => {
// Two chips of width 100 after a label, gap 6, plus pill 40. Right edges: 166, 272. avail 300,
// narrowPx 100 (so 300 >= narrow -> 'some'): chip0 166+46=212 <= 300 ok; chip1 272+46=318 > 300
// -> dropped. visible 1.
const input = base({
n: 2,
chipRights: rights(60, [100, 100], 6),
plusW: 40,
clearW: 6 + 60,
avail: 300,
gap: 6,
narrowPx: 100,
});
expect(decideChipLayout(input)).toEqual({ mode: "some", visible: 1 });
});
it("falls back to the count pill when not even one chip fits beside the +N pill", () => {
// Wide-ish lane (>= narrow) but a single very wide chip: label 60, chip width 500 -> right 566.
// avail 430 >= narrow(420); all-fit 566+clearW > 430; loop: 566+6+40 > 430 -> k=0; labelRight
// 60+6+40=106 <= 430 so NOT count by that guard... use narrow-avail instead: keep avail 430 but
// make label+gap+plus exceed it to force count.
const input = base({
n: 1,
labelRight: 400,
chipRights: [966],
plusW: 40,
clearW: 0,
avail: 430,
gap: 6,
narrowPx: 420,
});
// label 400 + gap 6 + plus 40 = 446 > 430 and k===0 -> count.
expect(decideChipLayout(input)).toEqual({ mode: "count", visible: 0 });
});
});
+53
View File
@@ -0,0 +1,53 @@
// Pure fit decision for a single-line chip lane that must share its width with fixed trailing
// controls (the feed's applied-filter row + Sort/View). Lifted out of ActiveFilterChips so the
// all/some/count math is unit-testable without a layout engine (jsdom reports 0-width boxes), the
// same split as lib/dateFilter and lib/columnSort. The caller measures real widths off a hidden
// mirror (including the live rem gap, so nothing is frozen in px) and hands them here.
//
// A sibling one-line-fit measurer lives in ChannelTags (ChannelTagsControl); if a third appears,
// promote the DOM-measurement half to a shared hook and keep this as its policy core.
export interface ChipFitInput {
/** Number of chips. */
n: number;
/** Right edge (offsetLeft + offsetWidth) of the leading label, in the measurer. */
labelRight: number;
/** Right edge of each chip, in the measurer (index-aligned to the chips). */
chipRights: number[];
/** Width of the "+N" overflow pill. */
plusW: number;
/** Width the trailing "Clear all" button needs INCLUDING its leading gap (0 when n <= 1, since it
* only renders with more than one chip). Reserved only in the "all" branch, which renders it. */
clearW: number;
/** Available lane width. */
avail: number;
/** The real (rem-scaled) inter-item gap. */
gap: number;
/** At or below this lane width an overflow collapses to a single count pill instead of "+N". */
narrowPx: number;
}
export type ChipFit = { mode: "all" | "some" | "count"; visible: number };
export function decideChipLayout(p: ChipFitInput): ChipFit {
const { n, labelRight, chipRights, plusW, clearW, avail, gap, narrowPx } = p;
if (n <= 0) return { mode: "all", visible: 0 };
// Do all chips (plus the Clear all button it renders) fit? Use the last chip's right edge.
const allRight = chipRights[n - 1] ?? labelRight;
if (allRight + clearW <= avail) return { mode: "all", visible: n };
// Overflow. On a narrow lane, collapse everything into one count pill.
if (avail < narrowPx) return { mode: "count", visible: 0 };
// Otherwise fit as many chips as leave room for the "+N" pill after them.
let k = 0;
for (let i = 0; i < n; i++) {
const right = chipRights[i] ?? 0;
if (right + gap + plusW <= avail) k = i + 1;
else break;
}
// If not even one chip fits beside the pill, fall back to the count pill.
if (k === 0 && labelRight + gap + plusW > avail) return { mode: "count", visible: 0 };
return { mode: "some", visible: k };
}
+26 -24
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { applyDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
import { selectDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
import { type FeedFilters } from "./api";
const PRESETS: DatePreset[] = [
@@ -12,7 +12,7 @@ const PRESETS: DatePreset[] = [
const base = (over: Partial<FeedFilters> = {}): FeedFilters => ({
tags: [],
tagMode: "or",
tagModes: { language: "or", topic: "or", other: "or" },
q: "",
sort: "date",
scope: "my",
@@ -48,41 +48,43 @@ describe("deriveDateKey", () => {
});
});
describe("applyDateChip", () => {
describe("selectDateChip", () => {
it("picking a preset sets maxAgeDays and clears any custom range", () => {
const change = applyDateChip(
"7",
base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }),
false,
);
expect(change.customDates).toBeUndefined();
const change = selectDateChip("7", base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }));
expect(change.customOpen).toBe(false);
expect(change.filters).toMatchObject({ maxAgeDays: 7, dateFrom: undefined, dateTo: undefined });
});
it("re-picking the active preset toggles it off", () => {
const change = applyDateChip("7", base({ maxAgeDays: 7 }), false);
expect(change.filters?.maxAgeDays).toBeUndefined();
it("picking a preset always sets it (no toggle-off) even when it's already active", () => {
const change = selectDateChip("7", base({ maxAgeDays: 7 }));
expect(change.filters.maxAgeDays).toBe(7);
expect(change.customOpen).toBe(false);
});
it("a preset is not 'active' while a custom range is open, so it sets rather than toggling off", () => {
const change = applyDateChip("7", base({ maxAgeDays: 7 }), true);
expect(change.filters?.maxAgeDays).toBe(7);
it("'any' clears the preset and any custom range", () => {
const change = selectDateChip("any", base({ maxAgeDays: 7, dateFrom: "2026-01-01" }));
expect(change.filters).toMatchObject({
maxAgeDays: undefined,
dateFrom: undefined,
dateTo: undefined,
});
expect(change.customOpen).toBe(false);
});
it("opening Custom sets the flag and clears a preset's maxAgeDays", () => {
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), false);
expect(change.customDates).toBe(true);
expect(change.filters).toMatchObject({ maxAgeDays: undefined });
it("an empty key behaves like 'any'", () => {
const change = selectDateChip("", base({ maxAgeDays: 7 }));
expect(change.filters.maxAgeDays).toBeUndefined();
expect(change.customOpen).toBe(false);
});
it("closing Custom clears the flag and leaves filters untouched", () => {
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), true);
expect(change.customDates).toBe(false);
expect(change.filters).toBeUndefined();
it("opening Custom sets the flag and clears a preset's maxAgeDays but keeps a typed range", () => {
const change = selectDateChip("custom", base({ maxAgeDays: 7, dateFrom: "2026-01-01" }));
expect(change.customOpen).toBe(true);
expect(change.filters).toMatchObject({ maxAgeDays: undefined, dateFrom: "2026-01-01" });
});
it("preserves unrelated filter fields when picking a preset", () => {
const change = applyDateChip("30", base({ q: "hello", tags: [1, 2] }), false);
const change = selectDateChip("30", base({ q: "hello", tags: [1, 2] }));
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
});
});
+24 -26
View File
@@ -1,51 +1,49 @@
import { type FeedFilters } from "./api";
// Pure date-filter selection logic for the feed sidebar's "Upload date" chip row, lifted out of the
// Pure date-filter selection logic for the feed sidebar's "Upload date" selector, lifted out of the
// component so the single-select derivation + the preset/custom dispatch can be unit-tested without
// mounting Sidebar (same split as columnSort.ts for sort). The row is single-select across the day
// presets plus a "Custom" range chip.
// mounting Sidebar (same split as columnSort.ts for sort). The control is a single-select dropdown
// across an "Any time" clear, the day presets, and a "Custom range" (reveal-on-select from/to).
export interface DatePreset {
days: number;
key: string;
}
/** Which date chip is active: a preset's day-count (as a string), "custom", or "" (none). A custom
* range (the editor is open, or a from/to is set) owns the selection; otherwise a preset matches only
* when no custom range is active; a maxAge that matches no preset (e.g. a shared-link value) is none. */
/** Which date option is active: a preset's day-count (as a string), "custom", or "" (none / "Any
* time"). A custom range (the reveal is open, or a from/to is set) owns the selection; otherwise a
* preset matches only when no custom range is active; a maxAge that matches no preset (e.g. a
* shared-link value) is none. */
export function deriveDateKey(
filters: Pick<FeedFilters, "maxAgeDays" | "dateFrom" | "dateTo">,
customDates: boolean,
customOpen: boolean,
presets: readonly DatePreset[],
): string {
if (customDates || filters.dateFrom || filters.dateTo) return "custom";
if (customOpen || filters.dateFrom || filters.dateTo) return "custom";
const preset = presets.find((p) => filters.maxAgeDays === p.days);
return preset ? String(preset.days) : "";
}
/** The state change when a date chip is picked. A preset is single-select with toggle-off (re-picking
* the active preset clears it) and clears any custom range; "custom" toggles the range editor and,
* when opened, clears a preset. Returns the next `filters` (only when they change) and/or the next
* `customDates` flag (only when it changes) so the caller applies exactly what moved. */
export function applyDateChip(
/** The state change when a date option is picked from the dropdown. Unlike the old chip row there is no
* toggle-off — "Any time" is the explicit clear. A preset sets maxAge and clears any custom range;
* "custom" opens the reveal and clears the preset (keeping any typed range); "any"/"" clears everything.
* Returns the next `filters` and the next `customOpen` flag so the caller applies both. */
export function selectDateChip(
key: string,
filters: FeedFilters,
customDates: boolean,
): { filters?: FeedFilters; customDates?: boolean } {
): { filters: FeedFilters; customOpen: boolean } {
if (key === "custom") {
const next = !customDates;
return next
? { customDates: true, filters: { ...filters, maxAgeDays: undefined } }
: { customDates: false };
return { filters: { ...filters, maxAgeDays: undefined }, customOpen: true };
}
if (key === "any" || key === "") {
return {
filters: { ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined },
customOpen: false,
};
}
const days = Number(key);
const activePreset = filters.maxAgeDays === days && !customDates;
return {
filters: {
...filters,
maxAgeDays: activePreset ? undefined : days,
dateFrom: undefined,
dateTo: undefined,
},
filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined },
customOpen: false,
};
}
-12
View File
@@ -145,15 +145,3 @@ export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map
export function moduleOrder(me: Me): Page[] {
return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id);
}
// Step to the next/previous module cyclically (wraps at both ends). `dir` = +1 forward, -1 back.
// If the current page isn't a primary module (e.g. Settings), forward lands on the first module
// and back on the last.
export function stepModule(me: Me, current: Page, dir: 1 | -1): Page {
const order = moduleOrder(me);
if (order.length === 0) return current;
const i = order.indexOf(current);
// order is non-empty (guarded above) and every index below is in-bounds, so none are undefined.
if (i === -1) return dir === 1 ? order[0]! : order[order.length - 1]!;
return order[(i + dir + order.length) % order.length]!;
}
+1 -1
View File
@@ -16,7 +16,7 @@ export interface PanelLayout {
// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are
// appended by normalizeLayout, so nothing silently disappears.
const PANEL_ORDER: Record<PanelId, string[]> = {
feed: ["savedviews", "date", "channel", "tags", "language", "topic"],
feed: ["date", "channel", "tags", "language", "topic"],
plex: [
"playlists",
"watch",
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { preferSticky } from "./savedViews";
const v = (id: number) => ({ id, name: `v${id}` });
describe("preferSticky", () => {
it("returns null when nothing matches", () => {
expect(preferSticky([], 3)).toBeNull();
expect(preferSticky([], null)).toBeNull();
});
it("returns the only match regardless of stickyId", () => {
expect(preferSticky([v(5)], null)?.id).toBe(5);
expect(preferSticky([v(5)], 99)?.id).toBe(5);
});
it("prefers the sticky view among several identical-filter matches", () => {
// A (id 1) is earlier in the list, B (id 2) was explicitly applied (sticky).
expect(preferSticky([v(1), v(2)], 2)?.id).toBe(2);
});
it("falls back to the first match when the sticky id isn't among the matches", () => {
expect(preferSticky([v(1), v(2)], 99)?.id).toBe(1);
expect(preferSticky([v(1), v(2)], null)?.id).toBe(1);
});
});
+12
View File
@@ -0,0 +1,12 @@
// Pure selection helpers for saved ("personal") views, split out of SavedViewsProvider so the
// tie-break logic is unit-testable without mounting the provider (its React Query / context deps).
/** Among the views whose filters match the live filters, pick the one to treat as active: prefer the
* explicitly-applied ("sticky") view when several capture identical filters, so apply(B) isn't
* silently re-attributed to an earlier duplicate A; otherwise the first match. Null if none match. */
export function preferSticky<T extends { id: number }>(
matches: T[],
stickyId: number | null,
): T | null {
return matches.find((v) => v.id === stickyId) ?? matches[0] ?? null;
}
+21 -2
View File
@@ -12,7 +12,7 @@ import {
filtersToParams,
paramsToFilters,
} from "./urlState";
import type { FeedFilters } from "./api";
import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
function setLocation(search: string) {
window.history.replaceState(null, "", `/app${search}`);
@@ -23,7 +23,7 @@ function queryOf(url: string): URLSearchParams {
const DEFAULTS: FeedFilters = {
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
@@ -121,4 +121,23 @@ describe("filterParamUpdates", () => {
expect(back.scope).toBe("all");
expect(back.includeShorts).toBe(true);
});
it("round-trips per-category tag modes (only 'and' is emitted)", () => {
const f: FeedFilters = {
...DEFAULTS,
tags: [1, 2],
tagModes: { language: "and", topic: "and", other: "or" },
};
const params = filtersToParams(f);
expect(params.get("tagmode_language")).toBe("and");
expect(params.get("tagmode_topic")).toBe("and");
expect(params.has("tagmode_other")).toBe(false); // 'or' is the default, not emitted
const back = paramsToFilters(params, DEFAULTS);
expect(back.tagModes).toEqual({ language: "and", topic: "and", other: "or" });
});
it("migrates a legacy global ?tagmode=and into the topic slot only", () => {
const back = paramsToFilters(new URLSearchParams("tagmode=and"), DEFAULTS);
expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
});
});
+14 -4
View File
@@ -4,7 +4,7 @@
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is
// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
import type { FeedFilters } from "./api";
import { TAG_CATEGORIES, type FeedFilters } from "./api";
const KEYS = [
"q",
@@ -16,7 +16,10 @@ const KEYS = [
"shorts",
"live",
"tags",
"tagmode",
"tagmode_language",
"tagmode_topic",
"tagmode_other",
"tagmode", // legacy global (topic-only) — still recognised so old shared links resolve
"channel",
"maxage",
"from",
@@ -38,7 +41,9 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
if (f.includeShorts) p.set("shorts", "1");
if (f.includeLive) p.set("live", "1");
if (f.tags.length) p.set("tags", f.tags.join(","));
if (f.tagMode === "and") p.set("tagmode", "and");
for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and");
}
if (f.channelIds.length) p.set("channel", f.channelIds.join(","));
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
if (f.dateFrom) p.set("from", f.dateFrom);
@@ -84,7 +89,12 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
.split(",")
.map((s) => Number(s))
.filter((n) => Number.isInteger(n));
if (params.has("tagmode")) f.tagMode = params.get("tagmode") === "and" ? "and" : "or";
// Per-category tag match; a legacy global `tagmode=and` (topic-only) migrates into the topic slot.
f.tagModes = {
language: params.get("tagmode_language") === "and" ? "and" : "or",
topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or",
other: params.get("tagmode_other") === "and" ? "and" : "or",
};
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
f.maxAgeDays = num(params.get("maxage"));
f.dateFrom = params.get("from") || undefined;
+2 -2
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type FeedFilters } from "./api";
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "./api";
import { parseSort } from "./feedSort";
/** One narrowing the user has applied, with the label to show and the way to undo just this one. */
@@ -14,7 +14,7 @@ export interface ActiveFilter {
/** What the feed looks like with nothing applied — the baseline every "active" check reads against. */
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
sort: "newest",
includeNormal: true,
+16 -13
View File
@@ -6,6 +6,7 @@ import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider";
import { NavigationProvider } from "./components/NavigationProvider";
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
import { SavedViewsProvider } from "./components/SavedViewsProvider";
import { PlaylistsProvider } from "./components/PlaylistsProvider";
import { PlexProvider } from "./components/PlexProvider";
import { ChannelsProvider } from "./components/ChannelsProvider";
@@ -57,19 +58,21 @@ const root =
<ConfirmProvider>
<NavigationProvider>
<FeedFiltersProvider>
<PlaylistsProvider>
<PlexProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
<App />
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</PlexProvider>
</PlaylistsProvider>
<SavedViewsProvider>
<PlaylistsProvider>
<PlexProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
<App />
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</PlexProvider>
</PlaylistsProvider>
</SavedViewsProvider>
</FeedFiltersProvider>
</NavigationProvider>
</ConfirmProvider>