Commit Graph
16 Commits
Author SHA1 Message Date
peter ccd5f3739a feat(e24): pin to the comfort shelf from every card and row
S3 wires the shelf's editor. The gesture is the E10 right-click menu, so the
decision "this is comfort media" can be made where the thing is — the feed, a
Plex grid, a channel row, a playlist — rather than only on the page that lists
the shelf.

One shared piece, `components/comfortPin.tsx`: the five pin-kind constructors,
the shelf query, and ONE `ActionMenuItem` that switches Pin/Unpin honestly (and
says "not known yet" with a disabled row until the shelf has answered, since a
confident "Pin" over something already pinned is exactly the quiet lie the house
rules forbid). Surfaces that already have a row menu compose the item in
(`Playlists`, `PlaylistsRail`); the rest mount `ComfortPinMenu`.

Two structural notes:

- The feed's menu lives in `VirtualFeed`, not in `Feed` — that is the list, and
  it is also what gives the channel page the gesture for free, since it renders
  `<Feed channelScoped>` all the way down.
- Cards take `menuProps` as a FUNCTION of the row, not the built props object.
  `triggerProps` is stable across renders while its return value is not, and a
  fresh object prop would blow past `VideoCard`'s memo for every card in the
  virtualized feed on every parent render.

`DataTable` and `ChannelLayoutGrid` grow a generic `rowProps` so a channel row
answers the same gesture in both of the manager's layouts; the table stays
domain-free and never learns what a menu is.

On Home the shelf tile's menu is the whole editor: four move rows (front / left
/ right / end, reusing the playlist menu's ordering idiom) plus the unpin, over
menu rows rather than drag & drop — dragging inside a horizontal, scrolling
Carousel fights the scroll, and menu rows are keyboard-reachable for free. The
reorder is optimistic on the cached payload and re-asks the server on failure
rather than showing an order that was never accepted.

The exhausted shelf tile (a pinned channel with nothing unwatched left) is
`aria-disabled`, not `disabled`: measured in Chromium, a disabled button still
receives contextmenu and pointerdown, but it loses focus — and with it Shift+F10,
the only keyboard route to the menu it most needs.

Verified end to end in the browser against the live account: all five pin kinds,
the reorder (persisted across a reload), the unpin, and the honest Unpin label on
a resume tile that is already on the shelf.
2026-09-06 05:07:06 +02:00
peter 7d302f71b7 feat(e10): let an ultrawide buy bigger cards instead of more of them
The viewport contract has two ends, and only the floor was enforced. An auto-fill grid
answers "more room" with more columns, never bigger ones, so it settles just above its
minimum column at any width: measured at 3440px, the feed laid out 11 columns of 264px.
A feed diluted across an ultrawide fails the contract as surely as a broken one at the
floor.

Multi-column feed views now carry a `maxCols`; past it the leftover width flows into
the items. At 3440: cards 11x264 -> 8x370, cardsSmall 15 -> 11x264 (still visibly the
dense card, and never as wide as cards gets), tiles 7 -> 6x498. Each cap is set so the
item tops out near 1.5x its own minCol, which is what keeps a view recognisably itself.

A cap can only bind ABOVE the width at which that many columns already fit, so it is a
pure ultrawide affordance — 1024, 1920 and 2560 lay out exactly as before. Verified in
the browser at all three widths and across all five views.

The Channels grid needs no cap: the page's own 96rem --content-mw already bounds it.

Also renames VirtualGrid's `maxCol` to `colMaxWidth`. It was never a column count — it
is the single-column reading-width cap — and `maxCol`/`maxCols` one letter apart is the
kind of drift that has bitten this repo before.

The column maths moves to ui/gridColumns.ts as a pure function so both ends of the
contract are unit-testable without a layout engine; the tests assert the cap's numbers,
that it never binds at the floor, and that the dense card cannot grow into the standard
one.
2026-09-05 22:22:28 +02:00
peter 5bfcf5d83a refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with
arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17),
PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among
~160 feature components with nothing marking it as one. Several of those
files describe themselves as 'the one place' or 'canonical' and were
still bypassed by hand-rolled markup, because a layer nobody can see is a
layer nobody uses; three separate inventory passes of this codebase
misread it the same way.

Moves the 33 domain-free modules into components/ui/, rewrites the
imports in 89 files, and makes the boundary a lint rule rather than a
convention: inside ui/ a one-level-up import (a feature component) and
any lib/api slice are errors. The rule was verified to fire on both and
to leave generic lib/ helpers alone. README.md states which side of the
line a component belongs on and why.

No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests
all green; no backend file touched.
2026-09-05 16:58:29 +02:00
peter 5de6be8fde refactor(api): literal-union the closed-value string fields (R7 S3a·1)
Video.status, Playlist.kind/source (+ PlaylistDetail), DownloadJob.job_kind
were bare `string`; type them as named unions verified against the backend
(VALID_STATES={new,watched,hidden}; kind user|watch_later; source local|
youtube; job_kind download|edit). VideoStatus then propagates through the
optimistic-override map, onState, and the VideoCard/VirtualFeed/PlayerModal
props — tsc now rejects a status typo (e.g. a stale "in_progress", which is a
derived filter, never a stored status) at every call site.
2026-07-27 22:40:11 +02:00
peter 5ea82095a1 refactor(feed): extract virtualization engine into a generic VirtualGrid<T>
VirtualFeed becomes a thin wrapper that maps a FeedView to VirtualGrid's
layout spec and renders a VideoCard per item; all measure/chunk/place/
infinite-scroll mechanics move to VirtualGrid so the channel manager (E4 S3)
can reuse them. Behaviour-preserving: e2e 17/17, all render families smoked.
2026-07-19 21:36:54 +02:00
peter a1bdfece6d refactor(nav): read navigation from context, drop the prop-drilling
VideoCard/PlayerModal read openChannel from useNavigationActions (the memo'd
VideoCard subscribes to the STABLE actions context, so a page/channel change
no longer re-renders it); NavSidebar/Header read page/setPage; ChannelPage
reads closeChannel; NotificationsPanel reads setPage. App sheds the matching
props, and Feed/VirtualFeed no longer thread onOpenChannel at all.
2026-07-18 02:19:52 +02:00
peter ba23c0c996 refactor(feed): derive the row's drop-out thresholds from its column widths
They were two hand-written sets of numbers describing the same boxes —
change a w-32 and the thresholds are silently wrong, the symptom being a
column appearing just before it fits. Now computed from one table (which
reproduces the old values exactly).

Width also stops reaching the four views that ignore it: it changes every
frame of a resize, so passing it everywhere pushed all of them through
VideoCard's memo for nothing.
2026-07-17 02:13:45 +02:00
peter be05c80d82 fix(feed): drop the compact row's columns by the ROW's width, not the viewport
The viewport was the wrong signal: the nav rail and filter panel move the
row's width by ~430px between them, so at a 1280 window with both pinned
every column still qualified while the row had 762px for 766px of them —
the title squeezed back to nothing, the exact bug the breakpoints were
meant to fix. My own comment predicted it and I didn't follow it through.

VirtualFeed already measures the container for colCount, so it now passes
the item's real width down. Measured in the failing config: title -4 ->
359 with the date column standing down.
2026-07-17 02:11:06 +02:00
peter 5436a81d10 refactor(feed): read the view spec's column bounds in one place 2026-07-16 04:23:43 +02:00
peter 6f1afbbb13 fix(feed): re-measure the bare row's first-paint estimate
Making it a single line halved its height (82 -> 46), which left the
virtualizer sizing a 60-item page ~2160px too tall.
2026-07-16 04:20:59 +02:00
peter f108c93c02 improvement(feed): rework the row views from UAT feedback
The bare row becomes a single line — title truncating into the space
left, then channel, badges, meta and the actions each holding a fixed
column — and it drops the 896px reading cap, since with the actions
right-aligned there is no gap for the extra width to stretch. The
actions get a reserved slot: the reset button only exists for videos
with watch state, so without one the meta sat 32px further right on
some rows than others.

Rows and tiles get a permanent container instead of a hover-only fill.
They were transparent at rest and painted an opaque bg on hover, so
running the pointer down the list kept covering and re-revealing the
ambient backdrop — which reads as the background flickering.

Card actions now sit on the bottom edge rather than wherever the title
ends, and the small thumbnails carry icon-only controls: the labelled
Continue / Restart are sized for a card's image and were clipped at
176px.
2026-07-16 04:14:27 +02:00
peter 944dedb218 fix(feed): keep the duration, live state and resume bar in the bare row
Dropping Thumb dropped everything it draws ON the image — duration, the
live/stream badges, the saved marker and the resume bar. Those are
metadata, not decoration, so they move into the meta line and the bar
onto the row. Splits the spec's overloaded compact flag into thumb/dense,
which meant one thing per family and nothing on its own.
2026-07-16 03:25:05 +02:00
peter 78f21eb46b feat(feed): add the small-card, compact-row and tile views
Tiles answer the row view's wasted space: the actions move under the meta
into vertical slack the thumbnail already reserves, which frees ~168px of
width, so the block survives in a narrow column and the list can go
multi-column. Rows stay single-column on purpose — reading down one edge
is why that view exists.

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

lib/feedView.ts owns the vocabulary and maps the old grid/list prefs
forward on read, so nobody's saved choice is lost.
2026-07-16 02:29:32 +02:00
peter c8123dd9ae feat(channels): dedicated channel page + ephemeral explore UI
Frontend for the channel-explore feature:
- ChannelPage: banner/avatar/stats header, Subscribe/unsubscribe, an "exploring" badge
  while browsing an un-subscribed channel, Videos/About tabs. Reuses Feed scoped to the
  channel (scope=all + source=all so the per-user ephemeral videos show). Auto-ingests
  recent uploads on first visit (background, with a loading note) + "Load more from
  YouTube" to page deeper; skipped for demo / already-subscribed channels.
- App: openChannel/closeChannel as a Back-aware sub-view (history.state._chan, mirrors the
  YT-search _yt pattern); ChannelPage takes over the content column, nav rail stays.
- ChannelLink/cards/player: the channel name now opens our channel page (onChannelFilter →
  onOpenChannel); the in-card "only this channel" filter button is dropped (the page
  subsumes it). PlayerModal channel-name wiring follows in the next commit.
- api: channelDetail + exploreChannel; ChannelDetail/ExploreResult types.
- i18n EN/HU/DE: channel namespace, explore_cleanup scheduler labels, explore config group,
  channels_explore quota label.
2026-06-30 03:08:52 +02:00
peter 9d1df3edce feat(feed): virtualize the feed list and consume the keyset cursor
Render only the visible rows via @tanstack/react-virtual (useVirtualizer
against the app's <main> scroll container, per-row dynamic measurement),
so the DOM no longer accumulates every loaded card. A ResizeObserver keeps
the responsive grid column count in sync and chunks cards into virtualized
rows; the list view virtualizes single-card rows. Infinite scroll now
triggers from the virtual range and pages via next_cursor instead of
offset; feed/count drops its unused paging args.
2026-06-25 19:54:40 +02:00