Commit Graph
205 Commits
Author SHA1 Message Date
peter 9dc7f80305 improvement(welcome): gallery landing, refreshed copy, name-free screenshots
Rebuild the landing preview into a per-module screenshot gallery (feed hero + channels, playlists, search, player, downloads, Plex), reusing the existing Preview/Lightbox. Rewrite the hero + feature copy in a plainer, concise tone that reflects the current modules (Download Center, Plex, two-way playlists), in EN and HU. Regenerate all screenshots name-free. Bring the README up to date: fix the broken landing image, add the Plex feature, and correct the now-temporary YouTube-search behaviour.
2026-07-29 18:28:51 +02:00
peter 9d3d69f0e9 feat(channels): press-and-hold priority stepper + inline-editable value
The priority arrows changed one step per click, so setting a large value meant many clicks. Now a
press-and-hold auto-repeats at an accelerating rate (a quick tap still steps once; keyboard path
kept), and the PATCH is debounced per channel so a hold sends one request with the final value, not
one per notch (flushed on unmount). The value also sits LEFT of the arrows (cursor never covers it)
and is click-to-edit — type any integer directly, negatives allowed — via an absolutely-positioned
input that overlays without disturbing the table row layout.
2026-07-28 02:22:36 +02:00
peter eb922cb589 feat(channels): surface full-history pull + honest stored/total count on the channel page (S3)
The catalog only stores the recent-backfill/RSS subset of a channel's uploads (video_count is
YouTube's raw total), so a subscribed channel looked like it was "missing" videos. Now the channel
page header shows "{stored} / {total} videos" when there's a gap, and a "Full history" action queues
the deep backfill (the same deep_requested trigger the channel manager uses) — showing a loading
state until done. Backend: channel_detail now returns deep_requested/backfill_done.
2026-07-28 02:22:36 +02:00
peter cdcffb8c91 feat(downloads): categorize download failures + a readable error dialog (S2)
Failed downloads stored the raw yt-dlp/ffmpeg text and dumped it as a truncated red
one-liner in the queue. Classify the failure into a category (unavailable / login_required /
geo_blocked / format_unavailable / drm / postprocess / source_missing / network / unknown)
and surface it properly:

- backend: app/downloads/errors.classify maps the raw message to a category code; the worker
  stores it in the new download_jobs.error_code (migration 0061) at both failure sites; resume
  clears it; the serializer exposes it. Raw text kept as technical detail.
- frontend: the queue row shows a clean localized reason + a "details" link opening a modal
  with the explanation, the raw text behind a disclosure, and a Retry only for transient
  categories (network/postprocess/unknown).
- tests: classify() unit coverage over verbatim yt-dlp/ffmpeg message shapes.
2026-07-28 01:40:22 +02:00
peter 3f15f35256 feat(downloads): S1 UI quick wins — queue default, dates, auto-link, thumb fallback
- Always land on the Queue tab (drop the persisted last-tab) so new work/failures are the default view
- Show the download date (relative) on Library rows
- Share dialog opens with a default link already generated when none exists; all link controls stay available
- Thumbnail falls back to the self-hosted poster when a remote (i.ytimg/fbcdn) thumbnail 404s
- Add a clipboard-paste affordance to the add-URL box (explicit button + best-effort on focus)
2026-07-28 01:21:50 +02:00
peter d0aa1cf9a7 refactor(api): code-review fixes (R7 S1)
- localizeDetail: don't leak FastAPI's snake_case `loc` field names into a
  translated 422 toast; return a clean, fully localized validation message
  (drops the loc filter/pop parsing entirely)
- me(): drop the dead `r &&` guard now that req<T> types r non-nullable
2026-07-26 22:17:04 +02:00
peter 16585d868d refactor(api): type req<T>() and tighten the error layer (R7 S1)
req() returned Promise<any>, so every api.* return annotation was an
unchecked assertion and the 17 action endpoints leaked `any` to callers.
Make req generic with a single audited cast at the boundary; T flows from
each method's declared return type via contextual inference, so no call
site needs a type arg except where none existed:

- me(): type the /api/me probe ({ authenticated } + Partial<Me>)
- deepAll / syncSubscriptions: real backend-verified response shapes
- HttpError.detailData, localizeDetail(d), inner detailData: any -> unknown

Also teach localizeDetail the FastAPI 422 detail-ARRAY shape (carried in
from R6 S1b review): now that malformed requests return 422, surface the
offending field via a localized errors.validation key instead of falling
through to the generic toast.

No runtime change on normal flows (req is await-identical); the 422 branch
only fires on genuinely malformed input. Gate green: typecheck, eslint,
prettier, 68 vitest. Smoke-tested localdev: /api/me + feed 200, no console
errors.
2026-07-26 21:51:24 +02:00
peter 51193105a7 fix(player): first-class maximise/fullscreen view (UAT round)
UAT feedback on the F-maximise view — three fixes:
- Own control bar in the big views (maximise + our fullscreen): previous/next +
  N/M, auto-advance, loop, add-to-playlist, download, mark-watched, exit, and a
  true-fullscreen toggle. Auto-hides on idle. Add/download are hidden in true
  fullscreen (their popovers portal to <body>, outside the fullscreen element).
- Wheel volume works in the big views again: the centre interaction overlay is
  now armed in maximise/fullscreen (it only yields to YouTube's own settings
  menu), so the wheel bubbles to our handler instead of the bare iframe. F also
  offers true fullscreen that WE own (Fullscreen API on the stage), so wheel/
  arrow volume and our controls work inside it too — YouTube's own fullscreen
  button, which we never surface, is not involved.
- Back (mouse/browser) steps out one level at a time via nested history layers
  (useBackToExit): fullscreen -> maximise -> the modal -> closed, instead of
  closing the whole player at once.

Verified in Chrome: wheel volume in maximise + fullscreen, the control bar, and
the full Back ladder (fullscreen -> maximise -> modal -> closed).
2026-07-26 02:48:46 +02:00
peter a5336d20e0 fix(r5): address the seventh /code-review high round — 7 findings
Five of them sit where round 6 turned F from a Fullscreen API call into a CSS
maximise: the guards around it still asked `isFullscreen`, which that pivot made
permanently false.

- Arrow-key volume was dead in the maximised view: the scroll-deference checked
  only `isFullscreenRef`, so the (now hidden but still scrollable) card kept
  swallowing Up/Down. Both flags now count as "the video fills the screen".
  Measured: 87px of scroll room on the focused card, ArrowDown moves 80 -> 75
  and scrolls nothing.
- The maximised view had no way out once the pointer touched the video. Reaching
  YouTube's controls means clicking into the cross-origin iframe, which takes
  keyboard focus with it, and the re-arm paths (stage mouseleave, window focus)
  need the pointer to leave the browser entirely while the stage fills it. So
  the exit now lives on screen, in our DOM: a button that un-maximises AND pulls
  focus back, re-arming every shortcut.
- WebKit fires only `webkitfullscreenchange`/`webkitFullscreenElement`, so on
  Safari/iPadOS YouTube's own fullscreen never registered — no overlay yield, no
  focus reclaim. One `fullscreenEl()` reader, both event spellings.
- The stage claimed `z-index: 9999`, the glass tuner's reserved level, for a job
  that only needs to beat the modal chrome it is nested in (its siblings top out
  at z-menu). Dropped to rail-level; nothing escapes the modal's stacking
  context either way.
- `inset: 0` already sizes a fixed layer — the inherited `100vw`/`100vh`
  over-constrained it, which is how a classic scrollbar gets a horizontal
  overflow and a mobile URL bar hides the bottom edge (YouTube's control bar).
- The shortcut hint still promised "F: fullscreen" in both locales.

Backend, both "the fix stopped one layer short":
- The startup HLS sweep was awaited inside lifespan, so uvicorn still served
  nothing until it finished — a thread doesn't help when nothing is listening
  yet. Fire-and-forget task instead, cancelled on shutdown. Measured with 200
  planted segments: "Application startup complete" now precedes "swept 1".
- An RSS poll where every channel failed returned normally, so the scheduler
  recorded `ok` with the outage buried in the summary string. `failed == total`
  now raises: a run that reached nothing is a failed run.

Suites: backend 147 -> 150.
2026-07-24 03:41:29 +02:00
peter 45654fc7ec fix(player): F maximises without the Fullscreen API, so YouTube's own controls work
Root cause, measured in Chrome/Brave/Firefox alike (so: spec behaviour, not a
browser bug): a cross-origin embed reads its fullscreen state from ITS OWN
document. While we hold the browser's fullscreen lock — on our wrapper OR on the
iframe element — the YouTube player never learns it is fullscreen, so its own
fullscreen button stays in the "enter" state and clicking it changes nothing
visible. Every other YouTube control works, which is exactly what the user saw.

So we stop taking the lock: F now toggles a CSS layer (`player-stage--max`) that
fills the viewport. The page stays non-fullscreen, so the embed's own fullscreen
button behaves natively, and our own overlays and shortcuts keep working because
our DOM is what's on screen. Escape steps out of maximise before closing.

Verified end to end in real Chrome:
- F -> stage 1920x889, document.fullscreenElement null (no lock taken)
- YouTube's own fullscreen button -> real fullscreen (innerHeight 889 -> 1024,
  fullscreenElement becomes the IFRAME) and its icon flips to "exit"
- clicking it again -> back to the maximised view, our class still applied
- wheel volume while maximised: 100 -> 85, with the level flash visible

Drops the "YouTube fullscreen" toolbar button added earlier today: with the lock
gone, YouTube's own button does that job correctly.
2026-07-24 02:46:42 +02:00
peter d8d82e47d0 fix(player): give YouTube's own fullscreen a working path + round-6 findings
The reported bug: after F, the embed's own fullscreen button is dead. Root cause
measured in Chrome — the browser's fullscreen lock sits on OUR wrapper, so
YouTube's button would need a nested request from a cross-origin frame. Handing
the lock to the iframe fixes it, but `iframe.requestFullscreen()` only resolves
from a CLICK (from a key press the promise stays pending forever, and awaiting
it spends the activation so a fallback is denied too).

So: F keeps fullscreening our wrapper (unchanged, our overlays stay visible) and
a new toolbar button hands the lock to the iframe — verified in real Chrome:
document.fullscreenElement is the IFRAME at 1920x1024, i.e. a plain YouTube
fullscreen where its own controls work.

Round-6 review findings, all fixed:
- one _rate_limiter + one _cancel_poll shared by the yt-dlp hook and run_ffmpeg
  (was three hand-rolled throttles; cancel latency differed 2s vs 1s)
- stepVolume takes {delta, fallback} — two adjacent number args were silently
  transposable
- volume gestures before the player is ready are honoured instead of dropped
- normalizeVolume's fallback is typed unknown (its guard was unreachable) and
  applyVolume takes a number again
- the throttle tests inject a clock instead of patching global time.monotonic
2026-07-24 02:16:58 +02:00
peter 5130584d0a fix(player): R5 S3 — fullscreen controls, volume memory, Ctrl+wheel zoom
- the interaction overlay yields fully in fullscreen: YouTube only reveals its
  control bar while the iframe itself sees the mouse, so after pressing F the
  native fullscreen button was unreachable
- remember the volume per account (a YT.Player is created per video and always
  starts at 100), plus ArrowUp/ArrowDown volume keys that work in fullscreen
- Ctrl/Cmd+wheel falls through to the browser's zoom instead of the volume
2026-07-23 22:56:42 +02:00
peter 10da69c1b9 fix(downloads): confirm before clearing a link password; fix placeholder
Review follow-up on the share-link password UI:
- Removing a link's password makes it publicly watchable, a protection
  downgrade; gate it behind a danger-confirm like the neighbouring Revoke,
  spelling out that anyone with the URL can then watch without a password.
- When setting a first password the input reused the create-form "Password
  (optional)" placeholder, but it's required here (Save stays disabled until
  non-empty); use the "Set a password" wording instead.
2026-07-23 04:15:39 +02:00
peter c8cbbb6189 feat(downloads): change/clear a share link's password from the UI
C-3.7 made a password rotation invalidate outstanding grants, but ShareDialog
only let you set a password at link creation — there was no way to rotate or
clear it afterward, so the protection wasn't reachable. Add a lock control to
each existing link row: set / change / remove the password via the existing
update_link endpoint (which bumps password_version and revokes old grants).
2026-07-23 04:03:11 +02:00
peter 1119eb0218 feat(messages): R4 S2 — forgotten-passphrase reset for E2EE
The set-once key guard made a lost passphrase a permanent wall (409 on re-setup,
KeyGate offered only unlock/setup). Add an escape hatch:

- POST /api/messages/keys/reset drops the user's MessageKey so a new passphrase
  can be set. Because every conversation key is derived from the keypair,
  replacing it orphans all stored ciphertext in both directions, so the now-dead
  `user` messages are deleted too (clean slate; system messages untouched).
  Password accounts must re-verify (a hijacked session must not silently reset
  messaging and read future messages); Google-only accounts rely on the session
  + the client danger-confirm. Rate-limited per user.
- KeyGate gains a "Forgot passphrase?" path in unlock mode → a danger reset view
  (spells out the permanent history loss; a current-password field for password
  accounts) → resets and returns to a fresh setup.
- e2ee.resetLocal wipes this device's key + derived conversation keys + the
  IndexedDB copy; api.resetMessageKey; HU/EN strings.
2026-07-23 02:57:27 +02:00
peter bacef11e33 fix(auth): address second-round review findings
- OAuth-cancel feedback now keys on the live session, not link_uid: ANY
  signed-in cancel (add-another-account via /auth/login, link, or upgrade)
  redirects to /?oauth=cancelled and gets a neutral "Google sign-in was
  cancelled" toast — the add-account path set no marker and was silent, and
  the shared ?link= message wrongly called an upgrade "account linking"
- drop 127.0.0.1 from the dev OAuth host allowlist: only the localhost
  callbacks are registered in the Google console, so a 127.0.0.1 origin now
  falls back to the fixed callback instead of a redirect_uri_mismatch
- Welcome: extract the shared goResend handler (banner CTA + sign-in link) and
  merge the two adjacent signin-only blocks
2026-07-23 01:24:34 +02:00
peter c837b59373 fix(auth): address R4 S1 code-review findings
- link/upgrade OAuth cancel now redirects to /?link=cancelled (a channel App.tsx
  surfaces as a toast) instead of the pre-auth /?login=oauth_cancelled banner a
  signed-in user never sees; a plain sign-in cancel is unchanged
- the login banner (suspended / oauth_cancelled) is now state, cleared on the
  first deliberate action like the verify banner — no more stale lingering
- add a "Didn't get the verification email?" resend entry on the sign-in screen
  so a never-arrived verification mail isn't a dead end (was only reachable from
  the expired-link banner)
- unit-test _oauth_redirect_uri (dev-host derive, unknown-host fallback, prod
  ignores the Host header — the injection guard)
- collapse the 5-deep title/button ternaries into Record<Mode,string> maps
2026-07-23 01:04:09 +02:00
peter a851fb213b feat(auth): R4 S1 — fix auth dead ends
- password reset also sets email_verified (the mail proves the mailbox),
  rescuing an account that never clicked the verify link; is_active (admin
  approval) is deliberately left untouched
- add POST /auth/verify/resend (rate-limited, off-response-path, anti-enum)
  + a "resend" mode/CTA on the verifyInvalid banner, so an expired/lost
  verification link is no longer a dead end (re-register was a silent no-op)
- OAuth cancel/deny now redirects to /?login=oauth_cancelled with a friendly
  banner instead of a raw 400 JSON page; pop the oauth_link markers BEFORE the
  token exchange so an aborted link flow can't poison the next clean sign-in
2026-07-23 00:15:26 +02:00
peter a393fd2fb1 fix(ui): R3 S1 review fixes — don't blank data on transient refetch errors
- The honest-state branch now guards on `isError && !data`, so a background/window-focus refetch
  failure keeps already-visible content instead of replacing the list with a full error screen
  (regression the bare `isError` introduced). Applied to AuditLog, Channels, ChannelDiscovery,
  PlexBrowse, PlexPlaylistView, Messages(people), Feed — matching the guard already used in
  Messages(conversations)/NotificationsPanel/Scheduler.
- Drop the unused DataTable loading/error/onRetry props (every table page uses the outer
  StateMessage branch, not the props) — reverts DataTable to its simple empty block.
- StateMessage announces errors with role=alert/aria-live=assertive (was polite for all tones).
- Remove the now-unused common.empty string (en+hu), left over from the removed QueryState render-prop.
2026-07-22 04:14:46 +02:00
peter d9a08892a5 feat(ui): R3 S1 — honest error states across the pages
The error state didn't exist as a concept: a failed main query rendered as an "empty" list
("No channels" while the request actually died), so a backend restart read as "everything is
empty". Add the convention that an empty state may NEVER show while a query is erroring.

- New components/QueryState.tsx: shared StateMessage (loading / error+Retry, house style, with
  role=status/aria-live — the app had zero live regions).
- DataTable gains loading/error/onRetry props (suppresses empty-text on error).
- isError branch (before the empty branch) added across the false-empty pages: Feed (retry),
  Channels, ChannelDiscovery, AuditLog, NotificationsPanel, Messages (conversations + people),
  Scheduler, Stats, PlexBrowse grid, PlexPlaylistView. ChannelPage's main content is Feed, now
  covered. common.loadError/empty strings (en+hu).
2026-07-22 03:58:51 +02:00
peter e46f215ada feat(downloads): ASCII-slug names, universal browser playability, force re-download
Mini-epic "Downloads correctness & UX" (S1–S3), on top of the iOS-AV1 + 720p fixes.

S1 — naming & titles:
- ASCII-fold on-disk dir/file names AND the served (Content-Disposition) filename: no
  spaces (underscore-joined), no accents (á→a, ő→o, ß→ss), pure ASCII — portable and free
  of the `?` mojibake accented names show in non-UTF-8 tools. (Forward-only; existing files
  clean up via re-download.)
- Strip a leading social engagement prefix ("1.6M views · 66K reactions | …") from titles.

S2 — universal browser playability (generalizes the iOS re-encode):
- ensure_browser_playable guarantees the stored file is H.264 + AAC/MP3 in mp4 — the only
  combo every browser decodes. Re-encode just the offending stream(s) (video for AV1/VP9/HEVC,
  audio for Opus/etc), or remux when only the container is wrong. Gated on the mp4 compat
  profile (an explicit vcodec / non-mp4 custom profile opts out).

S3 — force re-download (preserves share links):
- POST /downloads/{job_id}/redownload keeps the job row so its public DownloadLink survives
  and resolves to the freshly-downloaded file; deletes the old file and re-fetches under the
  current rules. A failed re-download leaves the job in error (the intended broken-link case).
- Library "Re-download" action (icon + confirm), api method, hu/en strings.

Tests: naming/title-strip (test_naming), browser_transcode_flags matrix; existing
download_filename expectation updated for the underscore policy.
2026-07-22 02:04:27 +02:00
peter 6ed08383e7 fix(errors,a11y): review round 1 — drop redundant onErrors, stabilize the live region
Review found my C-3.23 additions double-notified: the global error modal (api.ts req)
already surfaces 400/409/422/500 with the backend's specific message, so a caller
toast on those codes is a second, weaker surface. Only 403/404 are caller-handled and
silent — and only a YouTube WRITE action has a meaningful 403 (missing scope).

So the correct set is just the ChannelPage unsubscribe handler (a YouTube write, 403 →
"connect", matching subscribe). Reverted the redundant onErrors on block (a local DB
op, not YouTube), SettingsPanel Plex watch toggle/reimport, and the DownloadCenter job
action — the global modal already speaks for them. Removed the three now-unused keys.

Toaster: moved the polite live region onto the stable, always-mounted container (a
polite region must pre-exist to announce later insertions); error/fatal toasts keep
role="alert", which announces on insertion regardless.
2026-07-21 23:29:51 +02:00
peter d3e466774c fix(errors): surface four mutations that failed silently (C-3.23)
House policy: user actions must report failure. Added onError to:
- ChannelPage block + unsubscribe → notifyYouTubeActionError (a 403 = missing YT
  scope shows the connect message; else the translated fallback);
- SettingsPanel PlexWatchSync toggle + reimport → a plain error notify (these are
  Plex, not YouTube, so notifyYouTubeActionError's "connect YouTube" would mislead);
- DownloadCenter job action (pause/resume/cancel/delete) → a plain error notify.
New keys (HU+EN): channels.notify.blockFailed, settings.plexSync.failed,
downloads.actionFailed.
2026-07-21 23:13:53 +02:00
peter 58b9691467 i18n(a11y): translate the modal + Welcome close buttons and the feed fallback (C-3.22)
House policy is HU+EN for every user-facing string. Modal's shared close button was a
hardcoded title="Close" (in every modal) — now t("common.close") + an aria-label.
Welcome's lightbox close aria-label and Feed's "this video" notification fallback are
translated too (new feed.thisVideo, HU+EN). The other Close hardcodes the review
listed were already fixed since.
2026-07-21 23:13:53 +02:00
peter e006cc879f refactor(frontend): satisfy noUncheckedIndexedAccess (S3b)
Enable noUncheckedIndexedAccess and fix all 98 call sites it surfaced across 18
files. Every fix is behaviour-preserving — the flag flagged reads TypeScript
couldn't prove in-bounds, all of which were already guarded by a length/index check,
a findIndex result, or a non-empty invariant:

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

The single highest-leverage fix: PlayerModal's `active` became `queue?.[index] ??
video` (always Video), clearing 34 of the 45 errors in that file at once. Verified:
tsc/eslint/prettier clean, 56 vitest + 34 pytest + 17/17 e2e green (the e2e suite
exercises the feed/player/channel components touched here).
2026-07-21 04:11:19 +02:00
peter e257e2aca0 style(frontend): Prettier config + one-time repo-wide format pass
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing
hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass
(`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a
feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest
all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
2026-07-21 02:26:40 +02:00
peter f87d58d8d7 fix(playlists): name the view state in one sentence
Sorting a playlist whose stored order already matches the sort produced a
warning, no Save button and no visible change — which reads as a malfunction,
not as "there is nothing to save". And when the order DID change, three separate
texts piled up in the toolbar (a reset link, a save hint, a "reordering is off"
notice), none of which answered the question actually being asked: did I just
change my playlist?

One line now names which of the three situations you are in — the view matches
the saved order, the view differs and can be saved, or a filter is on and saving
is impossible — and says in each case that the saved order is untouched. The
disabled grip still explains itself on hover and focus, so that notice no longer
needs to stand in the toolbar.
2026-07-21 00:40:49 +02:00
peter efd46a01c8 fix(playlists): address the review round — 11 findings
Header (shared, so every table gets these):
- The filter funnel could not close itself: useDismiss only knew the panel, so
  the trigger's mousedown closed it and the click reopened it. It now knows the
  trigger, captured at open time so it survives the close.
- Escape stranded focus on <body>; it goes back to the funnel, which also gained
  aria-expanded/aria-haspopup.
- A non-sortable header rendered a focusable no-op button — two unlabeled dead
  stops per playlist table, whose grip and actions headers are empty. Plain span
  unless the column sorts.

Playlist:
- The disabled drag grip explained itself to nobody: `disabled` drops a button
  from the tab order and suppresses its tooltip. It is aria-disabled now, stays
  focusable, and the reason is its accessible name.
- That reason also lied on a built-in list, where the block has nothing to do
  with sorting or filtering. Third branch, own string.
- The open player followed a position in a derived list, so a refetch or a view
  change could slide a different video under it or unmount it mid-playback. It
  follows the video id, like PlayerModal's own queue already does.
- A failed reorder could not resync: the id set is unchanged by a reorder, so
  the arriving detail was judged "same list" and the rejected order stayed on
  screen as the base for the next drag.
- The same guard is now keyed by playlist. Two lists holding the same videos
  looked identical to it, so the second rendered in the first's order and
  inherited its undo stack — where Undo would write it back to the wrong list.
- A one-item list filtered to nothing had no Reset view: the whole control strip
  was gated on the item count while the funnels rendered regardless.
- Both delete outcomes now read as actions ("Delete here only" / "Delete here
  and on YouTube"); "Here only" scanned as the safe choice while also deleting.
- Keyboard dragging shipped with dnd-kit's hardcoded English screen-reader
  instructions and announcements. Localized, with live positions.
2026-07-20 23:45:31 +02:00
peter 83e673718f feat(playlists): hybrid table over the drag list, and guard its destructive paths
The column sort and the filters are a VIEW; the playlist's stored order only
changes when the user presses "Save this order", which still goes through the
undoable path. Picking a sort used to write straight to the server, so merely
looking at the list by title rewrote the playlist and marked a YouTube-linked
mirror dirty.

The table:
- playlistColumns.tsx holds the column defs; the body renders them as table rows
  and fills the grip cell itself, since the handle needs the row's sortable
  listeners that a render closure can't reach.
- The # column shows the STORED position, so a view-sort never renumbers the
  playlist under the reader.
- Saving is withheld while a filter is active (it would drop the hidden items),
  and drag is disabled under any view, with the reason spelled out rather than
  silently doing nothing.
- applyView/sameOrder live in lib/playlistView.ts with tests: the sort and the
  channel grouping compose, and getting that wrong produces a plausible order
  that the save button would then persist.
- dnd-kit KeyboardSensor: the grip was focusable and called itself "reorder",
  but only a pointer could move it.

The guards:
- Delete is ONE dialog with every outcome. It was two chained confirms where the
  second one's Cancel/Escape/backdrop meant "delete here only" — dismissing the
  dialog deleted the playlist. ConfirmProvider gained useChoice() for it.
- ConfirmProvider focuses Cancel when an offered action is destructive; the
  confirm button took autoFocus unconditionally, so a stray Enter on an open
  dialog ran "Delete user" or "Clear audit log".
- Removing an item asks first, and rebases the undo history instead of throwing
  it away: useUndoable.rebase maps every snapshot, so the reorder history stays
  valid for the remaining items.
- A failed detail query showed "Loading…" forever; it now says so and offers a
  retry. Rename and reorder failures are surfaced instead of swallowed (reorder
  was a bare .then(), which also produced an unhandled rejection).
- Local reorder/remove no longer require YouTube write scope.
- playlistName() replaces the watch-later localizer copied into three files.
2026-07-20 23:30:49 +02:00
peter 7cd5adc78f refactor(format): localize relative time with Intl.RelativeTimeFormat
The tier table kept a divisor and a translation key as two independently
maintained halves, and a mispairing there rendered minutes as "Ns ago" for 59
releases. The table now pairs a divisor with the Intl unit it produces and the
platform supplies the words, so the wrong half is at least self-evident on
sight — "minute" next to 3600 reads wrong in a way "time.minutesAgo" never
did. It also removes 12 hand-written strings and hands plural rules to CLDR
for any language added later.

Hungarian output is byte-identical to the strings it replaces. English gets
shorter: "59 min ago" to "59m ago", "1 wk ago" to "1w ago", "1 mo ago" to
"1mo ago", "1 yr ago" to "1y ago"; "2h ago" and "1d ago" are unchanged.

style narrow matches the app's compact timestamps and numeric always keeps it
counting, rather than "yesterday"/"last week". time.justNow stays because Intl
has no wording for the sub-minute case. Formatters are memoized per language,
since this runs once per rendered timestamp across a virtualized feed.
2026-07-20 22:28:09 +02:00
peter c61bc4ff74 fix(format): relative time labelled every tier one unit too small
The units table paired each divisor with the NEXT row's label, so the count
came out right and the word wrong, all the way up the scale: minutes rendered
as "Ns ago", hours as "N min ago", days as "N hours ago", months as "N weeks
ago". Most visible on the audit log, where scheduler rows three minutes apart
read "1s / 4s / 7s ago".

Each row now carries its own label and is read whole, so a divisor can no
longer drift from its word. Also guard a non-finite age, which rendered
"NaN mo ago", and drop time.secondsAgo now that nothing emits it.

Present since ea317c0 swapped the hardcoded English strings for i18n keys;
shipped in every release from 0.2.0 on.
2026-07-20 03:06:42 +02:00
peter dff4152522 feat(managers): paginate the card layout + its top scroll-fade
UAT round 3 feedback on the card layout:
- Add a pager + "Cards per page" to the card view (it infinitely-scrolled before).
  Extracted the pager cluster out of DataTable into a reusable Pager component
  (the table now renders it too — no behaviour change); the card view slices the
  sorted rows and drives its own per-account page size (subscribed + discovery).
- Restore the top edge-fade in the card layout. The channels page registers
  fadeTop:false for its sticky-header table; the card layout has no sticky header,
  so App now re-enables the top fade when the channel layout is cards.
2026-07-19 23:28:43 +02:00
peter 9c4dd0163a feat(managers): shared sort across the table and card layouts
Cards have no column headers to sort by, so lift the sort out of DataTable into
one shared state that BOTH views drive and reflect. New lib/columnSort.ts holds
the comparator + click-cycle (one source of truth); DataTable gains an optional
controlled sort (sort + onSortChange) and falls back to its internal persistKey
sort when they're omitted, so every other table is untouched. The subscribed
sort lives in ChannelsProvider, discovery keeps its own (its columns differ);
a new ColumnSortControl dropdown (options derived from the columns' sortable
metadata, feed-style + asc/desc toggle) drives it in cards mode. Per-account,
persisted, per-tab.
2026-07-19 23:09:54 +02:00
peter 875c7ad741 feat(managers): richer channel cards + drop the rows layout, cap the channel column
UAT round 1 feedback:
- Retire the `rows` layout — it barely differed from the table; keep Table + Cards.
- The card now shows the About cell (+ its hover overlay) and the priority stepper
  in every layout, not just the table: ChannelLayoutGrid gains named slots
  (lead / about / actions) and stops hiding the `hideInCard` columns, since the
  roomy desktop card has space the cramped mobile fallback did not.
- Fix the table losing its centering with "Rows per page: All": a long-titled
  channel stretched the uncapped Channel column (~289→429px) and overflowed the
  centered table. Cap it with a max-w element inside the cell (table-layout:auto
  ignores a cell max-width), the same trick AboutCell already uses. Gaps now 163/162.
2026-07-19 22:42:09 +02:00
peter 1703d4ab5e feat(managers): table / cards / rows layouts for the channel manager (E4 S3)
A generic ViewSwitcher (reused from the feed) drives a per-account layout
shared by the Subscriptions + Discovery tabs. `table` keeps the DataTable
(numbered pager); `cards` and `rows` render a virtualized grid over the same
client-side list via the new ChannelLayoutGrid, which composes cells straight
from each table's Column<T> render closures (cardPrimary/hideInCard/cardLabel),
so both tabs — with their different columns — get the views for free. Status
chips surface in the fixed band when the table's own controls row is gone.
2026-07-19 21:52:16 +02:00
peter 9db1d3fba3 feat(managers): About column with hover overlay on both channel tables (E4 S2)
- Backend: add channel description to the shared _channel_summary projection, so both
  the subscribed list and the discovery list carry it (no migration; description is
  already stored from the channels.list call)
- New AboutCell: one truncated line in the table + full text in a portalled, always-on
  glass hover/focus overlay (wider than Tooltip, flips above the anchor near the bottom,
  closes on scroll/resize)
- About column (after Channel) on both the subscribed and discovery tables; hidden in the
  mobile card fallback
2026-07-19 19:35:07 +02:00
peter 669dc3f2a6 refactor(managers): tidy channel header + column filters + move Actions (E4)
- Drop the intro blurb (+ its orphan channels.intro i18n key and Trans import)
- Header is now two tight rows: tabs + stats (right), your-tags + sync/backfill (right),
  leaving room on the tab row for future tabs
- Add a funnel filter on the Prio / Channel / Sync / Tags columns (client-side, like
  the discovery table's Channel filter)
- Move the Actions column to 2nd position (after Prio, before Channel), left-aligned
2026-07-19 18:24:25 +02:00
peter 12d17ec7b0 polish(plex): match the feed rail's segmented-pill look for the any/all toggle
The genre + actor MATCH toggles were a boxed button row (rounded-lg border, no
track); restyle them as the feed rail's tag-match toggle — a rounded-full track
(border + bg-card + p-0.5) with an inset rounded-full active pill, plus a MATCH
label. Extracted a shared MatchToggle so both read identically. i18n
plex.filter.matchLabel (en/hu). (Plex cast-explore polish)
2026-07-19 04:31:02 +02:00
peter 05105197e9 feat(plex): person cards in the grid for the filtered actors (S3)
The actors currently in the filter now render as cards above the poster grid —
headshot + name + in-scope title count + a remove x — so you SEE who you're
exploring, not just chips in the rail (generalises the old search-only person
cards to the applied filter). New GET /plex/people/cards (photo from the
plex_people cache, count live against cast_names); PlexBrowse fetches + renders
them; i18n en/hu. Verified: John Goodman (11) / Douglas M. Griffin (1) / Bradley
Cooper (14) with photos + counts. (Plex cast-explore S3)
2026-07-19 03:46:31 +02:00
peter 867daa0dcf feat(channels): pager into the chip row, Blocked as its own tab
Address manager UAT:
- The DataTable controls row (status chips + pager) now portals into the
  fixed band via a new controlsInBand prop, so it stays put above the
  sticky header instead of scrolling away — and the pager fills the empty
  space next to the status chips, on one row.
- The tab bar is always fixed chrome now (not just on Subscriptions), so
  it doesn't jump when switching tabs; on Subscriptions the stats/intro/
  tags ride with it.
- Blocked channels moves off the bottom of the Subscriptions list into its
  own 'Blocked' tab (with an empty state), alongside Subscriptions and
  Discover.

Verified with a 90-channel test account: everything above the rows stays
fixed while the rows scroll under the sticky header. Suite 15/15, tsc +
knip clean.
2026-07-18 05:34:07 +02:00
peter c855924fdc feat(settings): auto-save preferences instead of draft + Save
Every Settings change now persists on its own (debounced ~500ms so a slider drag
is one PUT), with a small transient indicator; the draft/dirty-save UI and the
navigation leave-guard are gone. Also unblocks the navigation extraction — there
is no longer a cross-page "unsaved settings" guard to thread through routing.
2026-07-18 01:44:25 +02:00
peter e52fba8b07 fix(feed): don't tell people to click an indicator that isn't a button
The saved pip borrowed the bookmark button's label, so it read "Saved —
click to remove" on a plain span: hover, be told to click, click, nothing.
It gets its own "Saved" string. The status markers drop their sr-only
twin too — with a title already carrying the meaning, a screen reader
that surfaces title text said it twice.
2026-07-17 00:45:06 +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 0d10e0f0a5 feat(filters): filter the feed by several channels at once (OR)
Pick any number of channels; they OR together and every other filter applies
across the lot — "the last week's unwatched from these three".

`channelId`/`channelName` are replaced outright by `channelIds: string[]`, no
legacy reader (standing rule: no back-compat unless asked — a saved view written
against the old field simply loses its channel filter). The wire and the share
URL move with it: repeated `channel_ids` params, `?channel=a,b`. Backend ORs via
`Video.channel_id.in_()`; the count endpoint shares the same params object.

Two things the migration exposed:
- SavedViewsWidget fed DB blobs straight into serializers that now index an
  array — the FeedFilters type says nothing about what an older build wrote, so
  the blob is coerced where it enters. It white-screened the app before this.
- Chips carried channelName to label themselves. Without it they resolve names
  from the channel cache, so they now fall back to the id, not a shared
  "This channel" that would render N identical chips while the cache loads.
2026-07-16 00:54:22 +02:00
peter 82e351bc62 improvement(channel): bring the actions to the tabs, list every channel
Channel page: the actions sat in the header's top-right corner, a page-width
mouse trip from anything else you'd click. There are only ever two tabs, so
they ride the tab row now, right next to About.

Channel picker: drop the 50-row cap — 320 plain buttons cost nothing to render
(the feed virtualises thousands), so the whole list scrolls in the same box. The
cap was caution, not a measurement. The box gets the app's usual edge-fade to
hint at more, and a line saying "Priority first, then A-Z" — the backend orders
by `priority DESC, lower(title)`, which reads as random to anyone who hasn't set
a priority.
2026-07-16 00:32:53 +02:00
peter 3caef7f6f9 feat(filters): make the feed's channel filter reachable
`filters.channelId` was settable by exactly one thing: a "?channel=" URL from a
shared view. The panel still rendered a CHANNEL card whose only control was
"remove" — a remove button for a filter the UI could not apply.

Two ways in now. The panel gets a real Channel group (searchable picker,
first-class alongside date/tags/language, so the chip row surfaces it for free);
normalizeLayout appends it to saved layouts, so nobody loses their arrangement.
And a subscribed channel's page gets "Show in my feed", which applies the filter
and keeps the rest of your filters — the point is "this channel, the way I
normally read", which the channel page itself can't answer (it runs show=all,
scope=all over the whole catalog).

The picker owns its channel query so a few-hundred-entry list loads when the
panel is actually opened, not on every feed visit, and says "showing 50 of N"
rather than quietly stopping.
2026-07-16 00:11:30 +02:00
peter 0ab1d0b057 fix(header): show the sync chip only where it means something, trim its copy
The chip is about YouTube subscription syncing, so it only earns its place on
the feed those subscriptions fill and the manager where you curate them. On
Plex — a different source entirely — plus playlists, downloads and stats it was
just noise.

Its tooltips were written for a roomy sidebar block and read like documentation
on a pill: the counts explanation went 120 chars -> 48, the full-history one
142 -> 46, and pause/resume drop "background".
2026-07-15 23:55:37 +02:00
peter 644d65a3fd feat(feed): removable active-filter chips above the feed
With the filter panel now collapsed to a tab, there was no way to see what was
narrowing the feed without opening it. Every applied filter is now a chip you
can drop on its own, with a Clear all when more than one is on.

The panel's count badge and the chip row read the SAME list (lib/useActiveFilters),
so they can't disagree about what's applied; that list also owns the reset both
"Clear all"s share. Replaces the hand-summed activeCount in Sidebar.
2026-07-15 22:11:44 +02:00
peter 79d9bca290 feat(panels): peek-open side panels with a docked tab
The shared SidePanel now follows the nav rail's model, so Filters, Plex and
Playlists all inherit it: unpinned it sits as a small tab docked flush against
the rail (replacing the full-height vertical strip) and expands into the card on
hover or keyboard focus without pushing content; pinned it stays expanded
in-flow. The collapse toggle became pin/unpin, and panels now default to
unpinned. The tab carries the active-filter count and an accent ring.

The tab stays mounted under the open card on purpose: swapping it out from under
the pointer drops the hover that opened the card, and unmounting it while
focused strands keyboard focus.

Measured: feed content starts at 123px instead of 336px when unpinned.
2026-07-15 20:57:21 +02:00