Compare commits

..
30 Commits
Author SHA1 Message Date
peter 8d9a99ce54 release: 0.82.0 (feature/e10-platform-ergonomics)
gate / frontend (push) Failing after 2m7s
gate / backend (push) Successful in 3m36s
2026-09-06 02:37:10 +02:00
peter 45dd4bdda7 chore(release): 0.82.0 — E10 Platform & Ergonomics 2026-09-06 02:37:07 +02:00
peter dac391a890 fix(e10): review round 3 — settle the focus race in a browser instead of guessing again
Round 3 reviewed round 2's diff. Trend across the loop: 32 findings over
the whole epic, 15 over round 1's fixes, 11 over round 2's. What it caught
is mostly the same shape as before — a guard narrowed until it was wrong
in the other direction — plus one item this branch had now got wrong
three different ways.

Backspace ownership over the player, third and final answer. Round 1
deferred to nothing (the cheat sheet took the key and the player stopped
underneath it). Round 1's fix deferred to `anyLayerAbove` (the player's
own gear/tracks popovers push layers too, so it stopped closing them).
Round 2's fix named the sheet and palette (the app-wide ErrorDialog is
neither, so the player tore down under it). The rule that actually holds
is not a list of overlays: the player's own panels answer first, because
handleBack's cascade is what closes them, and anything else layered above
takes the key.

The rail's inline rename is settled by measurement rather than argument.
Two rounds reasoned about whether a React passive effect lands before or
after floating-ui's return-focus microtask and reached opposite
conclusions; a jsdom harness built to decide it reproduced no failure for
ANY shape, because focus never enters the menu there — so it was deleted
rather than kept as a test that discriminates nothing. Checked in a real
browser instead: the field opens and holds focus. The shipped fix works,
and the comment now says what was measured rather than what was inferred.

The same check found that round 1's OTHER rail fix never worked at all.
Escape does run its own handler (typing then Escape leaves the name
unchanged, so it cancels rather than blur-commits) and the row button is
focusable when focused from outside — yet focus still lands on <body>.
Three explanations, none of which survived. So the refocus machinery is
reverted to the shape that shipped before this epic and the gap is
documented where the next person will hit it, with the evidence and an
instruction to instrument before theorising. A fourth guess would have
been worse than a known bug.

Also: the two comments in Playlists.tsx that still claimed the disabled
menu rows explain themselves — untrue since round 2 reverted `inert`, and
exactly the comment-vs-code drift CLAUDE.md calls the source of the worst
bugs here. And the move-failure toast printed HttpError.detail verbatim,
which for the reachable 404 is the backend's bare English "Unknown
playlist"; only a structured detail has been through localizeDetail, so
only a structured detail reaches the toast now.
2026-09-06 02:22:23 +02:00
peter 4b33036e7c fix(e10): review round 2 — revert what round 1 got wrong, narrow what it got too broad
Round 2 reviewed round 1's own fix diff, which is where a fix round does
its damage. It found 15 issues in 792 lines — a much higher density than
round 1 found in the whole epic — and most were regressions round 1
introduced. So this follows the rule rather than fighting it: revert the
fixes whose mechanism was wrong, narrow the ones whose guard was too
broad, and stop reaching for a third variant.

REVERTED, because the mechanism does not work:

- The `inert` menu item. Round 1 swapped a native `disabled` for
  `aria-disabled` so a row's "why is this off" tooltip could be reached.
  floating-ui's list navigation skips `aria-disabled="true"` exactly as it
  skips `disabled` (isListIndexDisabled) — and AnchoredPopover's own
  comment, written before this branch, already said so. The swap fixed
  nothing and made the mouse worse: without native `disabled` the row took
  pointer events, the full active highlight and DOM focus, then no-opped
  on click. The underlying finding is logged where the prop was, unfixed
  and honest: the reason has to go somewhere a disabled row is not.

- `useContextMenu` owning the focus restore. Turning floating-ui's
  returnFocus off platform-wide traded one bug for two: Playlists spreads
  triggerProps on a bare <tr>, so restoring to it is a spec no-op and
  Escape stranded focus on <body>; and with dismissOnScroll now on, the
  restore's plain focus() scrolled the row back into view, undoing the
  scroll that closed the menu. Patching that needs three more guards on
  one fix. The original bug — the rail's rename field dying on arrival —
  is fixed at the field instead: it focuses from an effect, which is
  scheduled after the microtask that was blurring it.

NARROWED, because the fix was right and the guard was not:

- PlexPlayer's Backspace deferred to `anyLayerAbove`, but the player's own
  gear and tracks menus register layers via useDismiss — so Backspace
  stopped closing them and did nothing at all. Only the cheat sheet and
  the palette own that key, so it names them.
- `historyDepth = 0` also desynced the counter from overlays the
  navigation does NOT unmount (About and ReleaseNotes live outside the
  page column), leaving one Back to both navigate and close them. The
  entries are marked buried and consumed silently by reconcile instead.
- The move's error toast fired for every status, so a transport fault
  surfaced twice on top of req's own dialog. Scoped to 403/404.
- The palette's moved-pointer guard could not judge the FIRST move of a
  session and went stale across a close. `e.movementX/Y` asks the browser
  directly and needs no state.
- `useKeyScope("contextMenu")` runs once per hook, and the Playlists page
  mounts two — so the cheat sheet rendered the chords twice under a
  duplicated React key. The scope stack dedupes.
- The `enabled:hover:` sweep missed the half that lives in CSS, so a
  disabled glass button still lifted. `.glass-hover:not(:disabled):hover`.
- ShareDialog's migrated buttons passed a colour className that the
  variant's own colour class outranks under clsx, so Revoke hovered
  neutral and a password-protected link looked unprotected. The colour
  goes on the icon, as the Copy button beside them already did.
- `matchesShortcut` took an untyped id and ids are only unique within a
  scope, so `back` would have answered for the wrong player. Keyed on
  scope + id, both literal.

And the systemic one: round 2 reverted eight of round 1's behaviour fixes
as a single mutation and all 763 tests still passed. Round 1 tested the
three fixes that were easy to test and none of the rest. So the guards
with the widest blast radius are pinned now, each mutation-checked to
fail for its own reason: the global keymap's repeat/allowInInput/disabled
handling (a new dom test), the cheat sheet bound to the real
useContextMenu rather than a scope stub, the scope dedupe, the history
burial, and the Button accessible-name union — whose @ts-expect-error
fixtures turn a widened type into a red typecheck lane.

The chipLane contract test that round 1 rewrote to count per lane counted
the import line as a lane, giving every file one free mirror — so the
exact hole it was rewritten to close was still open. Mutation-checked
both ways this time.
2026-09-06 01:57:32 +02:00
peter 80dea4f307 fix(e10): review round 1, part 2 — the docs the epic invalidated, and three contracts made real
Finishes round 1. Part 1 took the runtime bugs; this takes the prose that
had gone false and the three "the comment says so" gaps behind it.

CLAUDE.md is auto-loaded into every agent session and ui/README.md is
not, so an architecture paragraph that still described a flat components
bag was the most expensive stale sentence in the repo: the next agent
adding a popover reads it, sees no platform layer, and writes the generic
component into components/ — the invisible-layer failure this epic exists
to end. It now names components/ui/ and says the boundary is lint-enforced.
The canonical-patterns table gained the three rows the epic established:
ui/Button over a hand-rolled <button>, a keymap row over a private keydown
listener, and disabled-when-busy vs inert-when-inapplicable.

ui/README.md's inventory was written in this branch's first commit and
never learned about CommandPalette, ContextMenu/useContextMenu,
KeyboardHelp or gridColumns — in a section whose own closing rule is that
a second component painting the same thing is how the last inconsistency
happened. It also claimed "everything is rem-derived" while VirtualGrid's
minCol/GRID_GAP are deliberately px; that is now stated as the exception
it is, and a new section records which promises here are tests rather
than sentences.

Three contracts stopped being prose:

- A text-less Button must carry a name. "Required for iconOnly" was a doc
  comment, which is why the migration shipped two nameless icon buttons
  through eight green lanes. It is a union now, covering both spellings
  (iconOnly, and size="icon"/"icon-sm"), satisfied by label, aria-label or
  title. It caught one more on the way in: Banner's dismiss took an
  optional dismissTitle, so it could render unnamed — it falls back to
  common.close now.
- gridColumns' gutter arithmetic. Every existing assertion survived
  replacing floor((w + GAP) / (minCol + GAP)) with the naive
  floor(w / (minCol + GAP)): the fixed widths agree, the capped cases are
  cap-bound, and the loops compare columnsFor against itself. Pinned at
  the exact-fit boundaries where the two first disagree (536 and 828).
- ShareDialog's link row. Only Copy was migrated in S1, leaving one 2rem
  button between two 1.75rem ones — and on a coarse pointer .touch-target
  grew only the migrated one to 2.75rem while the destructive Revoke
  stayed small.

Also: the maxCols docstring said 8 columns of 384px where the test it
shipped with asserts 369.
2026-09-06 01:30:40 +02:00
peter 20e17c0ccb fix(e10): review round 1 — the boundary that did not bind, and 14 more
The epic-end review found 32 verified issues. This is the first fix round:
the three highs, the user-facing mediums, and the tests that were passing
for the wrong reason. Docs and the remaining low-severity tail are next.

The boundary rule was the worst of them. Both `no-restricted-imports`
regexes were written in plain "..." literals, where `\.` is an unknown
escape that collapses to the any-char `.` — so the rule read like a
boundary and matched far less than one. `../../lib/api` (the barrel every
feature component imports) and `../../components/Feed` (the same file
`../Feed` names) both sailed through a green gate. String.raw, a third
pattern for the second spelling, and a vitest over ESLint.lintText so a
rule that silently matches nothing can never look like a clean codebase
again.

closeThenNavigate waited for exactly one reclaim popstate. With a second
Back-closable layer still mounted — a palette opened over the player —
the navigation unmounted that layer too, and its reconcile backed over
the entry the navigation had just pushed: "go to Channels" closed the
player and landed on the feed. It now waits for the LAST programmatic
back, then adopts the entries the navigation buried, and falls back after
250ms rather than hanging if no popstate is ever coming.

The rail's inline rename died on arrival. With no domReference, floating-
ui's returnFocus falls through to a module-global stack and restores some
unrelated element from an earlier popover — blurring the field into its
own commit-and-close, from the second popover of a session onward. The
context menu now owns the restore: back to the row, but only if the row
is still there and nothing else has claimed focus.

Also:
- Backspace under the cheat sheet stopped Plex playback and dropped to
  the grid; `back` now defers to any layer above, like `escape` beside it.
- A move invalidated only the source list, so the target kept serving
  pre-move contents for 30s while the rail already counted N+1.
- Every move error a user can reach is a 404, which `req` leaves to the
  caller — so the row snapped back in silence and the shipped moveErrors
  strings were unreachable.
- swallowClick armed on long-press but was disarmed only by the click it
  was armed for; any press that produced no click ate the next tap on the
  whole list.
- The context menu stayed parked over whatever row scrolled under it.
- overflow-clip on the chip lanes ate the focus ring's top and bottom.
- "Why is this off" rode on disabled rows, which show no tooltip and take
  no focus: a new `inert` item keeps the reason readable.
- Shift+F10 was advertised app-wide but wired only on Playlists; it has
  its own scope now, and useContextMenu matches through the shared parser
  instead of its own literals.
- Mod+K could not close the palette, `h` machine-gunned on auto-repeat,
  and both were bound above the branch that mounts their overlays.
- Palette options were in the tab order under aria-activedescendant, and
  a scroll-synthesised mousemove stole the keyboard selection.
- The OSD live region was created together with its own text.
- Ending a rename stranded focus on <body>.
- The Button variants' hover halves are `enabled:` — the one migrated
  site that had guarded its own lost the guard on the way in.
- HU builtinLocked named the list "Watch later" in a Hungarian sentence.

Tests: the chip-lane contract counted per FILE, so a second hand-rolled
lane in an already-compliant file was invisible; the closeThenNavigate
ordering test only passed on a popstate left behind by its neighbours and
failed alone, so it moved to its own file.
2026-09-05 23:52:29 +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 f99240d8ca fix(e10): stop the hidden chip measurer scrolling the whole app sideways
The four chip lanes render an intentionally unbounded hidden "mirror" so the shared
fit core can read each chip's natural width. An absolutely positioned box still
contributes to its ancestors' SCROLLABLE overflow, and `visibility: hidden` does not
exempt it — and nothing between the toolbar band and <html> clipped it.

At the 1024x768 floor that meant the channel manager's document scroll width was
1258px: the whole app scrolled 249px sideways into dead space and dragged the fixed
header to x=-177. The same mirror was also 228px wide inside the Tags column's 144px
cell, which pushed the table to 990px inside its 906px container — i.e. it broke
DataTable's "the table never exceeds its container" contract and truncated channel
names to "The...". One cause, two symptoms.

The lane now carries a shared `chipLaneClass` = `relative overflow-clip`, so the
contract lives in one constant instead of four hand-rolled class strings. `clip`
rather than `hidden` because it creates no scrollport, so nothing can ever scroll the
mirror into view. Measurement is untouched: offsetLeft/offsetWidth are layout values
and a clip does not change layout.

Measured after: document 1258 -> 1024, sideways scroll 249 -> 0, table 990 -> 921
with no cell overflow, and the "+N" collapse still fits exactly as before.

A source-level test asserts every component rendering a mirror uses the lane class —
the drift a fifth hand-rolled lane would introduce, which no DOM test can catch since
jsdom has no layout engine to overflow.
2026-09-05 22:22:14 +02:00
peter cc8cf96c19 feat(e10): a jump-to palette on Ctrl/Cmd+K
Reaching a channel three modules away cost a rail, a page and a scroll.
Now it costs a keystroke and half a word: Mod+K searches modules,
channels, playlists, the saved views applicable where you stand, and the
app-wide actions.

Scope agreed with the product owner: navigation + actions, but only the
reversible local ones (the shortcuts sheet, the theme). "Sync from
YouTube" is deliberately absent — it is a long-running job against a
quota, it wants the progress and error surface the Channels page already
gives it, and one mistyped Enter is a worse failure than the walk it
would have saved.

Matching is word-wise and accent-folded, because this is a bilingual app
and nobody reaches for the accent keys in a hurry: "pod thevr" finds
TheVR Podcast and "penzugy" finds pénzügy. The arrows walk one flat
result list so crossing a group heading is invisible, and the field
carries the combobox/listbox aria tree that virtual selection needs.

ui/CommandPalette is domain-free (items are a prop, per the ui/ import
rule); AppCommandPalette supplies them. The shell is Modal again, with a
new `top` placement so a search-first dialog grows downwards instead of
jumping as results change.

Also fixes a bug the palette hit on its first run, and that PlayerModal
had already hit once: closing a dialog reclaims its Back-to-close
history entry with a programmatic history.back(), which undoes any
pushState made before it lands — so a command that navigates only closed
the palette. The rule is now closeThenNavigate() in history.ts with a
test, and PlayerModal's inline copy is gone.

The global `h` moves out of KeyboardHelp into useGlobalKeymap beside
Mod+K, so one listener owns the whole `global` scope and dispatch's
exhaustiveness keeps meaning what it says.
2026-09-05 21:33:29 +02:00
peter 86163958d6 feat(e10): one keyboard cheat sheet for the whole app
The Plex player had a shortcuts overlay nobody else did, so the YouTube
player's Space/F/arrows and the Plex browser's Backspace were documented
nowhere. ui/KeyboardHelp is that sheet for every surface, opened by a
global `h` - not the conventional `?`, which is a Shift chord on a
Hungarian layout, while `h` is the key this app's reader already has in
their fingers from the player.

It renders the shared table filtered to the scopes that are LIVE: a
surface registers its scope with useKeyScope while mounted, and only
while its keys really do something - PlexBrowse deactivates its own
scope inside the player, where its Backspace no-ops, so the sheet cannot
promise a "Back one level" the handler declines to perform. Same rule
for the `when` rows: ⇧←/⇧→ are listed only inside a series, an answer
the player publishes because the sheet cannot ask.

No new overlay primitive, after checking: Modal already portals into
document.fullscreenElement ?? document.body (so this works over a
fullscreen player), registers an Escape layer, traps focus and handles
browser Back. Third time this epic that the thing was already built.

Shift+F10 is in the table as `handledLocally` - documented and sharing
the chord parser, but matched by useContextMenu on the focused row.
dispatch neither routes it nor demands a handler for it, so the
exhaustiveness check stays honest about what it actually covers.

Also: A/S have cycled the Plex audio and subtitle tracks since E5, but
silently - the only way to see where you landed was to open the tracks
menu, the exact round-trip the shortcut exists to avoid. cycleAudio and
cycleSubtitle now return the track they moved to, and the player flashes
it over the video.
2026-09-05 21:15:42 +02:00
peter 12822a9e04 feat(e10): put every keyboard shortcut in one table
Six surfaces each owned a private keydown listener with its own key
literals and its own idea of "am I typing?", and they had drifted:
PlexPlayer matched `case "a"` without checking modifiers, so Ctrl+A
(select all) cycled the audio track, and its typing guard missed
<select> and contentEditable where the other two surfaces covered both.

lib/keymap.ts is a TABLE, not a dispatcher. Each surface keeps its own
listener and closures - PlexPlayer's refs are not worth re-plumbing -
but the chords, the modifier rules and the typing guard live in one
place, and dispatch() binds a scope's rows to handlers through a mapped
type, so a row without a handler (or a handler without a row) is a tsc
error rather than a comment someone forgets.

Matching is now exact on modifiers, which is what kills the Ctrl+A bug
and lets Shift+arrow be its own row beside the bare arrow. Escape opts
out of the typing guard (allowInInput) so it stays the way out of a
player's own subtitle-offset field.

The player's shortcut sheet renders helpLines() instead of a hand-copied
array beside the handler - the two used to be edited separately, which
is how a sheet ends up documenting a key that no longer exists. Labels
move to the new `keys` i18n namespace (EN + HU), and a test asserts
every row is named in both.
2026-09-05 20:58:17 +02:00
peter 928eac4c89 feat(e10): open the app's menu with a right-click, starting with playlist rows
S2's headline was never a new Menu: `ActionMenu` already sits on `AnchoredPopover`
(arrows, typeahead, Escape, anchor-flip). What was missing is an INPUT PATH into it, so
this adds `useContextMenu` + `<ContextMenu>` over the same `ActionMenuItem[]` and the same
`MenuItemList` — a row cannot grow a right-click menu that looks or behaves unlike its own
⋯ menu. Three ways in: right-click anywhere on the row, long-press on touch/pen, and
Shift+F10 / the Menu key. The LEFT button is untouched and keeps the row's own action.

One popover per list, anchored to a virtual element at the pointer (native behaviour, and
a table of 200 rows would otherwise mount 200 floating contexts to keep 199 shut).

Platform bits it needed: `hoverAlso` on AnchoredPopover (a submenu falls open under a
mouse and is tapped by a finger — mouse-only, so a tap's synthetic mouseenter can't race
the click toggle) and `elementRef` on PopoverItem (the submenu anchors to the very
menuitem that opens it, where aria-haspopup/expanded belong). `ActionMenuItem` becomes a
discriminated union — a row either fires or opens a submenu — plus `danger` and
`separatorBefore`.

First consumers, both in Playlists:
- item rows: play · move to another list (a submenu of every other playlist, one gesture)
  · to top / up / down / bottom · remove. Reordering goes through the same undoable store
  as the drag handle and is disabled under a view for the same reason, with the same
  explanation rather than silence.
- rail rows: rename (in the row that was right-clicked, keeping its cover so the rail
  doesn't jump) · delete. Both now run through `usePlaylistActions`, which also takes over
  the detail header's pencil and trash — the delete dialog's YouTube branch and the
  "what gets selected next" rule exist once, not twice.
- the rail gains a "recently changed" sort key off the new `updated_at`. NOT the default:
  a rail that reshuffles itself while you look at it costs more than it saves.
2026-09-05 19:59:56 +02:00
peter b28be682fe feat(e10): make "move a video to another list" one atomic request
The one-step move S2 needs has no honest client-side shape: "add to the target, then
remove from the source" leaves the video in BOTH lists whenever the second call fails,
and the reverse order loses it entirely. So it is one endpoint in one transaction —
POST /api/playlists/{id}/items/{video_id}/move — which re-parents the existing item row
(so `added_at` survives), recomputes `dirty` on both sides, and is idempotent when the
target already holds the video.

Also publishes `updated_at` on the playlist summary, and bumps it explicitly for the
edits that only write `playlist_items` (add / remove / reorder / move). UpdatedAtMixin's
`onupdate` fires on an UPDATE of the playlist row, so without `_touch` the rail's coming
"recently changed" sort would have seen renames and nothing else.
2026-09-05 19:59:41 +02:00
peter 7e97559615 feat(e10): put the 1024x768 size contract inside the primitives
S1c, the last part of S1. The contract now lives in components/ui/ and
in one CSS recipe instead of in ~160 call sites:

- Minimum target 2.75rem, on coarse pointers only (.touch-target). A
  32px icon button is fine with a mouse -- precise pointer, hover to
  confirm the aim -- and not fine with a thumb, so it grows exactly where
  there is no precise pointer, which on this app's real second device is
  the tablet. It grows the real box rather than painting an invisible
  ::after hit area, because two expanded hit areas on icon buttons 4px
  apart would overlap and the overlap fires the WRONG button. `link` is
  excluded: it is text inside a sentence.
- 2.75rem rather than 44px so the target obeys the text-size slider like
  everything else; verified that the type/geometry ladder was already
  rem-derived off --font-scale, so nothing else was needed there.
- The ladder is documented as a closed set in Button.tsx, with the site
  counts it was derived from, and ui/README.md states the contract.

Measured in the browser: fine pointer 36x36 and 34px-tall buttons
(unchanged), coarse pointer the same buttons at 44x44 and 44px tall.
1024x768 renders identically. 3x tsc, eslint, knip, prettier and 690
vitest green.
2026-09-05 18:49:48 +02:00
peter 721467ef69 feat(e10): finish the mappable Button migration, including the ui/ layer itself
Third sweep, 20 more sites: the rest of AdminUsers (the access-request
and invite clusters), Scheduler, SettingsPanel, SetupWizard, About, Feed,
KeyGate, OnboardingWizard -- and, importantly, four platform components
that were still hand-rolling their own buttons: Modal's close, Banner's
dismiss, SidePanel's two toggles and QueryState's retry. A platform layer
that bypasses its own primitive is the same defect one level up.

Hand-rolled buttons with their own geometry: 110 -> 39; Button call sites
37 -> 106. What is left is deliberate, and it is the list Button's own
docstring excludes plus two families this pass refused to force: video
overlay controls painted on white/N, drag handles, chip removers,
accent-solid pills, the bg-card family on the signed-out screen, and
sites whose paint is conditional (`suspended ? warn : neutral`) or
otherwise has no variant yet. Forcing those through a className override
would be unsafe without tailwind-merge, which is exactly the hazard this
sprint documented.

3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Settings and the
About dialog (its close button and full-width CTA are both migrated),
clean console.
2026-09-05 18:41:41 +02:00
peter 47e2293de3 feat(e10): migrate the notification, message, auth and Plex action buttons
Second sweep, 34 more sites: NotificationsPanel (14 -- eight identical
accent text links, three ghost icon buttons, two ghost text actions),
Welcome's five auth links, KeyGate, Messages, NavSidebar, ChatThread,
ShareDialog, ChannelPage, PlexPlaylistView, PlexPlaylistAdd,
PlexCollectionEditor and plexViews.

Moves the font-weight out of Button's BASE and into each variant, so the
`link` variant can be semibold (which is what every text link in this app
looks like) while the action variants stay medium, and no element can end
up with two font-* utilities.

Welcome is the one signed-out surface this session cannot see, so only
its byte-identical link swaps were taken; its primary submit and the
bg-card button family are left for a pass that can UAT the screen.

3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Notifications,
Messages and the nav rail, clean console in a fresh tab.
2026-09-05 18:39:22 +02:00
peter 29bb914a95 feat(e10): re-aim the Button variants at the paint the app actually uses, and migrate the first 15 sites
Counting the un-migrated call sites showed the primitive and the app had
drifted into two different looks: the majority of hand-rolled buttons
hover to accent (outline) and fill with card (ghost), while Button
hovered to fg and filled with surface. That split is the inconsistency
this epic exists to remove, so the primitive follows the majority rather
than the other way round -- 16 existing call sites shift slightly, ~90
hand-rolled ones stop being special.

Adds the variants the call sites prove are missing (outline-accent,
outline-danger, outline-warn, link), an icon-sm size for the p-1.5 icon
family, and a size=none so a link owns no button padding; renames
danger-outline to danger-soft so it cannot be confused with
outline-danger (red at rest vs red on hover). Two new tests lock the
link-size default and the rule that a variant never adds a second
font-weight utility -- without tailwind-merge, two font-* utilities are
decided by stylesheet order, not class order, so that is a real hazard.

Migrates AdminUsers, AuditLog, Channels, TagManager, Playlists,
PlaylistsRail and SettingsPanel (Button call sites 37 -> 52). Left alone
deliberately: conditional-paint sites, video-overlay white buttons, drag
handles, chip removers and card-click targets -- the families Button's
own docstring excludes.

3x tsc, eslint, knip, prettier and 689 vitest green; visual UAT on Users,
Access requests and Playlists at 1280x900, no console errors.
2026-09-05 18:31:00 +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 31fa554b87 release: 0.81.0 (improvement/e12-security-ops)
gate / frontend (push) Failing after 1m57s
gate / backend (push) Successful in 3m25s
2026-09-05 04:54:26 +02:00
peter 6a0efba477 chore(release): 0.81.0 — E12 Security & Ops Hardening 2026-09-05 04:54:15 +02:00
peter 9f28f46839 fix(e12): unbreak the gate, and stop printing a command that can strand the restore
Round five. 47 -> 34 -> 15 -> 7 -> 6 raw findings, and this round's sharpest was the
loop's own blind spot: **the branch did not pass the gate.** `prettier --check` is one of
its eight lanes and I had been running the other seven, so an unformatted `onError` from
round one sat on the branch for four rounds — `siftlode publish` would have refused it,
with no --skip flag to get past.

**The restart hint could still strand the restored data.** Round four said the scripts no
longer print a command they cannot resolve; they did. The COMPOSE_FILE fallback only fires
when `docker-compose.yml` is ABSENT, so in a source checkout it never fires and the hint
expanded to a bare, pasteable `docker compose up -d` — which reads the other file, whose
volumes are `pgdata`/`downloads` rather than `siftlode_pgdata`/`siftlode_downloads`, and
recreates the database on an empty one. When the file is unresolved the scripts now print
`-f <the compose file you started with>`: not runnable, which is the point.

**A comment naming the wrong owner.** `formats.py` still said a zero budget is "a refusal
for enqueue to make" — the premise round one disproved, three files away from the two
docstrings that now say the opposite. Anyone acting on it would delete the worker's refusal
and re-open the original inversion, so it now says where the refusal actually lives.

Also: the reset-flag rule was rewritten in three consecutive rounds and pinned by nothing —
two tests now cover a fragment losing its token, both by a pasted verify link and by
navigating away entirely; `backup.ps1` deletes a partially-copied dump before throwing, the
guard round four gave the docs but not the script it mirrors; and one comment that read as
if the asset were untouched by a line four below it.

Gate green, all eight lanes: ruff, ruff format, 3x tsc, eslint, prettier, knip,
vitest 687, pytest 950.
2026-09-05 04:41:21 +02:00
peter 14f1c97c0a fix(e12): a bare up -d picks the wrong compose file, and recreates the database
Round four, on round three's diff. 47 -> 34 -> 15 -> 7 raw findings.

**The printed restart command could strand the restored data.** Round three replaced
`start` with `up -d` because `start` ignores a newly pinned tag — but printed it bare. In
the git-clone layout BOTH compose files exist, so the scripts' COMPOSE_FILE fallback never
fires and a bare `docker compose up -d` reads `docker-compose.yml`: different volume names,
locally built images, no IMAGE_TAG at all. `stop` and `exec` had been fine there (they
resolve running containers by project and service), but `up -d` RECREATES — so the operator
pastes the hint straight after a successful restore and gets `db` on a new empty volume and
a first-run setup wizard, with their data in an orphaned volume. Verified: the two files
resolve `siftlode_pgdata` vs `pgdata` and different images.

The scripts no longer print a command that recreates from a file they did not resolve. They
name the compose file when they resolved one, and otherwise say to start it the way the
operator normally does — with the caveats on their own lines rather than in a parenthetical
at the end of a very long one.

**The reset flag is now decided where the live hash is already parsed.** Round three cleared
it in the token effect, which only runs when a NEW verify token arrives — so the back
button, or re-pasting an already-consumed verify link, still left the banner promising a
recovery whose token was gone. Setting it from the hash on every fragment change is both
smaller and closes those.

Also: the doc said `start` "still carries the old tag" four lines after "pin the older
IMAGE_TAG", where "old" then reads as the tag being rolled back TO — it now reuses the
scripts' unambiguous wording; the Windows recipe removes a partially-copied dump before
throwing, so a mid-stream `cp` failure cannot leave a truncated file under the final name;
and `$WarningPreference` is pinned beside `$ErrorActionPreference` so an inherited
SilentlyContinue cannot drop the half-restored warning, with the comment corrected to say
what the warning stream actually is.

Gate green: vitest 685, pytest 950.
2026-09-05 04:31:13 +02:00
peter fc56f514b3 fix(e12): the restart hint pointed at the one command that undoes a rollback
Round three, on round two's diff. The trend across the loop is 47 -> 34 -> 15 raw
findings; the ops half kept producing the sharpest ones because the gate cannot run it.

**`docker compose start` ignores a newly pinned IMAGE_TAG.** Round two removed the
automatic restart because it re-ran `alembic upgrade head` — and then told the operator to
run that exact command by hand, in both scripts and the docs. `start` relaunches the
container that already exists, which still carries the tag being rolled back FROM, so
pinning 0.79.0 and following the printed instruction brings 0.80.0 back up and re-applies
its migrations. Verified against a real daemon: after changing the pinned tag, `start` kept
the old image and only `up -d` picked up the new one. Both scripts and the doc now say
`up -d`, with the pin-first caveat where the operator reads it.

**The reset flag outlived its token.** Reverting round one's "each link clears the other"
went one step too far: a `#verify=` link pasted over a standing reset notice replaces the
whole fragment, so the reset token is gone — but `resetPending` stayed true, and being
checked first it hid the confirmation while promising a recovery that signing out could no
longer complete. The flag is now cleared when the fragment it describes is gone, which is
narrower than what was reverted and does not blank the signed-out card.

**A test that could not fail.** The shared-asset regression test seeded the asset as
`pending`, and `_reset_asset_pending` only acts on `downloading` — so the release half was a
no-op and deleting it kept the suite green. Seeded as the worker actually leaves it now.

Also: the malformed-URL guard answers with urlguard's structured detail rather than a bare
English string, so both halves of one typo class get the same localized copy, and the third
site in that module handing user input to `urlparse` was swept with it; the asset is
released after the job is terminal, not before; the Stats 403 toast gets the wizard handle
its own comment claimed it had; the pasted Windows recipe sets `$ErrorActionPreference`
inside the block (a scriptblock inherits the caller's, so a `SilentlyContinue` profile
swallowed every throw) and cleans up the container-side dump on the failure path too; the
restore warning moved to the warning stream so a `> log` redirect cannot file it away; and
two comments narrowed to what the code does.

Gate green: vitest 685, pytest 950.
2026-09-05 04:18:33 +02:00
peter 358d722ce9 fix(e12): close what round one broke, and revert two fixes that overreached
Round two of the review loop, on round one's own diff. Two of its findings were
regressions the fixes themselves introduced, which is exactly what the loop is for:

**A per-user refusal was writing onto a shared row.** MediaAssets are deduped across
users, and the generic failure handler puts the message on the asset — right for an
upstream failure, wrong for "your storage is full". Two users queuing the same video share
one asset, so the first user hitting their cap failed the second user's job with the first
one's private reason, telling someone with an empty library to free up space. The refusal
now raises `_OwnerRefused`, caught separately: the job fails, the asset goes back to
`pending` for whoever else holds it. Pinned by a test that drives `_download` directly —
the refusal happens before extraction, so nothing needs stubbing.

**The restore scripts restarted the app, undoing the restore.** `docker compose start`
re-runs the container command, and that runs `alembic upgrade head` — so on the rollback
path this documents as recommended (restore the dump, THEN pin the older tag) the newer
image immediately re-applied the migrations the restore had just undone, and on a FAILED
restore it put the app back on a half-restored schema. Both are the opposite of why the
stop was added. Nothing is restarted automatically now; the scripts say what to run and
when, and the docs' snippets say the same.

**Reverted rather than patched:** the OnboardingWizard toast (the wizard already renders
that failure inline, next to a Retry button — three recoveries for one failure), and
`useVerifyEntry`'s "each new link clears the other". The latter blanked the signed-out
card's only banner, where nothing renders the reset flag to replace it. The two states are
independent again; what changes is that `dismiss` peels ONE layer, which is what the
original finding was actually about.

Also: `Stats` uses the YouTube helper rather than the bare one, so the route's English 403
detail no longer beats the translated fallback under a Hungarian UI; `urlparse` guarded
alongside `.port` (an unclosed bracket raised just as late), the reason renamed `malformed`
since "bad port" misdescribes it; three docstrings narrowed to what the code actually does
(the worker's byte check is per-job, not an aggregate ledger, and not on the edit path;
the redirect residual leaks a reachability oracle, which the note had denied; the
invalidation helper does not cover suspend); the pasted Windows recipe wrapped so `throw`
can stop it; the bash one-liner's failure branch no longer exits 0; and the tests that were
missing for the round-one fixes — the account panel's error paths, the invalidation set,
and the shared-asset case above.

Gate green: vitest 685, pytest 950.
2026-09-05 04:02:57 +02:00
peter fe38dfbcca fix(e12): close the eighteen findings the E12 review round left standing
The deep review of the branch raised 47 findings, 18 of which survived adversarial
verification. Grouped by what they actually were:

**The size ceiling inverted at its boundary.** `remaining_bytes` clamped to 0 for a user
already at their storage cap, and `build_ydl_opts` reads 0 as "no limit" — so the one
user over quota was the only one downloading with no ceiling at all. `check_enqueue`
could not catch it: it ran while the job was queued and the user was still under the cap.
The arithmetic moves into `quota.remaining_bytes` (where every other max_bytes decision
lives, and where it can be tested), and the worker now fails the job outright rather than
proceeding unbounded — as a non-retryable `quota_exceeded`, since retrying cannot help
until space is freed.

**Silent failures in the new account panel.** All three actions went through `quiet: true`
endpoints, which suppress the global error dialog on the promise that the caller reports
inline — with no `onError`, that promise was broken and a refusal rendered as nothing. On
the broken-SMTP instance this panel exists for, an admin would read silence as "the mail
went out". The co-invalidation set and the email-off predicate move to `lib/adminUsers`,
shared with the pending-access queue, so the two copies of these mutations cannot drift
apart again — which is how half of this was lost in the first place.

**A rollback recipe that destroyed data without working.** `alembic downgrade -1` steps
back one migration, but a release routinely ships several (0.80.0 shipped four): it drops
the newest one's columns and leaves the schema at a revision the older image also cannot
resolve, i.e. the crash loop it was meant to escape, minus the data. The dump restore is
now the recommended path, and the alembic route asks the target version for its own head
revision instead of counting backwards.

Also: redirects named in the urlguard docstring beside the DNS-rebinding residual they
share a fix with (SEC-17); a malformed port refused as `bad_port` instead of escaping as
a 500; the pull cooldown extended to the sibling route spending the same QuotaAction;
restore stops the app before dropping its tables; the PowerShell cleanup can no longer
decide the script's verdict on 5.1, nor report the wrong exit code; the documented
one-liners hardened to match the scripts; the registry's retention window stated where
pinning is advertised; the two banners made mutually exclusive so neither can shadow the
other; `hover:bg-hover` (a utility this project never generated) replaced by the
`selectable` recipe; the clipboard helper reused; and the sync cooldown's copy swept to
the two callers that discarded it.

Verified: gate green (vitest 678, pytest 949), and the panel, the row hover and the reset
banner UAT'd on localdev.
2026-09-05 03:41:58 +02:00
peter f98b060e2b feat(admin): an account panel, and a set-password link that needs no email
Design option C, chosen by the user 2026-09-05. Nothing in the admin UI
ever showed an account's GATE STATE: an admin could see a "Pending
approval" chip but not whether the address was confirmed, whether SEC-10
had cleared the password, or which Google account was attached — so
every support conversation started by guessing. Clicking a row now opens
a drawer that says which gate the person is actually stuck behind, with
the recovery actions under that readout rather than in the row.

The drawer is `Modal placement="drawer"`, not a second dialog: the layer
stack, ESC, the focus trap, Back-to-close and the scroll lock are the
hard parts, and duplicating them is how two shells drift apart. It
slides in, and honours prefers-reduced-motion like the rest.

The link itself (SEC-15, SEC-16, and the everyday "no SMTP and someone
forgot theirs") reuses the existing token machinery and the SAME
`#reset=` entry the mailed link uses, so the signed-out card consumes it
with no new flow. It is a distinct token KIND for one reason: a mailed
reset arrives in the mailbox, so completing it proves ownership and
confirms the address; an admin-issued link proves nothing about the
mailbox — the admin hands it over by whatever means they choose — so it
must not satisfy the email gate. Claiming otherwise would be the same
false attestation SEC-15 is about.

Issuing does not revoke the current password: a misclick must not lock
anyone out. USING it rewrites the hash and bumps the session epoch,
which is what evicts a squatter — SEC-16's actual remedy. Spending
either kind burns the siblings of both, so a completed reset cannot
leave a live admin link behind. The non-consuming probe learned the new
kind too, or every admin link would have opened onto "this link is
spent". The audit log records that a link was issued, by whom, for whom
— never the link, which is a live credential-setting secret and the last
thing that belongs in a long-lived log a human reads.

Not gated on account state on purpose: it is useful for a pre-empted
address, a revoked password, a Google-only account that wants one, and a
plain forgotten password. Narrowing it would only make it refuse exactly
when the admin needs it. The shared demo account is the one exception.

Verified on localdev, both languages, end to end: issue from the panel
-> the signed-in tab shows the honest "reset links open in a signed-out
window" banner with the token still in the address bar -> signing out
draws the "Set a new password" form, which is only possible because the
probe recognises the new kind. Gate: pytest 925 -> 942, vitest 676.
2026-09-05 01:46:58 +02:00
peter bc9e34d0ae fix(auth): close the three leftovers E9.5 logged but kept out of its diff
DX-17, three one-liners the epic's canonical sweep did not reach:

`routes/setup.py::setup_admin` still resolved the wizard's admin by
literal `User.email ==`. The wizard can be re-run, and E9.5's premise is
that two spellings of one Gmail mailbox are one person — so a second run
under the other spelling would have created exactly the duplicate admin
row the epic exists to prevent.

`routes/admin.py::_decide` hand-rolled `db.get` + 404 where the file's
newer endpoints use `get_or_404`. There were TWO such sites, not the one
the review named; both are converted, per the house rule that a
principle gets fixed everywhere in one sweep.

`useVerifyEntry`'s `hashchange` listener recognised `#verify=` but not
`#reset=`, so pasting a reset link into a tab already running the app
was silently ignored — the same silence that made the verify half a bug.
It now notices, and says out loud that reset links need a signed-out
window; the fragment is still deliberately left in the address bar
(review round 4), so signing out from that tab recovers it.

DX-16: `.catch(() => setOutcome("invalid"))` could not tell a spent
token from a 502 mid-deploy or a dropped connection, and by then the
fragment is cleared — so a network blip was reported as "that link is no
longer valid", pushing the user to request a fresh one on the strength
of a false statement. Only a 400 carrying {code:"verify"} is the
server's verdict now; everything else gets its own outcome, with no
resend CTA, because the token is untouched and retrying it works. The
rule was already written one file away for the reset-link probe: "a
network blip must never be cached as 'this link is dead'".

Two existing tests asserted the old conflation by rejecting with a bare
Error("400"); they now reject with the real HttpError. The errors are
built inside the rejection, not hoisted: mountAt calls vi.resetModules(),
so an HttpError imported earlier is a different class identity and
`instanceof` would quietly miss.

Gate: vitest 671 -> 676, pytest 924 -> 925.
2026-09-05 01:35:09 +02:00
peter 0cc450d1c9 fix(downloads,sync): bound what one user can make the server fetch, download and spend
SEC-12: the download box accepts an arbitrary http(s) URL and yt-dlp fetches
it from the WORKER, so any approved user could aim the server at the compose
network, the LAN or a cloud metadata endpoint — and read the result off
job.error, where "connection refused" vs "HTTP Error 401" vs "timed out" is a
port scanner. downloads/urlguard.py refuses a URL whose host resolves to a
loopback/private/link-local/reserved address, at enqueue AND again in the
worker (DNS can move in between, and the worker is where the socket opens).
A network-class failure on a `url` job now reports one fixed sentence instead
of the raw transport text; the error CODE is still classified from the
original, so the localized explanation is unchanged. DNS rebinding is
explicitly NOT closed — that needs the connection gated inside yt-dlp — and
the module says so rather than implying more than it does.

SEC-13: `noplaylist` only demotes a video-inside-a-playlist URL to that video,
so a URL that is purely a playlist or channel still expanded into every
entry — one enqueue, unbounded downloads. `playlist_items` pins it to one.
Size was unbounded too: `check_enqueue` compares the CURRENT footprint against
the cap, and a download's size is unknown until it lands, so one job could
overshoot by any amount; the owner's remaining budget is now handed to yt-dlp
as `max_filesize`, applied AFTER the adapter merge so no source adapter can
raise a limit the quota layer set. A join's cut-list is capped at 100: an
accurate join emits two filters per segment into one ffmpeg filter_complex.

SEC-14: the YouTube budget is instance-wide, so one user holding down a
manual sync spent the day for everyone. Per-user (not per-IP — the cost
belongs to the account) 3-per-10-minutes limiters on the two bulk pulls,
subscriptions and playlists, with separate budgets so one does not close the
other. The review also listed /rss, /backfill and /enrich; E3 deleted them,
and search stays unlimited on purpose — it is interactive, not a bulk pull.

Gate: pytest 896 -> 924 (none skipped), vitest 671.
2026-09-05 01:03:24 +02:00
peter 688efb9ed6 feat(selfhost): license the repo, and make Plex actually usable on the public compose files
DX-10: the repo invited people to clone, build and self-host while carrying
no LICENSE at all — which legally means all rights reserved, so none of that
was permitted. Adds the verbatim GNU AGPL-3.0 text, the SPDX id in
package.json and a README section. AGPL rather than MIT deliberately: this is
a server application, so the case worth covering is someone running a
modified copy as a network service.

DX-12: .env.example documented PLEX_MEDIA_HOST_PATH -> /plex-media and the
README advertised the Plex library, but only the localdev and home compose
files carried the mount — on both PUBLIC files the module could read Plex's
catalogue and then fail to open a single file, because it streams the media
itself rather than proxying Plex. Both api and worker now mount it read-only
with an empty named volume as the fallback, and self-hosting.md gains the
section that explains the server URL/token, the path map and the mount
together (it had never mentioned Plex).

DX-15: the worker had no init: true on either public file, though localdev
and home both carry tini with the reason written out — without PID 1 reaping,
a watchdog hard-kill of a download's process group orphans its ffmpeg/yt-dlp
grandchildren.

Verified: 'compose config' resolves on both files, the Plex mount comes out
read_only: true on api and worker, and init lands on worker (not just the
bgutil-pot sidecar). Gate green: vitest 671, pytest 896.
2026-09-05 00:53:26 +02:00
peter ca4e559cc7 fix(ops): make backup/restore work, and give upgrades a pin and a rollback
DX-13: both shell scripts ran bare 'docker compose', which has no config
file to find in the layout docs/self-hosting.md tells people to use (only
docker-compose.selfhost.yml is downloaded) — they now fall back to it, while
a source checkout keeps resolving docker-compose.yml as before. The two
PowerShell scripts moved the dump through the PS pipeline, which corrupts
binary on 5.1 and cannot express -Encoding Byte at all on 7 ('Byte' is not a
supported encoding name), so they simply failed there; they now write inside
the container and 'docker compose cp' it across. backup.sh also wrote its
output straight into backups/, leaving a truncated file that looks like a
real backup when pg_dump fails — it writes a .part and promotes on success.
Both restores report a non-zero pg_restore rather than printing 'Restored'
over it. Docs now state that a backup is the dump PLUS .env: without
TOKEN_ENCRYPTION_KEY every encrypted secret in the restored database is
unreadable, which the old one-line 'back up the volume' never said.

DX-14: 'latest' moves with every release and migrations run on every boot,
so self-hosters had no way to choose when they move and no way back. Docs
gain IMAGE_TAG pinning, a rollback recipe (image-only vs schema-bearing) and
the migration-failed-at-boot case; CHANGELOG.md is generated from
releaseNotes.ts so the file cannot drift from what the app shows, and
'siftlode release' refuses on a stale one rather than regenerating it into
the clean tree it just demanded.

Verified: dump + restore round-trip on both platforms against localdev into
a scratch database (8 users, 262822 videos, schema 0082_invite_lang), and
IMAGE_TAG=0.80.0 resolves on both services in 'compose config'.
2026-09-05 00:50:07 +02:00
235 changed files with 9839 additions and 1117 deletions
+1818
View File
File diff suppressed because it is too large Load Diff
+31 -24
View File
@@ -26,8 +26,8 @@ The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc project
keep commits logical and Conventional-Commit-styled.
- **No AI attribution in commits.** No `Co-Authored-By` trailer, no "generated by" lines. (This file
being AI-written is the intended, visible exception; commits are not.)
- **Release: user-owned in timing, agent-run in execution.** The user decides *when* to ship; on their
go-ahead *you* run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
- **Release: user-owned in timing, agent-run in execution.** The user decides _when_ to ship; on their
go-ahead _you_ run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
verify `/api/version`). Never self-ship — bring work to a green, committed state and stop for the
go-ahead first, then run it yourself.
- **Public repo.** Keep ops/topology/secrets out — those live in a separate private repo. Nothing here
@@ -41,7 +41,7 @@ how it gets built. Concretely:
- **Plan before you build.** For anything non-trivial, present a short plan (sprint-level for larger
work) and get a go-ahead before writing code.
- **Offer choices, mark the optimal — and explain the trade-off.** When several solutions are viable,
list them and mark the recommended one. This is the *one* place to spend extra words: give the *why*
list them and mark the recommended one. This is the _one_ place to spend extra words: give the _why_
behind each option and the weight of the decision. These decision points are where the product owner
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
@@ -59,31 +59,38 @@ how it gets built. Concretely:
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
`security`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → components; data
via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through `lib/api/*`
slices over `core.req<T>`.
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
feature component or a domain API slice (`no-restricted-imports` in `eslint.config.js`, with its own
test). A new generic component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through
`lib/api/*` slices over `core.req<T>`.
**Not wanted** (reviewed and rejected as over-engineering for this size): microservices, event bus,
CQRS, a repository/DI framework, a router library, a generic CRUD-router factory or base serializer.
## Canonical patterns — when you have a choice, pick THIS
| Area | Use | Not |
|---|---|---|
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
| Request body | a Pydantic model | `payload: dict` |
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| Area | Use | Not |
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------ |
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
| Request body | a Pydantic model | `payload: dict` |
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo |
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
## House rules that bite (learned from real bugs)
@@ -100,6 +107,6 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
---
*Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
_Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
code conventions in [`backend/app/README.md`](backend/app/README.md). This file stays the source of
truth for the canonical-patterns table and the gate/workflow/collaboration rules above.*
truth for the canonical-patterns table and the gate/workflow/collaboration rules above._
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+18 -3
View File
@@ -94,9 +94,18 @@ docker compose -f docker-compose.selfhost.yml pull # or: docker compose pull
docker compose -f docker-compose.selfhost.yml up -d
```
Database migrations run automatically on startup. Your data (accounts, subscriptions, playlists, the
video catalog) lives in a Postgres volume — back it up with `scripts/backup.sh` (or `backup.ps1` on
Windows) and restore with `scripts/restore.sh`.
That follows `latest`, which moves with every release. Set `IMAGE_TAG=0.80.0` in `.env` to pin a
version and upgrade on your own schedule; released versions are listed in [`CHANGELOG.md`](CHANGELOG.md)
and in the app's Release Notes. The registry keeps only the newest few of those, so a pin decides
*when* you take the next hop rather than letting you stay put forever — see
[docs/self-hosting.md](docs/self-hosting.md#upgrading).
**Back up before upgrading.** Migrations run automatically on startup and are not undone by pulling
the old image back. A backup is the database dump **plus your `.env`** — `TOKEN_ENCRYPTION_KEY` is
what decrypts the Google, SMTP and Plex secrets stored in that database, so a dump restored without it
is only half an instance. Use `scripts/backup.sh` (or `backup.ps1` on Windows) and `scripts/restore.sh`;
the equivalent one-liners, the pinning recipe and the rollback steps are in
[docs/self-hosting.md](docs/self-hosting.md#backups).
## How it works
@@ -111,6 +120,12 @@ Windows) and restore with `scripts/restore.sh`.
FastAPI + PostgreSQL (SQLAlchemy, Alembic) backend; React + Vite + Tailwind + TanStack Query
frontend; packaged as a single Docker image with Docker Compose.
## License
[GNU AGPL-3.0](LICENSE). You can run, study, modify and share Siftlode; if you modify it and let other
people use it over a network, they are entitled to your modified source. Copyright © 2026 Siftlode
contributors.
## Note
This project is developed with AI assistance.
+1 -1
View File
@@ -1 +1 @@
0.80.0
0.82.0
+1
View File
@@ -31,6 +31,7 @@ class AuditAction:
# recorded because both are the admin acting ON someone's access, not merely reading a list.
USER_ACTIVATE = "user_activate"
USER_VERIFY_RESEND = "user_verify_resend"
USER_SETPW_LINK = "user_setpw_link"
INVITE_APPROVE = "invite_approve"
INVITE_DENY = "invite_deny"
INVITE_ADD = "invite_add"
+10
View File
@@ -54,6 +54,10 @@ class EditAborted(Exception):
# --- edit spec ------------------------------------------------------------------------------
# SEC-13: ceiling on a join's cut-list. An accurate join emits two filters per segment into a single
# ffmpeg `filter_complex`, so "however many the caller sent" is an unbounded command line.
_MAX_SEGMENTS = 100
def normalize_edit_spec(spec: dict | None) -> dict:
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
@@ -88,6 +92,12 @@ def normalize_edit_spec(spec: dict | None) -> dict:
if en > st:
norm.append({"start_s": round(st, 3), "end_s": round(en, 3)})
norm.sort(key=lambda x: x["start_s"])
# SEC-13: an accurate join builds one ffmpeg `filter_complex` with two filters per segment,
# so an unbounded list becomes an unbounded command line and an ffmpeg that never finishes.
# The cap is far above any real cut-list (the UI's own splitter produces a handful) and the
# excess is DROPPED rather than raising: the surplus segments are the attack, the first
# _MAX_SEGMENTS are a legitimate edit.
del norm[_MAX_SEGMENTS:]
if norm:
out["segments"] = norm
# A join always chooses a codec path: accurate=filter-concat re-encode,
+36
View File
@@ -8,9 +8,28 @@ explanation (and decides whether a retry makes sense). The raw text stays availa
`_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this".
"""
# Two worker-authored strings that this module owns because both are coupled to `_RULES`.
#
# QUOTA_FULL_MESSAGE is raised by the worker and must classify back to `quota_exceeded`; its rule
# below matches a fragment of it, and test_download_errors pins the pair, because a message and its
# rule drifting apart is exactly how a category silently becomes unreachable.
#
# NETWORK_REDACTED_MESSAGE is the opposite direction: it REPLACES a message after classification, so
# it deliberately has no rule. Nothing re-classifies a stored `job.error` today; if anything ever
# does, it must carry the original code rather than re-deriving one from this text.
#
# Other worker-raised texts are still inline literals at their raise sites. The ffmpeg ones and
# "produced no output file" have markers here; the urlguard refusal deliberately has none and
# classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more.
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
NETWORK_REDACTED_MESSAGE = "The server could not reach that address."
# (marker, category) — first match wins, so order most-specific → most-general. Matching is
# case-insensitive on a substring, against the raw yt-dlp/ffmpeg message.
_RULES: tuple[tuple[str, str], ...] = (
# Our own refusal: the worker found the user already at their storage cap (SEC-13). Placed
# first — it is our wording, so it can never be a false match on an upstream message.
("download storage is full", "quota_exceeded"),
# Login / age / members / bot-check — the user must be signed in (we deliberately are not).
("sign in to confirm your age", "login_required"),
("confirm your age", "login_required"),
@@ -91,3 +110,20 @@ def classify(msg: str | None) -> str:
if marker in low:
return category
return "unknown"
def user_error_text(source_kind: str, msg: str, code: str) -> str:
"""The failure text to STORE on the job, given the raw message and its category.
SEC-12: for a user-supplied URL, a transport-level failure text distinguishes "connection
refused" from "HTTP Error 401" from "timed out" per host:port — which is a port scanner with
extra steps, since the raw text is shown to the user behind the details disclosure. `urlguard`
stops the fetch aiming at our own network; this stops the remaining readout.
Only the network class, and only for ``url`` jobs: a YouTube job has no user-chosen host, and
every other class (login_required, geo_blocked, drm, …) is real, useful diagnosis. A separate
function rather than an inline branch in the worker so it can be pinned by a test — the raw
text leaking back is a security regression that would otherwise pass a green suite."""
if source_kind == "url" and code == "network":
return NETWORK_REDACTED_MESSAGE
return msg
+20 -1
View File
@@ -116,6 +116,7 @@ def build_ydl_opts(
progress_hook,
extra: dict | None = None,
cookiefile: str | None = None,
max_bytes: int | None = None,
) -> dict:
"""Assemble yt-dlp options for a single download to `outtmpl` (a full path template).
@@ -125,12 +126,20 @@ def build_ydl_opts(
`cookiefile` is the acting user's per-host cookies.txt path (S6), attached ONLY by the worker
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
merge so a source adapter can never clobber it."""
merge so a source adapter can never clobber it.
`max_bytes` (SEC-13) is the caller's remaining storage budget; yt-dlp refuses a format larger
than it rather than filling the disk and only then being caught by the quota accounting."""
s = normalize(spec)
h = s["max_height"]
opts: dict = {
"outtmpl": outtmpl,
"noplaylist": True,
# SEC-13: `noplaylist` only demotes a video-inside-a-playlist URL to the single video. A
# URL that is PURELY a playlist or channel has no such video to fall back to, so it still
# expands into every entry — one enqueue, an unbounded number of downloads. One job means
# one item; a user who wants the whole list enqueues it item by item, under their quota.
"playlist_items": "1",
"quiet": True,
"no_warnings": True,
"noprogress": True,
@@ -154,6 +163,16 @@ def build_ydl_opts(
if extra:
opts.update(extra)
# SEC-13: hard ceiling on a single download. AFTER the adapter merge, for the same reason
# `cookiefile` is — a source adapter must not be able to raise a limit the quota layer set.
#
# A non-positive budget is left unset because 0 is not an expressible yt-dlp filter, NOT because
# nothing needs to happen: the worker refuses that job outright before extraction (`_OwnerRefused`
# → `quota_exceeded`). Enqueue cannot make that call — it ran while the user was still under the
# cap. Delete the worker's refusal and this branch silently uncaps the one user already over it.
if max_bytes and max_bytes > 0:
opts["max_filesize"] = max_bytes
# The acting user's per-host cookie (S6). AFTER the adapter merge on purpose: an adapter must not
# be able to override the authenticated session the worker resolved for this specific user.
if cookiefile:
+68 -4
View File
@@ -15,7 +15,8 @@ from app.config import settings
from app.db import SessionLocal
from app.downloads import edit as editmod
from app.downloads import errors as dlerrors
from app.downloads import adapters, credentials, formats, mediakind, probe, service, storage
from app.downloads import adapters, credentials, formats, mediakind, probe, quota, service, storage
from app.downloads import urlguard
from app.downloads.jobdb import (
_edit_staging_dir,
_job_status,
@@ -36,6 +37,18 @@ class _Aborted(Exception):
"""Raised inside the progress hook when the job was paused/canceled mid-download."""
class _OwnerRefused(Exception):
"""A refusal that belongs to THIS job's owner, not to the source.
MediaAssets are shared: two users asking for the same video and format get one row (see
`service.get_or_create_asset`), and the generic failure handler writes the message onto it —
correctly, because an upstream failure really is everyone's. A per-user refusal is not. Written
to the shared row it would fail a second user's queued job for the same video with the first
user's private reason (`worker._process_job` reads `asset.error` verbatim), telling someone with
an empty library to free up space. So this is caught separately: the job fails, the asset goes
back to `pending` for whoever else is holding it."""
# Both progress sources call back FAR faster than the database should be touched: yt-dlp fires its
# hook per chunk, and ffmpeg's `-progress pipe:1` emits a block of ~12 lines every ~0.5s while
# run_ffmpeg calls its callbacks once per LINE. Every callback here is a round-trip (`_job_status`
@@ -305,13 +318,17 @@ def _media_info(info: dict) -> dict:
return {**info, **chosen}
def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookiefile=None):
def _extract_with_fallback(
job_id, asset_id, url, spec, staging, adapter, cookiefile=None, max_bytes=None
):
"""Download via yt-dlp using the source `adapter`'s client sets + options. For YouTube the adapter
supplies the primary player-client set then a POT-backed fallback (retried on a bot/DRM/format
error); other sources make a single attempt. Returns the info dict of the successful attempt.
`cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated."""
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt."""
client_sets = adapter.client_sets()
outtmpl = str(staging / "%(id)s.%(ext)s")
last_exc: Exception | None = None
@@ -322,6 +339,7 @@ def _extract_with_fallback(job_id, asset_id, url, spec, staging, adapter, cookie
_make_progress_hook(job_id, asset_id),
extra=adapter.ydl_opts_extra(clients),
cookiefile=cookiefile,
max_bytes=max_bytes,
)
opts["postprocessor_hooks"] = [_make_pp_hook(job_id)]
_touch_job(job_id) # stall-watchdog heartbeat: extraction writes no progress, so a slow /
@@ -415,8 +433,17 @@ def _download(
_set_job(job_id, phase="downloading", progress=0)
adapter = adapters.resolve_adapter(source_kind, source_ref)
url = service.source_url(source_kind, source_ref)
# SEC-12, second half: the enqueue guard ran when the job was created, which may have been
# hours ago and against a different DNS answer. This is where the socket actually gets
# opened, so the check is repeated here and the job fails rather than fetching.
if source_kind == "url":
try:
urlguard.check_url(url)
except urlguard.BlockedUrl as e:
raise RuntimeError(f"Refusing to download from this address ({e.reason}).") from e
# S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND
# this asset is that user's private, salted identity (the double gate lives in credentials).
remaining_bytes: int | None = None
with SessionLocal() as cdb:
asset_sig = cdb.execute(
select(MediaAsset.format_sig).where(MediaAsset.id == asset_id)
@@ -425,6 +452,19 @@ def _download(
cookiefile = credentials.resolve_cookiefile(
cdb, user_id, url, asset_sig, spec, staging
)
# SEC-13: what this user has left. `check_enqueue` only compared the footprint as it
# stood when the job was QUEUED — a download's size is unknown until it finishes, so a
# single job could previously blow through the cap by any amount. Handing the remainder
# to yt-dlp as `max_filesize` makes the format selector refuse an oversized file up
# front instead.
remaining_bytes = quota.remaining_bytes(cdb, user_id)
# …and zero is a REFUSAL, not "no limit". Earlier jobs of this user may have filled the cap
# while this one sat in the queue, and 0 is not an expressible yt-dlp `max_filesize`, so
# passing it down would leave the ceiling unset — the one user already at their cap would be
# the only one downloading unbounded. Fail the job here instead. Not retryable until they
# free space, which is what the `quota_exceeded` category tells them.
if remaining_bytes == 0:
raise _OwnerRefused(dlerrors.QUOTA_FULL_MESSAGE)
info = _media_info(
_extract_with_fallback(
job_id,
@@ -434,6 +474,7 @@ def _download(
staging,
adapter,
cookiefile=str(cookiefile) if cookiefile else None,
max_bytes=remaining_bytes,
)
)
# Re-resolve now that yt-dlp's info reveals the true source (a bare/redirected url only
@@ -525,8 +566,31 @@ def _download(
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
_reset_asset_pending(asset_id)
log.info("job %s aborted (pause/cancel)", job_id)
except _OwnerRefused as exc:
# Fail this job only. The asset is never marked `error`; it goes back to `pending` (below),
# so another user's job for the same video is claimed and downloaded normally instead of
# inheriting a reason that is about someone else's account.
msg = str(exc)[:500]
with SessionLocal() as db:
service.finalize_if_running(
db,
job_id,
status=JobStatus.ERROR,
error=msg,
error_code=dlerrors.classify(msg),
phase=None,
)
db.commit()
# Released AFTER the job is terminal: the moment the asset is `pending` another job can claim
# it, and this one must already be out of the way. (The staging tree is still torn down in
# `finally` below — the same narrow window the pause/cancel path above has always had.)
_reset_asset_pending(asset_id)
log.info("job %s refused for its owner: %s", job_id, msg)
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
msg = str(exc)[:500]
code = dlerrors.classify(msg)
# SEC-12: redact a transport-level readout for user-supplied URLs (see user_error_text).
msg = dlerrors.user_error_text(source_kind, msg, code)
with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id)
if asset:
@@ -539,7 +603,7 @@ def _download(
job_id,
status=JobStatus.ERROR,
error=msg,
error_code=dlerrors.classify(msg),
error_code=code,
phase=None,
)
db.commit()
+22
View File
@@ -7,6 +7,13 @@ Limits come from a per-user DownloadQuota row if the admin set one, else the sys
Enforcement split: max_jobs + max_bytes are checked at enqueue (they bound holdings); per-user
max_concurrent is enforced by the worker at claim time (it bounds simultaneous downloads).
DOWNLOAD jobs additionally consult `remaining_bytes` in the worker, because enqueue can only compare
the footprint as it stood when the job was created and earlier jobs may have filled the cap since.
That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user each read the same
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
is unknown until it finishes. Edit jobs do not consult it at all (tracked as SEC-19).
"""
from dataclasses import dataclass
@@ -122,6 +129,21 @@ def check_enqueue(db: Session, user_id: int) -> None:
raise QuotaExceeded("max_bytes", lim.max_bytes, used)
def remaining_bytes(db: Session, user_id: int) -> int | None:
"""Storage this user may still spend on ONE download: ``None`` for an unlimited user, else the
cap minus the current footprint, floored at 0.
Zero is a real answer, not "no limit" — it means the cap is already met and the caller must
REFUSE rather than proceed unbounded. `check_enqueue` cannot make that refusal on the caller's
behalf: it ran when the job was queued, while the user was still under the cap, and the jobs
that filled it have landed since. Keeping the arithmetic here (rather than inline in the worker)
is what keeps every max_bytes decision in this module."""
lim = resolve(db, user_id)
if lim.unlimited:
return None
return max(0, lim.max_bytes - footprint(db, user_id))
def check_upload(db: Session, user_id: int, add_bytes: int) -> None:
"""Like check_enqueue, but for an upload whose size is KNOWN at ingest: also reject when adding
this file would push the footprint over max_bytes. A download can't pre-check its size (unknown
+108
View File
@@ -0,0 +1,108 @@
"""Refuse a user-supplied download URL that points back into our own network (SEC-12).
The Download Center deliberately accepts an arbitrary ``http(s)`` URL — that is the feature — and
hands it to yt-dlp running on the WORKER. Without a guard, any approved user can make the server
fetch anything the server can reach: other containers on the compose network, the LAN, a cloud
metadata endpoint. The failure text then comes back to them in ``job.error``, so "connection
refused" vs "HTTP Error 401" vs "timed out" turns the download box into an internal port scanner.
What this closes: a URL whose host resolves to a loopback / private / link-local / reserved address
is refused, at enqueue AND again in the worker before extraction. Both, because DNS can answer
differently between the two moments, and the worker is where the socket is actually opened.
What it does NOT close, stated plainly rather than implied, because both need the same fix — gating
the connection itself with a custom socket factory inside yt-dlp, a much larger change than this:
* DNS rebinding: a host that passes the worker's check and then answers yt-dlp's own resolution
with an internal address.
* HTTP redirects: a public host that answers 3xx with an internal ``Location``. yt-dlp follows it,
and only the URL the user TYPED was ever judged here. An attacker-controlled host or any public
open redirect reaches it, so this is the cheaper of the two. It does NOT usually yield the
response body — a non-media body fails the mp4 remux and stores nothing — but it does hand back
the readout above: the redaction in ``errors.user_error_text`` fires only for the ``network``
class, so a dead internal port (refused → ``network`` → redacted) is distinguishable from a live
one (an HTTP answer that is not media → ``unsupported_host``/``format_unavailable``, raw text
kept). Tracked as SEC-17.
The two checks here stop the straightforward case (typing ``http://127.0.0.1:6379`` or an internal
hostname into the box), which is the reachable one.
Not used for YouTube jobs: those resolve to a fixed public host from an 11-char id, so there is no
user-controlled destination to guard.
"""
import ipaddress
import socket
from urllib.parse import urlparse
# Reason codes travel to the client inside the structured 422 detail, so they are part of the API
# vocabulary (frontend `localizeDetail`) — keep them stable.
REASON_SCHEME = "scheme" # not http(s)
REASON_NO_HOST = "no_host" # URL carries no hostname
REASON_MALFORMED = "malformed" # unparseable authority or port; urlparse raises late, so guard it
REASON_UNRESOLVABLE = "unresolvable" # DNS said nothing (honest: not the same as "blocked")
REASON_PRIVATE = "private" # resolves into a network the server must not fetch from
class BlockedUrl(Exception):
"""A user-supplied URL that must not be fetched server-side. ``reason`` is one of the
REASON_* codes above."""
def __init__(self, reason: str):
super().__init__(reason)
self.reason = reason
def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""Every address family we must not open a connection to. Checked explicitly rather than
leaning on ``is_private`` alone, whose coverage differs between IPv4 and IPv6."""
# ::ffff:127.0.0.1 is loopback wearing an IPv6 hat — unwrap before judging it.
mapped = getattr(ip, "ipv4_mapped", None)
if mapped is not None:
ip = mapped
return bool(
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
)
def _resolve(host: str, port: int | None) -> list[str]:
"""Every address ``host`` resolves to. Raises BlockedUrl(REASON_UNRESOLVABLE) when DNS has no
answer — reported as its own outcome, because "we could not look that up" and "we refuse to
fetch that" are different things to tell a user."""
try:
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
except socket.gaierror as exc:
raise BlockedUrl(REASON_UNRESOLVABLE) from exc
return [info[4][0] for info in infos]
def check_url(raw: str) -> None:
"""Raise BlockedUrl unless ``raw`` is an http(s) URL whose host resolves only to addresses the
server is allowed to fetch from. A host with even ONE forbidden address is refused: a
round-robin answer must not become a coin flip."""
# `urlparse` itself raises ValueError on a malformed authority ("http://[::1/x" — an unclosed
# bracket), and `.port` raises on ":99999" / ":abc" when READ rather than at parse time. Both
# escape the caller's `except BlockedUrl` as a 500 unless caught here, which is the opposite of
# what this module exists to do: turn a bad URL into a structured, localizable refusal.
try:
u = urlparse((raw or "").strip())
port = u.port
except ValueError as exc:
raise BlockedUrl(REASON_MALFORMED) from exc
if u.scheme not in ("http", "https"):
raise BlockedUrl(REASON_SCHEME)
host = u.hostname
if not host:
raise BlockedUrl(REASON_NO_HOST)
for addr in _resolve(host, port):
try:
ip = ipaddress.ip_address(addr)
except ValueError: # getaddrinfo returned something unparseable; treat as unusable
raise BlockedUrl(REASON_PRIVATE) from None
if _is_forbidden(ip):
raise BlockedUrl(REASON_PRIVATE)
+73 -7
View File
@@ -32,7 +32,12 @@ from app.models import (
User,
)
from app.routes._common import get_or_404
from app.routes.auth import APPROVAL_VERIFY_TTL, issue_verification_link, send_verification_link
from app.routes.auth import (
APPROVAL_VERIFY_TTL,
issue_set_password_link,
issue_verification_link,
send_verification_link,
)
from app.utils import canonical_email, iso, valid_email
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -168,9 +173,7 @@ def _activate_user_by_email(db: Session, email: str) -> None:
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
inv = db.get(Invite, invite_id)
if inv is None:
raise HTTPException(status_code=404, detail="No such invite")
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
# Idempotency guard: only a still-pending invite can be decided. Without this a repeat/stale
# approve or deny re-runs its side effects — re-activating a user, or (now that deny emails)
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
@@ -361,9 +364,7 @@ def delete_invite(
falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for
an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
inv = db.get(Invite, invite_id)
if inv is None:
raise HTTPException(status_code=404, detail="No such invite")
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
if _accounts_for(db, [inv.email]):
raise HTTPException(
status_code=409,
@@ -397,7 +398,15 @@ def _serialize_user(u: User) -> dict:
"email_verified": u.email_verified,
"is_demo": u.is_demo,
"has_password": u.password_hash is not None,
# Distinguishes "this account never had a password" (Google-only) from "SEC-10 cleared it" —
# the same distinction _send_reset_if_eligible needs, and the one an admin has to see before
# deciding what kind of help someone needs.
"password_revoked_at": iso(u.password_revoked_at),
"has_google": u.google_sub is not None,
# Only when it DIFFERS from the account address (E9.5 S3): "connected" is not the same
# statement as "connected as someone else", and the second one is the one that explains a
# confusing sign-in.
"google_email": u.google_email if u.google_email != u.email else None,
"created_at": iso(u.created_at),
}
@@ -612,6 +621,63 @@ def list_pending_access(_: User = Depends(admin_user), db: Session = Depends(get
return out
@router.get("/users/{user_id}/access")
def user_access(
user_id: int,
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Everything the account panel shows about why this person can or cannot get in (E12 S5).
Its own endpoint rather than extra columns on /users: `is_allowed` and `can_activate` each run a
query, and the panel opens for ONE user at a time — folding them into the list would pay that cost
per row on every load to answer a question nobody asked yet."""
target = get_or_404(db, User, user_id, detail="No such user")
return _serialize_user(target) | {
"is_allowed": is_allowed(db, target.email),
"can_activate": can_activate(db, target),
"email_configured": email_mod.email_enabled(db),
}
@router.post("/users/{user_id}/set-password-link")
def create_set_password_link(
user_id: int,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Mint a one-time link that lets this account set a password, with no email involved (E12 S5).
The answer to SEC-15, SEC-16 and "no SMTP and someone forgot their password" at once. The admin
hands the link over by whatever channel they trust; opening it sets a password and signs out
whoever held the old one.
Deliberately NOT gated on the account's state. It is useful for a pre-empted address, for a
revoked password, for a Google-only account that wants one, and for a plain forgotten password on
an instance with no mail — narrowing it to one of those would only make the admin's real case the
one it refuses. The demo account is the single exception: it is shared, so handing anyone a way to
change its password is handing them everyone else's session."""
target = get_or_404(db, User, user_id, detail="No such user")
if target.is_demo:
raise HTTPException(
status_code=400, detail={"code": "setpw", "reason": "demo", "email": target.email}
)
url, expires_at = issue_set_password_link(db, target)
# The URL itself is NOT recorded: the audit log is read by humans and kept long, and a live
# credential-setting link is the last thing that belongs in it. That the link was issued, by whom,
# for whom, is the auditable fact.
audit.record(
db,
admin.id,
AuditAction.USER_SETPW_LINK,
target_type="user",
target_id=target.id,
summary=target.email,
)
db.commit()
return {"ok": True, "url": url, "expires_at": iso(expires_at), "email": target.email}
@router.post("/users/{user_id}/activate")
def activate_user(
user_id: int,
+51 -5
View File
@@ -52,6 +52,16 @@ VERIFY_TTL = timedelta(hours=24)
# in and sets no password.
APPROVAL_VERIFY_TTL = timedelta(days=7)
RESET_TTL = timedelta(hours=1)
# An admin-issued set-password link (E12 S5) travels out of band — the admin has to actually reach the
# person and hand it over — so the one-hour reset window is far too short. A day matches VERIFY_TTL and
# the affordance the admin was shown.
ADMIN_SETPW_TTL = timedelta(hours=24)
# Both kinds set a password from a link, and the SIGNED-OUT card consumes them through the same
# `#reset=` entry, so the user-facing flow is one flow. They stay separate kinds for one reason: a
# `reset` token proves control of the MAILBOX (it was mailed there) and therefore confirms the address;
# an admin-issued one proves nothing about the mailbox — the admin could hand it to anyone — so it must
# not silently satisfy the email gate. See password_reset_confirm.
_PASSWORD_TOKEN_KINDS = ("reset", "setpw")
_DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
@@ -773,6 +783,35 @@ def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
return db.get(User, row.user_id)
def _consume_password_token(db: Session, raw: str | None) -> tuple[User, str] | None:
"""Spend a password-setting token of either kind; returns (user, kind). The kind is returned, not
swallowed, because the caller has to treat them differently — only a mailed reset attests the
address."""
for kind in _PASSWORD_TOKEN_KINDS:
user = _consume_token(db, raw, kind)
if user is not None:
return user, kind
return None
def issue_set_password_link(db: Session, user: User) -> tuple[str, datetime]:
"""An admin-issued, single-use link that lets `user` set a password without any email (E12 S5).
One mechanism answers three things: SEC-15 (on a no-SMTP instance every registration is
auto-verified, so a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address
stays takeable until its owner resets, and password-reset needs working mail), and the everyday
"no SMTP configured and someone forgot their password".
Issuing does NOT revoke the current password: an admin misclick must not lock anyone out, and the
account may be perfectly healthy. USING the link replaces the hash and bumps the session epoch,
which is what evicts a squatter — see password_reset_confirm."""
expires_at = datetime.now(timezone.utc) + ADMIN_SETPW_TTL
raw = _issue_token(db, user, "setpw", ADMIN_SETPW_TTL)
# The same `#reset=` fragment the mailed link uses: the signed-out card already knows how to
# consume it, and a second entry shape would be a second thing to keep correct.
return f"{_app_base()}/#reset={raw}", expires_at
@router.post("/register")
def register(
body: EmailPasswordIn,
@@ -1105,7 +1144,7 @@ def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(
"""
if not _reset_check_limiter.allow(_client_ip(request)):
return {"ok": True}
return {"ok": _peek_token(db, body.token, "reset") is not None}
return {"ok": any(_peek_token(db, body.token, k) is not None for k in _PASSWORD_TOKEN_KINDS)}
@router.post("/password-reset/confirm")
@@ -1118,16 +1157,23 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
status_code=400,
detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN},
)
user = _consume_token(db, token, "reset")
if user is None:
spent = _consume_password_token(db, token)
if spent is None:
raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"})
user, kind = spent
user.password_hash = hash_password(new_password)
user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent
# A valid reset link proves control of the mailbox, so completing a reset also verifies the
# email — this rescues an account that registered but never clicked the verification link (it
# can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched
# here: a reset proves email ownership, not that an admin approved the account.
user.email_verified = True
#
# An admin-issued `setpw` link proves NOTHING about the mailbox — the admin hands it over by
# whatever means they choose — so it must not satisfy the email gate. Claiming otherwise would let
# an admin confirm an address nobody ever proved, which is the same false attestation SEC-15 is
# about. The person still confirms their address the normal way.
if kind == "reset":
user.email_verified = True
bump_session_epoch(
user
) # SA4: a reset (often a compromise response) kills all existing sessions.
@@ -1135,7 +1181,7 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
for row in db.execute(
select(AuthToken).where(
AuthToken.user_id == user.id,
AuthToken.kind == "reset",
AuthToken.kind.in_(_PASSWORD_TOKEN_KINDS),
AuthToken.used_at.is_(None),
)
).scalars():
+30 -2
View File
@@ -28,6 +28,7 @@ from app.downloads import edit as editmod
from app.downloads import links as linksmod
from app.downloads import quota, service, storage
from app.downloads import upload as uploadmod
from app.downloads import urlguard
from app.models import (
DownloadJob,
DownloadLink,
@@ -65,7 +66,18 @@ def resolve_source(raw: str) -> tuple[str, str]:
if _VIDEO_ID.match(raw):
return "youtube", raw
if raw.startswith(("http://", "https://")):
u = urlparse(raw)
# `urlparse` raises ValueError on a malformed authority ("http://[::1/x"), and this runs
# BEFORE the urlguard check that would otherwise turn it into a structured refusal — so
# unguarded, that typo escapes as a 500. Answered in urlguard's own shape, because it is the
# same typo class: ":99999" is caught there and localized, and one of the two arriving as a
# bare English string would be exactly the split the canonical-patterns table forbids.
try:
u = urlparse(raw)
except ValueError as exc:
raise HTTPException(
status_code=422,
detail={"code": "url_blocked", "reason": urlguard.REASON_MALFORMED},
) from exc
host = u.netloc.lower()
if "youtube.com" in host:
if u.path.startswith(("/shorts/", "/embed/", "/live/")):
@@ -352,6 +364,17 @@ def enqueue_download(
body: EnqueueDownloadIn, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
source_kind, source_ref = resolve_source(body.source)
# SEC-12: a non-YouTube source is a raw URL the worker will fetch server-side, so refuse the
# ones pointing back into our own network BEFORE a job exists. The worker re-checks (DNS can
# move between here and the download); see downloads/urlguard.py for what that does and does
# not cover.
if source_kind == "url":
try:
urlguard.check_url(source_ref)
except urlguard.BlockedUrl as e:
raise HTTPException(
status_code=422, detail={"code": "url_blocked", "reason": e.reason}
) from e
spec, profile_id = _resolve_spec(db, user, body.profile_id, body.spec)
display_name = (body.display_name or "").strip() or None
try:
@@ -532,7 +555,12 @@ def _clean_url(raw) -> str | None:
return None
if not url.startswith(("http://", "https://")):
return None
parsed = urlparse(url)
# The third site in this module handing user input to `urlparse`, which raises on a malformed
# authority. This one already has a "not a plausible URL" answer, so the typo belongs in it.
try:
parsed = urlparse(url)
except ValueError:
return None
if not parsed.netloc:
return None
return url[:2048]
+97
View File
@@ -3,6 +3,8 @@ CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-w
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select, and_
@@ -20,10 +22,30 @@ from app.sync.playlists import (
repull_playlist,
sync_user_playlists,
)
from app.ratelimit import RateLimiter
from app.youtube.client import YouTubeClient, YouTubeError
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
# SEC-14, same reasoning as the sync module's limiter: a playlist pull lists every playlist the
# caller owns and then every item in each, against the instance-wide YouTube budget. Its own
# limiter rather than a shared one — spending the manual subscription-import allowance must not
# also close the playlist button, and vice versa.
#
# It guards EVERY route that spends under QuotaAction.PLAYLISTS_PULL, not just the bulk one: a
# scripted loop over the single-playlist re-pull drains the same shared budget, and limiting one of
# two routes with the same quota action is the asymmetric fix this codebase keeps re-learning.
# Deliberately NOT on the push side (`push-plan` / `push`): those run on every push attempt,
# including the no-op "already up to date" path, so a 3-per-10-minute allowance would refuse
# legitimate work. Their exposure is tracked as SEC-18 instead.
_playlist_pull_limiter = RateLimiter(max_events=3, window_seconds=600)
def _check_pull_cooldown(user: User) -> None:
"""Shared gate so the two PLAYLISTS_PULL routes cannot drift apart again."""
if not _playlist_pull_limiter.allow(str(user.id)):
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
pl = db.get(Playlist, playlist_id)
@@ -32,6 +54,16 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
return pl
def _touch(pl: Playlist) -> None:
"""Bump the playlist's `updated_at` for an edit that only writes `playlist_items`.
`UpdatedAtMixin`'s `onupdate` fires on an UPDATE of the PLAYLIST row, and add / remove /
reorder / move write the item rows instead — so without this the rail's "recently changed"
sort would only ever see renames. The assignment is what makes the column mean "last time
this list changed", which is what the rail promises the reader."""
pl.updated_at = datetime.now(timezone.utc)
def _next_playlist_position(db: Session, user_id: int) -> int:
"""Append position for a new playlist in this user's rail."""
return (
@@ -71,6 +103,7 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
)
if mark_dirty:
recompute_dirty(db, pl)
_touch(pl)
db.commit()
return True
@@ -88,6 +121,7 @@ def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool)
db.delete(item)
if mark_dirty:
recompute_dirty(db, pl)
_touch(pl)
db.commit()
return True
@@ -109,6 +143,9 @@ def _summary(
"item_count": count,
"total_duration_seconds": total_duration,
"cover_thumbnail": cover,
# When this list last CHANGED (rename, add/remove/move/reorder) — the rail's
# "recently changed first" sort reads it. See _touch for why it isn't automatic.
"updated_at": pl.updated_at,
}
if has_video is not None:
out["has_video"] = has_video
@@ -284,6 +321,7 @@ def sync_youtube(
status_code=403,
detail="Connect YouTube (read access) to sync your playlists.",
)
_check_pull_cooldown(user)
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return sync_user_playlists(db, user)
@@ -304,6 +342,7 @@ def revert_youtube(
raise HTTPException(
status_code=403, detail="Connect YouTube (read access) to sync your playlists."
)
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return repull_playlist(db, user, pl)
@@ -498,6 +537,63 @@ def remove_item(
return {"playlist_id": pl.id, "video_id": video_id}
class MoveItemIn(BaseModel):
target_playlist_id: int
@router.post("/{playlist_id}/items/{video_id}/move")
def move_item(
playlist_id: int,
video_id: str,
body: MoveItemIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Move one video from a playlist into another of the caller's playlists, atomically.
ONE transaction on purpose. The obvious client-side shape — add to the target, then remove
from the source — leaves the video in BOTH lists whenever the second call fails, which is
exactly the kind of half-applied state this codebase treats as a bug rather than an edge
case. Idempotent: if the video is already in the target, the source row is simply dropped
(moving into a list that already has it is a removal, not a duplicate)."""
src = _own_playlist(db, user, playlist_id)
dst = _own_playlist(db, user, body.target_playlist_id)
if src.id == dst.id:
raise HTTPException(
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
)
item = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
)
)
if item is None:
# A concurrent edit (another tab, or a YouTube re-pull) already took it out.
raise HTTPException(
status_code=404, detail={"code": "playlist_move", "reason": "not_in_playlist"}
)
already = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == dst.id, PlaylistItem.video_id == video_id
)
)
if already is not None:
db.delete(item) # the uq_playlist_video constraint would reject a second row anyway
else:
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
# survives the move, and there is no window where the video belongs to neither list.
item.playlist_id = dst.id
item.position = _next_item_position(db, dst.id)
# Both sides changed, so both get the dirty recompute and the timestamp — the asymmetric
# version of this (touching only the source) is the recurring smell CLAUDE.md warns about.
recompute_dirty(db, src)
recompute_dirty(db, dst)
_touch(src)
_touch(dst)
db.commit()
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
class ReorderItemsIn(BaseModel):
video_ids: list[str] = []
@@ -533,5 +629,6 @@ def reorder_items(
it.position = pos
pos += 1
recompute_dirty(db, pl)
_touch(pl)
db.commit()
return {"playlist_id": pl.id, "count": pos}
+8 -1
View File
@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
from app import email as email_mod
from app import mailtext
from app import state, sysconfig
from app.utils import canonical_email
from app.auth import PASSWORD_MIN_LEN
from app.db import get_db
from app.models import User
@@ -70,7 +71,13 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
raise HTTPException(
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
)
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists
# to prevent. Every other lookup went canonical in S3; this one was outside that diff.
user = db.execute(
select(User).where(User.email_canonical == canonical_email(email))
).scalar_one_or_none()
if user is None:
user = User(email=email, role="admin")
db.add(user)
+10
View File
@@ -7,12 +7,20 @@ from app.audit import AuditAction
from app.auth import admin_user, current_user, has_read_scope, require_human
from app.db import get_db
from app.models import Channel, Subscription, User, Video
from app.ratelimit import RateLimiter
from app.sync.runner import estimate_deep_backfill
from app.sync.subscriptions import import_subscriptions
from app.scheduler import running_job_ids
router = APIRouter(prefix="/api/sync", tags=["sync"])
# SEC-14: the YouTube API budget is SHARED across the whole instance, and a subscription import
# walks every page of the caller's subscriptions — so one user holding down a manual sync button can
# spend the day's units for everyone. Keyed by user id, not client IP: the cost belongs to the
# account, and the same person on a second device is the same drain. 3 per 10 minutes leaves manual
# re-syncing entirely usable (the scheduler does the routine work anyway) while removing the loop.
_sync_limiter = RateLimiter(max_events=3, window_seconds=600)
@router.post("/subscriptions")
def sync_subscriptions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
@@ -22,6 +30,8 @@ def sync_subscriptions(user: User = Depends(require_human), db: Session = Depend
status_code=403,
detail="Connect your YouTube account (read access) to import subscriptions.",
)
if not _sync_limiter.allow(str(user.id)):
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
result = import_subscriptions(db, user)
result["quota_used_estimate"] = m.used
+112
View File
@@ -0,0 +1,112 @@
"""E12 · S3 — the route-level halves of SEC-12 and SEC-14.
`test_urlguard.py` pins the address judgement itself; this pins that the ENQUEUE route actually
consults it and answers in the structured `{code, reason}` shape the frontend localizes, and that a
manual sync trigger stops spending the instance-wide YouTube budget when held down.
DB-backed (the `as_user` fixture needs real rows) — skipped when no database is reachable.
"""
from app.downloads import urlguard
def test_enqueueing_a_loopback_url_is_refused_with_a_structured_detail(
db, as_user, make_user, monkeypatch
):
user = make_user()
db.commit()
monkeypatch.setattr(
urlguard.socket, "getaddrinfo", lambda *a, **kw: [(None, None, None, "", ("127.0.0.1", 0))]
)
r = as_user(user).post("/api/downloads", json={"source": "http://redis.internal:6379/x"})
assert r.status_code == 422
# The shape matters as much as the refusal: a bare English string under a bilingual UI is the
# thing the canonical-patterns table forbids.
assert r.json()["detail"] == {"code": "url_blocked", "reason": urlguard.REASON_PRIVATE}
def test_a_public_url_gets_past_the_guard(db, as_user, make_user, monkeypatch):
# Mutation check for the test above: with a public answer the request must fail LATER (or
# succeed), never with url_blocked — otherwise the guard would be refusing everything and the
# first test would still pass.
user = make_user()
db.commit()
monkeypatch.setattr(
urlguard.socket,
"getaddrinfo",
lambda *a, **kw: [(None, None, None, "", ("93.184.216.34", 0))],
)
r = as_user(user).post("/api/downloads", json={"source": "https://example.com/v"})
detail = r.json().get("detail") if r.status_code >= 400 else None
assert not (isinstance(detail, dict) and detail.get("code") == "url_blocked")
def test_youtube_sources_never_consult_the_guard(db, as_user, make_user, monkeypatch):
# A YouTube job resolves to a fixed public host from an 11-char id, so there is no
# user-controlled destination — resolving one would be pure latency on every enqueue.
def boom(*a, **kw):
raise AssertionError("the URL guard resolved DNS for a YouTube source")
user = make_user()
db.commit()
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
as_user(user).post("/api/downloads", json={"source": "dQw4w9WgXcQ"})
def test_manual_subscription_sync_goes_on_cooldown(db, as_user, make_user, monkeypatch):
from app.routes import sync as syncroutes
user = make_user()
db.commit()
# Neither the YouTube grant nor the import itself is under test — the limiter is.
monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0})
c = as_user(user)
codes = [c.post("/api/sync/subscriptions").status_code for _ in range(4)]
# 3 per 10 minutes: the first three pass, the fourth is refused.
assert codes[:3] == [200, 200, 200]
assert codes[3] == 429
assert c.post("/api/sync/subscriptions").json()["detail"] == {"code": "sync_cooldown"}
def test_the_cooldown_is_per_user_not_global(db, as_user, make_user, monkeypatch):
# Keyed by user id on purpose: the shared YouTube budget is spent per account, and one user
# exhausting their allowance must not lock everyone else out of their own sync button.
from app.routes import sync as syncroutes
a, b = make_user(), make_user()
db.commit()
monkeypatch.setattr(syncroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(syncroutes, "import_subscriptions", lambda d, u: {"imported": 0})
ca = as_user(a)
for _ in range(4):
ca.post("/api/sync/subscriptions")
assert ca.post("/api/sync/subscriptions").status_code == 429
assert as_user(b).post("/api/sync/subscriptions").status_code == 200
def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_user, monkeypatch):
# SEC-14, the sweep half: `revert-youtube` spends under the SAME QuotaAction.PLAYLISTS_PULL key
# as the bulk `sync-youtube`, so limiting only one of them left the identical drain open one
# route over. Both must draw on the one allowance.
from app.models import Playlist
from app.routes import playlists as plroutes
user = make_user()
pl = Playlist(user_id=user.id, name="Linked", source="youtube", yt_playlist_id="PL123")
db.add(pl)
db.commit()
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {"ok": True})
monkeypatch.setattr(plroutes, "sync_user_playlists", lambda d, u: {"ok": True})
c = as_user(user)
codes = [c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code for _ in range(4)]
assert codes[:3] == [200, 200, 200]
assert codes[3] == 429
# One allowance, not one each: the bulk route is now on cooldown too.
r = c.post("/api/playlists/sync-youtube")
assert r.status_code == 429
assert r.json()["detail"] == {"code": "sync_cooldown"}
+20
View File
@@ -1,5 +1,6 @@
"""Category classification of raw yt-dlp / ffmpeg download-failure strings (S2)."""
from app.downloads import errors as dlerrors
from app.downloads.errors import classify
@@ -54,3 +55,22 @@ def test_login_beats_generic_unavailable_when_both_present():
"This video is unavailable"
)
assert classify(msg) == "login_required"
def test_our_own_worker_refusals_classify_as_themselves():
# The worker raises these itself (not yt-dlp), and the rule that catches each one matches only a
# fragment of the text. Pinning message and rule together is what stops an edit to the wording
# from silently orphaning the category — the UI would then fall back to "Download failed".
assert classify(dlerrors.QUOTA_FULL_MESSAGE) == "quota_exceeded"
def test_a_url_jobs_network_failure_is_redacted_but_youtube_keeps_its_text():
# SEC-12: the raw transport text for a user-chosen host:port is a port-scanner readout, so it is
# replaced for `url` jobs. Everything else must survive verbatim, or a real diagnosis is lost.
raw = "ERROR: Unable to download webpage: <urlopen error [Errno 111] Connection refused>"
assert dlerrors.user_error_text("url", raw, "network") == dlerrors.NETWORK_REDACTED_MESSAGE
# Same failure, but the destination was never user-chosen — nothing to leak, keep the detail.
assert dlerrors.user_error_text("youtube", raw, "network") == raw
# A user-chosen host, but a category that says something real about the CONTENT, not the network.
login = "ERROR: [facebook] 123: You must be logged in to see this post"
assert dlerrors.user_error_text("url", login, "login_required") == login
+109
View File
@@ -10,6 +10,8 @@ Uses the `db` fixture (real Postgres) — skipped when no database is reachable.
its OWN session, so every test COMMITS its setup before calling it.
"""
from app.config import settings
from app.downloads import pipeline, quota
from app.models import DownloadJob, DownloadQuota, MediaAsset, User
from app.worker import _claim_job
@@ -169,3 +171,110 @@ def test_list_downloads_is_capped(db, monkeypatch):
monkeypatch.setattr(dl, "_MAX_LIST", 2)
rows = dl.list_downloads(user=u, db=db)
assert len(rows) == 2 # bounded by the cap, not the 3 rows on disk
# --- E12 · S3 · the worker-side max_bytes remainder ------------------------------------------
def _sized_asset(db, size_bytes, ref):
a = MediaAsset(
source_kind="url",
source_ref=ref,
format_sig="s",
status="ready",
ref_count=1,
size_bytes=size_bytes,
)
db.add(a)
db.flush()
return a
def test_remaining_bytes_is_none_for_an_unlimited_user(db):
u = User(email="unlimited@example.com")
db.add(u)
db.flush()
_quota(db, u.id, max_concurrent=2, unlimited=True)
db.flush()
# None, not a number: "no cap" and "no budget left" must never be the same value downstream —
# build_ydl_opts leaves max_filesize unset for both, so only the caller can tell them apart.
assert quota.remaining_bytes(db, u.id) is None
def test_remaining_bytes_is_the_unspent_part_of_the_cap(db):
u = User(email="partial@example.com")
db.add(u)
db.flush()
db.add(
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
)
a = _sized_asset(db, 400, "partial-1")
_job(db, u.id, queue_pos=0, status="done", asset_id=a.id)
db.flush()
assert quota.remaining_bytes(db, u.id) == 600
def test_remaining_bytes_is_zero_once_the_cap_is_met(db):
# THE case this exists for: `check_enqueue` let these jobs in while the user was under the cap,
# and an earlier one has filled it since. Zero is what makes the worker refuse; if this ever
# returned the raw negative or None instead, the user already over quota would be the only one
# downloading with no size ceiling at all.
u = User(email="atcap@example.com")
db.add(u)
db.flush()
db.add(
DownloadQuota(user_id=u.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False)
)
a = _sized_asset(db, 1500, "atcap-1") # overshot: one job finished larger than the remainder
_job(db, u.id, queue_pos=0, status="done", asset_id=a.id)
db.flush()
assert quota.remaining_bytes(db, u.id) == 0
def test_a_refusal_for_one_user_does_not_poison_the_shared_asset(db, monkeypatch, tmp_path):
"""A user at their cap fails their own job, and another user's queued job for the same video
still downloads.
MediaAssets are deduped across users, and the generic failure handler writes the message onto
that shared row — correct for an upstream failure, wrong for "YOUR storage is full", which
`worker._process_job` would then hand verbatim to someone with an empty library.
"""
monkeypatch.setattr(settings, "download_root", str(tmp_path))
over, other = User(email="over@example.com"), User(email="other@example.com")
db.add_all([over, other])
db.flush()
db.add(
DownloadQuota(
user_id=over.id, max_bytes=1000, max_concurrent=2, max_jobs=100, unlimited=False
)
)
full = _sized_asset(db, 1500, "already-held")
_job(db, over.id, queue_pos=0, status="done", asset_id=full.id)
# `downloading` is the state the worker leaves it in — `_claim_asset` compare-and-sets it before
# the child runs — and `_reset_asset_pending` only acts on that status. Seeded as `pending` this
# assertion would pass with the release deleted.
shared = MediaAsset(
source_kind="youtube",
source_ref="x" * 11,
format_sig="s",
status="downloading",
ref_count=2,
)
db.add(shared)
db.flush()
mine = _job(db, over.id, queue_pos=1, status="running", asset_id=shared.id)
theirs = _job(db, other.id, queue_pos=2, asset_id=shared.id)
db.commit()
# The refusal happens before extraction, so nothing needs stubbing — if it ever stops happening
# first, this test starts reaching the network, which is its own signal.
pipeline._download(mine.id, shared.id, "youtube", "x" * 11, {}, over.id)
db.expire_all()
assert db.get(DownloadJob, mine.id).status == "error"
assert db.get(DownloadJob, mine.id).error_code == "quota_exceeded"
# The asset is released, NOT marked error — otherwise the next line's job inherits the refusal.
assert db.get(MediaAsset, shared.id).status == "pending"
assert db.get(MediaAsset, shared.id).error is None
assert db.get(DownloadJob, theirs.id).status == "queued"
+25
View File
@@ -345,3 +345,28 @@ def test_me_reports_the_google_address_only_when_it_differs(client, db, make_use
== 200
)
assert client.get("/api/me").json()["google_email"] == "me@gmail.com"
# --- DX-17 · the setup wizard resolves its admin canonically ---------------------------------
def test_rerunning_the_wizard_with_another_gmail_spelling_updates_the_same_admin(db):
"""The wizard can be re-run, and E9.5's premise is that two spellings of one Gmail mailbox are one
person. Resolving by the literal string here would create a SECOND admin row — exactly the
duplicate the epic exists to prevent. This site was outside S3's sweep (DX-17).
Calls the handler directly: `require_setup` is an instance-state gate, not the behaviour under
test, and the test fixtures present an already-configured instance."""
from app.routes.setup import SetupAdminIn, setup_admin
setup_admin(SetupAdminIn(email="a.b.c@gmail.com", password=_UAT_PW), db)
setup_admin(SetupAdminIn(email="abc@gmail.com", password="another good passphrase"), db)
admins = db.execute(select(User).where(User.role == "admin")).scalars().all()
assert len(admins) == 1, [u.email for u in admins]
# The address stays AS TYPED on the row it matched; only identity is canonical.
assert admins[0].email == "a.b.c@gmail.com"
# And the second run's password is the live one — it updated, it did not fork.
from app.security import verify_password
assert verify_password("another good passphrase", admins[0].password_hash)
+49
View File
@@ -109,3 +109,52 @@ def test_browser_transcode_flags_universal_playability():
assert F("h264", "aac", False) == (True, False, False)
# A failed probe / missing stream is never re-encoded.
assert F(None, None, True) == (False, False, False)
# --- SEC-13 · one enqueue is one bounded download -------------------------------------------
def test_a_playlist_url_can_only_yield_one_item():
# `noplaylist` alone only demotes a video-inside-a-playlist URL to that video; a URL that is
# purely a playlist or channel has no such video, so without this it expands into every entry.
opts = _opts({})
assert opts["noplaylist"] is True
assert opts["playlist_items"] == "1"
def test_the_remaining_quota_becomes_a_hard_size_ceiling():
opts = formats.build_ydl_opts({}, "/tmp/x", lambda d: None, max_bytes=5_000_000)
assert opts["max_filesize"] == 5_000_000
def test_no_budget_means_no_size_filter():
# Unlimited users pass None; a non-positive remainder is the quota layer's refusal to make at
# enqueue, not an impossible yt-dlp filter to express here.
for budget in (None, 0, -1):
assert "max_filesize" not in formats.build_ydl_opts(
{}, "/tmp/x", lambda d: None, max_bytes=budget
)
def test_a_source_adapter_cannot_raise_the_size_ceiling():
# The adapter merge happens after max_filesize is set, so this pins that no adapter option can
# widen a storage cap — the mirror of the cookiefile rule, in the opposite direction.
opts = formats.build_ydl_opts(
{}, "/tmp/x", lambda d: None, extra={"max_filesize": 999_999_999}, max_bytes=5_000_000
)
assert opts["max_filesize"] == 5_000_000
def test_a_join_cut_list_is_capped():
# An accurate join emits two filters per segment into one ffmpeg filter_complex, so an unbounded
# list is an unbounded command line. The surplus is dropped; the legitimate head survives.
segs = [{"start_s": i, "end_s": i + 1} for i in range(500)]
out = editmod.normalize_edit_spec({"segments": segs})
assert len(out["segments"]) == editmod._MAX_SEGMENTS
assert out["segments"][0] == {"start_s": 0.0, "end_s": 1.0}
def test_a_normal_cut_list_is_untouched():
segs = [{"start_s": 0, "end_s": 5}, {"start_s": 10, "end_s": 12}]
out = editmod.normalize_edit_spec({"segments": segs})
assert len(out["segments"]) == 2
+190
View File
@@ -0,0 +1,190 @@
"""E10 S2 — the one-step "move this video to another list" endpoint, and the `updated_at` touch
the rail's "recently changed" sort reads.
Two properties are load-bearing and neither is visible from the happy path alone:
1. **The move is one transaction.** The client-side shape it replaces (add to the target, then
remove from the source) leaves the video in BOTH lists when the second call fails. So the
tests assert the *pair* of lists after every call, never just the destination.
2. **`updated_at` tracks item edits.** `UpdatedAtMixin.onupdate` only fires on an UPDATE of the
playlist row, and add/remove/reorder/move write `playlist_items` — so the timestamp is bumped
explicitly, and a regression there would silently degrade the sort to "renames only".
"""
from datetime import datetime, timedelta, timezone
from app.models import Channel, Playlist, PlaylistItem, Video
def _seed_videos(db, ids):
db.add(Channel(id="UCtest", title="Test"))
now = datetime.now(timezone.utc)
for v in ids:
db.add(Video(id=v, channel_id="UCtest", published_at=now))
db.commit()
def _playlist(db, user, name, video_ids, **kw):
pl = Playlist(user_id=user.id, name=name, **kw)
db.add(pl)
db.flush()
for i, vid in enumerate(video_ids):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=i))
db.commit()
return pl
def _ids(db, pl_id):
return [
it.video_id
for it in db.execute(
PlaylistItem.__table__.select()
.where(PlaylistItem.playlist_id == pl_id)
.order_by(PlaylistItem.position)
).all()
]
def _move(client, src, video_id, dst):
return client.post(
f"/api/playlists/{src}/items/{video_id}/move", json={"target_playlist_id": dst}
)
def test_move_leaves_the_video_in_exactly_one_list(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1", "v2"])
src = _playlist(db, user, "Source", ["v1", "v2"])
dst = _playlist(db, user, "Target", [])
r = _move(as_user(user), src.id, "v1", dst.id)
assert r.status_code == 200
assert r.json() == {
"ok": True,
"from_playlist_id": src.id,
"to_playlist_id": dst.id,
"video_id": "v1",
}
db.expire_all()
assert _ids(db, src.id) == ["v2"]
assert _ids(db, dst.id) == ["v1"]
def test_move_appends_to_the_end_of_the_target(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1", "v2", "v3"])
src = _playlist(db, user, "Source", ["v3"])
dst = _playlist(db, user, "Target", ["v1", "v2"])
assert _move(as_user(user), src.id, "v3", dst.id).status_code == 200
db.expire_all()
assert _ids(db, dst.id) == ["v1", "v2", "v3"]
def test_move_into_a_list_that_already_has_it_is_a_removal_not_a_duplicate(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", ["v1"])
dst = _playlist(db, user, "Target", ["v1"])
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
db.expire_all()
assert _ids(db, src.id) == []
assert _ids(db, dst.id) == ["v1"] # exactly one row, not two
def test_move_to_the_same_list_is_refused(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", ["v1"])
r = _move(as_user(user), src.id, "v1", src.id)
assert r.status_code == 400
assert r.json()["detail"] == {"code": "playlist_move", "reason": "same_playlist"}
db.expire_all()
assert _ids(db, src.id) == ["v1"]
def test_move_of_a_video_that_left_the_source_reports_it(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", [])
dst = _playlist(db, user, "Target", [])
r = _move(as_user(user), src.id, "v1", dst.id)
assert r.status_code == 404
assert r.json()["detail"] == {"code": "playlist_move", "reason": "not_in_playlist"}
def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as_user):
user, other = make_user(), make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Mine", ["v1"])
theirs = _playlist(db, other, "Theirs", [])
assert _move(as_user(user), src.id, "v1", theirs.id).status_code == 404
db.expire_all()
assert _ids(db, src.id) == ["v1"]
assert _ids(db, theirs.id) == []
def test_move_marks_both_linked_sides_dirty(db, make_user, as_user):
"""A move edits two YouTube mirrors at once — the source lost an item, the target gained one.
Marking only the source is the asymmetric fix this repo keeps re-learning."""
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Src", ["v1"], yt_playlist_id="PLsrc", synced_video_ids='["v1"]')
dst = _playlist(db, user, "Dst", [], yt_playlist_id="PLdst", synced_video_ids="[]")
# Baseline: both are in sync with YouTube before the move.
src.dirty = False
dst.dirty = False
db.commit()
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
db.expire_all()
assert db.get(Playlist, src.id).dirty is True
assert db.get(Playlist, dst.id).dirty is True
def test_item_edits_bump_updated_at(db, make_user, as_user):
"""The rail's "recently changed first" sort reads this. Adding, moving and reordering all
write `playlist_items` only, so nothing here is covered by the ORM's `onupdate`."""
user = make_user()
_seed_videos(db, ["v1", "v2"])
src = _playlist(db, user, "Source", ["v1", "v2"])
dst = _playlist(db, user, "Target", [])
old = datetime.now(timezone.utc) - timedelta(days=3)
src.updated_at = old
dst.updated_at = old
db.commit()
client = as_user(user)
assert client.post(f"/api/playlists/{dst.id}/items", json={"video_id": "v1"}).status_code == 200
db.expire_all()
assert db.get(Playlist, dst.id).updated_at > old
dst.updated_at = old
db.commit()
assert (
client.put(f"/api/playlists/{src.id}/order", json={"video_ids": ["v2", "v1"]}).status_code
== 200
)
db.expire_all()
assert db.get(Playlist, src.id).updated_at > old
src.updated_at = old
dst.updated_at = old
db.commit()
assert _move(client, src.id, "v2", dst.id).status_code == 200
db.expire_all()
# BOTH sides of a move are "recently changed" — the target is where the reader will look.
assert db.get(Playlist, src.id).updated_at > old
assert db.get(Playlist, dst.id).updated_at > old
def test_the_list_endpoint_publishes_updated_at(db, make_user, as_user):
user = make_user()
_playlist(db, user, "Solo", [])
rows = as_user(user).get("/api/playlists").json()
assert len(rows) == 1
assert rows[0]["updated_at"] # the rail cannot sort by a field the API never sends
+280
View File
@@ -0,0 +1,280 @@
"""E12 · S5 — the admin-issued set-password link.
One mechanism answering three things: SEC-15 (a no-SMTP instance auto-verifies every registration, so
a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address stays takeable until
its owner resets, and reset needs working mail), and the everyday "no SMTP and someone forgot theirs".
The property that must not regress is the one that makes it honest: an admin-issued link proves
NOTHING about the mailbox — the admin hands it over by whatever means they like — so it must not
confirm the address the way a mailed reset does.
"""
from app.models import AuthToken
def _link(client_admin, user_id: int):
r = client_admin.post(f"/api/admin/users/{user_id}/set-password-link")
assert r.status_code == 200, r.text
return r.json()["url"].split("#reset=", 1)[1]
def test_the_link_sets_a_password_without_confirming_the_address(db, as_user, make_user, client):
"""The whole point of a separate token kind. A mailed reset arrives IN the mailbox, so completing
it proves ownership and confirms the address. An admin-issued link does not, and claiming
otherwise would let an admin confirm an address nobody ever proved."""
admin = make_user(role="admin")
target = make_user(email_verified=False)
db.commit()
token = _link(as_user(admin), target.id)
r = client.post(
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
)
assert r.status_code == 200, r.text
db.refresh(target)
assert target.password_hash is not None
assert target.email_verified is False # the address is still unproven — and says so
def test_a_mailed_reset_still_confirms_the_address(db, as_user, make_user, client):
"""The mutation check for the test above: if `email_verified` were simply never set, this would
fail — and the rescue path for "registered but never clicked the link" would be gone."""
from datetime import timedelta
from app.routes.auth import _issue_token
user = make_user(email_verified=False)
db.commit()
token = _issue_token(db, user, "reset", timedelta(hours=1))
r = client.post(
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
)
assert r.status_code == 200, r.text
db.refresh(user)
assert user.email_verified is True
def test_using_the_link_evicts_whoever_held_the_old_password(db, as_user, make_user, client):
"""SEC-16's actual remedy: the squatter's session dies the moment the real owner sets a password.
The epoch bump is what does it, and it is the reason this reuses the reset confirm path rather
than writing a second one that could forget to bump."""
target = make_user()
db.commit()
before = target.session_epoch
admin = make_user(role="admin")
db.commit()
token = _link(as_user(admin), target.id)
client.post(
"/auth/password-reset/confirm", json={"token": token, "password": "the owner's own choice"}
)
db.refresh(target)
assert target.session_epoch != before
def test_issuing_does_not_touch_the_current_password(db, as_user, make_user):
"""An admin misclick must not lock anyone out. Issuing is an offer; only USING it changes
anything — which is also why it is safe to offer on a perfectly healthy account."""
target = make_user()
db.commit()
original = target.password_hash
admin = make_user(role="admin")
db.commit()
_link(as_user(admin), target.id)
db.refresh(target)
assert target.password_hash == original
assert target.password_revoked_at is None
def test_the_link_is_single_use(db, as_user, make_user, client):
target = make_user()
admin = make_user(role="admin")
db.commit()
token = _link(as_user(admin), target.id)
first = client.post(
"/auth/password-reset/confirm", json={"token": token, "password": "first passphrase here"}
)
second = client.post(
"/auth/password-reset/confirm", json={"token": token, "password": "second passphrase here"}
)
assert first.status_code == 200
assert second.status_code == 400
def test_a_new_link_burns_the_outstanding_ones(db, as_user, make_user, client):
"""Spending any password token burns the siblings of BOTH kinds. Without that, a spent reset
would leave a live admin link behind — a second key to a door someone just changed the lock on."""
target = make_user()
admin = make_user(role="admin")
db.commit()
first = _link(as_user(admin), target.id)
second = _link(as_user(admin), target.id)
ok = client.post(
"/auth/password-reset/confirm", json={"token": second, "password": "the newer passphrase"}
)
assert ok.status_code == 200
stale = client.post(
"/auth/password-reset/confirm", json={"token": first, "password": "the older passphrase"}
)
assert stale.status_code == 400
def test_the_probe_recognises_an_admin_link(db, as_user, make_user, client):
"""`/auth/password-reset/check` is what stops the card drawing a form for a dead link. It peeked
only at `reset` tokens, so without teaching it the new kind every admin-issued link would open
onto "this link is spent"."""
target = make_user()
admin = make_user(role="admin")
db.commit()
token = _link(as_user(admin), target.id)
r = client.post("/auth/password-reset/check", json={"token": token})
assert r.json() == {"ok": True}
def test_only_an_admin_can_mint_one(db, as_user, make_user):
plain = make_user()
victim = make_user()
db.commit()
r = as_user(plain).post(f"/api/admin/users/{victim.id}/set-password-link")
assert r.status_code in (401, 403)
assert db.query(AuthToken).filter(AuthToken.kind == "setpw").count() == 0
def test_the_shared_demo_account_is_refused(db, as_user, make_user):
"""The demo account is shared, so handing anyone a way to change its password hands them
everyone else's session on it."""
admin = make_user(role="admin")
demo = make_user(is_demo=True)
db.commit()
r = as_user(admin).post(f"/api/admin/users/{demo.id}/set-password-link")
assert r.status_code == 400
assert r.json()["detail"]["reason"] == "demo"
def test_the_access_panel_reports_the_gates(db, as_user, make_user):
admin = make_user(role="admin")
target = make_user(email_verified=False, is_active=False)
db.commit()
body = as_user(admin).get(f"/api/admin/users/{target.id}/access").json()
assert body["email_verified"] is False
assert body["is_active"] is False
assert set(body) >= {
"is_allowed",
"can_activate",
"email_configured",
"password_revoked_at",
"google_email",
"has_password",
"has_google",
}
def test_the_panel_names_a_google_address_only_when_it_differs(db, as_user, make_user):
""" "Connected" and "connected as someone else" are different statements, and only the second one
explains a confusing sign-in. Repeating the account's own address would be noise on every row."""
admin = make_user(role="admin")
same = make_user(email="same@example.com")
same.google_email = "same@example.com"
other = make_user(email="typed@example.com")
other.google_email = "google-primary@example.com"
db.commit()
c = as_user(admin)
assert c.get(f"/api/admin/users/{same.id}/access").json()["google_email"] is None
assert (
c.get(f"/api/admin/users/{other.id}/access").json()["google_email"]
== "google-primary@example.com"
)
def test_the_link_is_audited_without_recording_the_link(db, as_user, make_user):
"""The audit log is read by humans and kept long; a live credential-setting link is the last
thing that belongs in it. That one was ISSUED, by whom, for whom, is the auditable fact."""
from app.models import AuditLog
admin = make_user(role="admin")
target = make_user()
db.commit()
url = as_user(admin).post(f"/api/admin/users/{target.id}/set-password-link").json()["url"]
secret = url.split("#reset=", 1)[1]
rows = db.query(AuditLog).filter(AuditLog.action == "user_setpw_link").all()
assert len(rows) == 1
assert rows[0].summary == target.email
assert secret not in str(rows[0].summary) + str(rows[0].before) + str(rows[0].after)
def test_a_google_only_account_can_be_given_a_password(db, as_user, make_user, client):
"""Deliberately not gated on the account's state: the admin's real case is whichever one it is,
and narrowing this would only make it refuse exactly then."""
admin = make_user(role="admin")
google_only = make_user(password=None)
db.commit()
assert google_only.password_hash is None
token = _link(as_user(admin), google_only.id)
r = client.post(
"/auth/password-reset/confirm", json={"token": token, "password": "now it has one too"}
)
assert r.status_code == 200
db.refresh(google_only)
assert google_only.password_hash is not None
def test_an_unknown_user_is_a_404(db, as_user, make_user):
admin = make_user(role="admin")
db.commit()
assert as_user(admin).post("/api/admin/users/999999/set-password-link").status_code == 404
assert as_user(admin).get("/api/admin/users/999999/access").status_code == 404
def test_the_issued_token_is_stored_hashed(db, as_user, make_user):
"""Same rule as every other token here: the DB holds a hash, so a database read cannot hand
someone a working link."""
admin = make_user(role="admin")
target = make_user()
db.commit()
raw = _link(as_user(admin), target.id)
rows = db.query(AuthToken).filter(AuthToken.kind == "setpw").all()
assert len(rows) == 1
assert rows[0].token_hash != raw
assert raw not in rows[0].token_hash
def test_the_link_outlives_the_one_hour_reset_window(db, as_user, make_user):
"""It travels out of band — the admin has to actually reach the person — so the mailed reset's
one hour is far too short."""
from datetime import datetime, timedelta, timezone
admin = make_user(role="admin")
target = make_user()
db.commit()
_link(as_user(admin), target.id)
row = db.query(AuthToken).filter(AuthToken.kind == "setpw").one()
assert row.expires_at - datetime.now(timezone.utc) > timedelta(hours=2)
def test_users_listing_still_serializes(db, as_user, make_user):
"""The list gained two fields; this pins that it did not gain the two per-row QUERIES the panel
endpoint exists to keep out of it."""
admin = make_user(role="admin")
db.commit()
rows = as_user(admin).get("/api/admin/users").json()
assert rows and "password_revoked_at" in rows[0] and "google_email" in rows[0]
assert "can_activate" not in rows[0]
+99
View File
@@ -0,0 +1,99 @@
"""SEC-12 · the download URL guard.
The Download Center hands a user-supplied URL to yt-dlp on the worker, so the server fetches
whatever the user typed. These pin that a URL resolving into our own network is refused, that the
refusal reasons stay distinct (an unresolvable host is not the same message as a blocked one), and
that a normal public URL still goes through.
DNS is stubbed rather than live: the point under test is the address JUDGEMENT, and a test that
resolves real names would be slow, offline-fragile, and would silently start passing if the guard
stopped resolving at all.
"""
import pytest
from app.downloads import urlguard
def _stub_dns(monkeypatch, addrs):
"""Make every lookup answer with `addrs` (a list of textual IPs)."""
def fake_getaddrinfo(host, port, *a, **kw):
return [(None, None, None, "", (addr, port or 0)) for addr in addrs]
monkeypatch.setattr(urlguard.socket, "getaddrinfo", fake_getaddrinfo)
def _blocked_reason(url):
with pytest.raises(urlguard.BlockedUrl) as e:
urlguard.check_url(url)
return e.value.reason
@pytest.mark.parametrize(
"addr",
[
"127.0.0.1", # loopback
"10.1.2.3", # RFC1918
"192.168.0.5", # RFC1918
"172.16.9.9", # RFC1918
"169.254.169.254", # link-local — the cloud metadata endpoint
"0.0.0.0", # unspecified
"::1", # IPv6 loopback
"fe80::1", # IPv6 link-local
"fc00::1", # IPv6 unique-local
"::ffff:127.0.0.1", # loopback wearing an IPv6 hat
],
)
def test_addresses_on_our_own_network_are_refused(monkeypatch, addr):
_stub_dns(monkeypatch, [addr])
assert _blocked_reason("http://whatever.example/video") == urlguard.REASON_PRIVATE
def test_one_bad_answer_in_a_round_robin_blocks_the_whole_host(monkeypatch):
# A host answering with both a public and an internal address must not become a coin flip:
# yt-dlp would pick for itself, so the guard refuses if ANY answer is forbidden.
_stub_dns(monkeypatch, ["93.184.216.34", "127.0.0.1"])
assert _blocked_reason("https://mixed.example/v") == urlguard.REASON_PRIVATE
def test_a_public_address_passes(monkeypatch):
_stub_dns(monkeypatch, ["93.184.216.34"])
urlguard.check_url("https://example.com/watch?v=abc") # does not raise
def test_unresolvable_is_its_own_reason(monkeypatch):
import socket as _socket
def boom(*a, **kw):
raise _socket.gaierror("nope")
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
# Distinct from PRIVATE on purpose: "we couldn't look that up" and "we refuse to fetch that"
# are different things to tell a user, and the frontend renders them differently.
assert _blocked_reason("https://nx.example/v") == urlguard.REASON_UNRESOLVABLE
@pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://host/x", "gopher://host/1"])
def test_non_http_schemes_are_refused_before_any_lookup(monkeypatch, url):
def boom(*a, **kw): # a scheme rejection must not have resolved anything first
raise AssertionError("DNS was consulted for a non-http scheme")
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
assert _blocked_reason(url) == urlguard.REASON_SCHEME
def test_a_url_without_a_host_is_refused():
assert _blocked_reason("http:///just/a/path") == urlguard.REASON_NO_HOST
def test_a_malformed_url_is_a_refusal_not_a_crash(monkeypatch):
# Both shapes raise ValueError LATE — `urlparse` on an unclosed bracket, `.port` only when the
# property is READ — and `.hostname` happily returns first. Unguarded, either turns a user typo
# into a 500 that escapes the caller's `except BlockedUrl` and loses the localized detail this
# module exists to produce.
_stub_dns(monkeypatch, ["93.184.216.34"])
assert _blocked_reason("http://example.com:99999/v.mp4") == urlguard.REASON_MALFORMED
assert _blocked_reason("http://example.com:abc/v.mp4") == urlguard.REASON_MALFORMED
assert _blocked_reason("http://[::1/v.mp4") == urlguard.REASON_MALFORMED
assert _blocked_reason("http://[example.com]/v.mp4") == urlguard.REASON_MALFORMED
+12
View File
@@ -53,6 +53,11 @@ services:
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
# named volume — harmless, and the Plex module is off by default.
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
security_opt:
- no-new-privileges:true
cap_drop:
@@ -74,6 +79,9 @@ services:
worker:
image: git.b1fr0st.eu/peter/siftlode:${IMAGE_TAG:-latest}
command: ["python", "-m", "app.worker"]
# tini as PID 1: reaps orphaned ffmpeg/yt-dlp grandchildren when the watchdog hard-kills a
# download child's process group (the worker reaps its own direct children via subprocess.wait).
init: true
env_file:
- .env
environment:
@@ -93,6 +101,8 @@ services:
networks: [internal]
volumes:
- ${DOWNLOAD_HOST_PATH:-siftlode_downloads}:/downloads
# Same read-only Plex library as the API — the worker resolves library paths too.
- ${PLEX_MEDIA_HOST_PATH:-siftlode_plex_media}:/plex-media:ro
security_opt:
- no-new-privileges:true
cap_drop:
@@ -114,6 +124,8 @@ services:
volumes:
siftlode_pgdata:
siftlode_downloads:
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
siftlode_plex_media:
networks:
internal:
+12
View File
@@ -38,6 +38,11 @@ services:
# Downloaded media. Defaults to a Docker-managed named volume; set DOWNLOAD_HOST_PATH in .env
# to a host directory (e.g. one your Plex server reads) to keep the Plex-style tree there.
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
# Plex library, read-only. The Plex module streams files itself, so it needs to SEE them: set
# PLEX_MEDIA_HOST_PATH in .env to the host directory holding the library (the local side of the
# admin "path map", whose default is /data=/plex-media). Left unset it falls back to an empty
# named volume — harmless, and the Plex module is off by default.
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/healthz').status == 200 else 1)"]
interval: 15s
@@ -57,6 +62,9 @@ services:
GIT_SHA: ${GIT_SHA:-unknown}
BUILD_DATE: ${BUILD_DATE:-}
command: ["python", "-m", "app.worker"]
# tini as PID 1: reaps orphaned ffmpeg/yt-dlp grandchildren when the watchdog hard-kills a
# download child's process group (the worker reaps its own direct children via subprocess.wait).
init: true
env_file: .env
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-siftlode}:${POSTGRES_PASSWORD:-siftlode}@db:5432/${POSTGRES_DB:-siftlode}
@@ -73,6 +81,8 @@ services:
condition: service_started
volumes:
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
# Same read-only Plex library as the API — the worker resolves library paths too.
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
restart: unless-stopped
# PO-token provider sidecar: mints YouTube Proof-of-Origin tokens so the worker beats bot-detection
@@ -85,3 +95,5 @@ services:
volumes:
pgdata:
downloads:
# Empty fallback for the Plex media mount when PLEX_MEDIA_HOST_PATH is unset (Plex module off).
plex_media:
+188 -7
View File
@@ -115,13 +115,34 @@ DOWNLOAD_HOST_PATH=/mnt/media/youtube
The directory must be writable by the container user (uid `1000`): `sudo chown -R 1000:1000 <dir>`.
## Plex library (optional)
If you run a **Plex** server, Siftlode can browse and play your movies and shows beside your YouTube
feed. Two things have to line up:
1. **Server URL + token**, entered as admin under **Configuration → Plex**. That is how Siftlode reads
your libraries.
2. **The media files themselves**, mounted into the container read-only. Siftlode streams the files
directly rather than proxying Plex, so it has to be able to open them.
Point `PLEX_MEDIA_HOST_PATH` at the directory holding your library and re-run `up -d`:
```bash
PLEX_MEDIA_HOST_PATH=/mnt/media
```
It appears inside the container at `/plex-media`, read-only. Siftlode then rewrites the paths Plex
reports into that mount using the **path map** on the same admin page — its default, `/data=/plex-media`,
means "wherever Plex says `/data/movies/…`, look in `/plex-media/movies/…`". Set the left-hand side to
whatever prefix your Plex server uses, and mount the matching host directory so the rest of the path
still lines up.
Leave `PLEX_MEDIA_HOST_PATH` unset if you don't use Plex — the mount falls back to an empty volume and
the module stays off.
## Day-to-day
```bash
# Update to the latest release
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
# Logs / status (api = web, worker = downloads/edits)
docker compose -f docker-compose.selfhost.yml logs -f api
docker compose -f docker-compose.selfhost.yml logs -f worker
@@ -131,6 +152,166 @@ docker compose -f docker-compose.selfhost.yml ps
docker compose -f docker-compose.selfhost.yml down
```
Your data (accounts, subscriptions, playlists, the video catalog) lives in the `siftlode_pgdata`
Docker volume — back that up to keep your instance's state. Database migrations run automatically
when the app starts, so updating is just pull + up.
## Backups
**A backup is three things, not one.** Copying only the database gets you an instance that starts and
then cannot read its own secrets.
1. **The database** — accounts, subscriptions, playlists, the video catalog, and every setting you
made in the admin pages. It lives in the `siftlode_pgdata` Docker volume; dump it with the command
below.
2. **`.env`** — in particular `TOKEN_ENCRYPTION_KEY`. Your Google client secret, SMTP password, Plex
token and every stored refresh token are encrypted with it *inside* the database. Restore a dump
against a different key and those values are lost — you would have to clear the affected rows from
`system_config` and re-enter every secret by hand. Keep `.env` beside the dump, and just as private.
3. **Downloaded media** *(only if you use the Download Center)* — the `siftlode_downloads` volume, or
whatever `DOWNLOAD_HOST_PATH` points at. Not needed to bring the instance back; the jobs and their
metadata are in the database, the files themselves are not.
Dump and restore the database with these two commands. They also exist as scripts
(`scripts/backup.sh` + `scripts/restore.sh`, or `backup.ps1` / `restore.ps1` on Windows) if you have
the source checkout:
If you customised `POSTGRES_USER` / `POSTGRES_DB` in `.env`, substitute them for `siftlode` below —
the scripts read them from the environment, these one-liners cannot.
```bash
# Back up. Write to a .part name and rename only on success: redirecting straight into the final
# name creates the file BEFORE pg_dump runs, so a failed dump leaves a plausible-looking 0-byte
# file in backups/ that you will not discover until the day you need it.
mkdir -p backups && out="backups/siftlode-$(date +%Y%m%d-%H%M%S).dump" && \
docker compose -f docker-compose.selfhost.yml exec -T db \
pg_dump -U siftlode -Fc siftlode > "$out.part" \
&& [ -s "$out.part" ] && mv "$out.part" "$out" && echo "wrote $out" \
|| { rm -f "$out.part"; echo "BACKUP FAILED" >&2; false; }
# Restore. Stop the app first: pg_restore --clean DROPs each object in its own transaction, and a
# running api holds locks that make those DROPs queue — abort the wait and you are left with a
# half-dropped database. Restoring with the app down also means nothing writes into a schema that
# is only partly back.
docker compose -f docker-compose.selfhost.yml stop api worker
docker compose -f docker-compose.selfhost.yml exec -T db \
pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-<timestamp>.dump
# Start it again only once the restore reported no errors — and, if you are rolling a release back,
# only AFTER pinning the older IMAGE_TAG. Every start runs `alembic upgrade head`, so bringing the
# newer image back here would re-apply the migrations you just restored away. Use `up -d`, not
# `start`: `start` relaunches the existing container, so it ignores the tag you just pinned and
# comes back on the image you are rolling back FROM.
docker compose -f docker-compose.selfhost.yml up -d
```
On Windows, do **not** pipe the dump through PowerShell — it corrupts binary data. Write it inside the
container and copy it out instead. Check the exit code between the two steps and use a timestamped
name: the dump inside the container keeps its name, so a `cp` that runs after a *failed* dump would
copy out the previous run's file and quietly overwrite your only backup with a stale one.
Paste it as ONE block. `throw` only aborts the statement it is in, so as separate pasted lines the
copy would still run after a failed dump — and copy out the previous run's file:
```powershell
& {
# Inside the block, because a scriptblock inherits the caller's preference — under a profile that
# sets SilentlyContinue, every `throw` below would be swallowed and the block would run on.
$ErrorActionPreference = "Stop"
$compose = "docker-compose.selfhost.yml"
New-Item -ItemType Directory -Force -Path backups | Out-Null
$out = "backups\siftlode-$(Get-Date -Format yyyyMMdd-HHmmss).dump"
docker compose -f $compose exec -T db pg_dump -U siftlode -Fc siftlode -f /tmp/s.dump
$code = $LASTEXITCODE
# Remove the in-container dump on the failure path too: leaving it there is how a LATER failed run
# copies out this run's file and overwrites a good backup with a stale one.
if ($code -ne 0) { docker compose -f $compose exec -T db rm -f /tmp/s.dump; throw "BACKUP FAILED - pg_dump returned $code" }
docker compose -f $compose cp db:/tmp/s.dump $out
$code = $LASTEXITCODE
docker compose -f $compose exec -T db rm -f /tmp/s.dump
# Remove the partial file too: `cp` streams, so a mid-stream failure leaves a truncated dump
# under the final name — the plausible-looking bad backup the .part dance above avoids.
if ($code -ne 0) { Remove-Item $out -ErrorAction SilentlyContinue; throw "BACKUP FAILED - could not copy the dump out (exit $code)" }
if ((Get-Item $out).Length -eq 0) { Remove-Item $out; throw "BACKUP FAILED - the dump came out empty" }
"wrote $out"
}
```
## Upgrading
**Take a backup first.** Every start runs any new database migrations automatically, and a migration
is not reversible by pulling the old image back — see rollback below.
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```
That tracks `latest`, which moves whenever a release is cut. **To decide for yourself when you move,
pin a version** by putting it in `.env`:
```bash
IMAGE_TAG=0.80.0
```
Then `pull` + `up -d` stays on that version until you change the line. Released versions and what
changed in them are listed in the app itself, under **Release Notes** in the account menu, and in
[`CHANGELOG.md`](../CHANGELOG.md).
**The registry keeps only the newest few releases, not every version the changelog lists.** A pin is
"decide when you take the next hop", not "stay here forever": once you are several releases behind,
your pinned tag can no longer be pulled — which matters the day you rebuild the host, prune your
Docker images, or move to a new machine. A container that is already running is unaffected (Docker
keeps the image locally), and you can always go back to `latest` or build the image yourself from the
repository. If you want to sit on one version long term, keep your own copy:
`docker save git.b1fr0st.eu/peter/siftlode:0.80.0 -o siftlode-0.80.0.tar`.
### Rollback
If a release misbehaves, set `IMAGE_TAG` back to the previous version and `up -d`. That is enough
**as long as the release did not change the database schema**. If it did, the older image will refuse
to start against the newer schema.
**Restoring your pre-upgrade dump is the recommended way out of that** — it puts the database back
exactly where the old image expects it, in one step, with no revision arithmetic:
```bash
# 1. stop the app (leave the database up)
docker compose -f docker-compose.selfhost.yml stop api worker
# 2. restore the dump you took before upgrading (see Backups above). Leave the app stopped:
# starting it here would run the NEWER image's migrations against the database you just restored.
docker compose -f docker-compose.selfhost.yml exec -T db \
pg_restore -U siftlode -d siftlode --clean --if-exists < backups/siftlode-<timestamp>.dump
# 3. pin the previous version in .env, then start again
docker compose -f docker-compose.selfhost.yml up -d
```
If you have no usable dump, you can step the schema back instead — but **not with `alembic downgrade
-1`**. One release routinely ships several migrations (0.80.0 shipped four), so `-1` undoes only the
newest one and leaves the schema at a revision the older image *also* cannot resolve: you lose that
migration's data and still get a crash loop. Downgrade to the revision the target version expects,
which that version can tell you itself:
```bash
# 1. stop the app (leave the database up)
docker compose -f docker-compose.selfhost.yml stop api worker
# 2. ask the version you are rolling back TO which schema revision it expects
IMAGE_TAG=0.79.0 docker compose -f docker-compose.selfhost.yml run --rm api alembic heads
# 3. step the schema back to exactly that revision — run this with the CURRENT (newer) image,
# because it is the one that ships the migrations being undone
docker compose -f docker-compose.selfhost.yml run --rm api alembic downgrade <revision from step 2>
# 4. pin the previous version in .env, then start again
docker compose -f docker-compose.selfhost.yml up -d
```
A downgrade drops the columns the newer version added, so anything stored only in those columns is
gone. That is the price of not having the dump.
### A migration failed on start
The container will keep restarting (`restart: unless-stopped`), and `worker` will sit waiting for an
`api` that never becomes healthy. Read the reason first:
```bash
docker compose -f docker-compose.selfhost.yml logs api | tail -50
```
Then either fix the cause and let it retry, or roll back: restore your pre-upgrade dump, pin the
previous `IMAGE_TAG`, and `up -d`. Do not delete the `siftlode_pgdata` volume to "start clean" unless
you have a dump you have actually tested restoring — that volume *is* your instance.
+42
View File
@@ -67,5 +67,47 @@ export default tseslint.config(
],
},
},
{
// E10 S1: `components/ui/` is the PLATFORM layer, and the rule that makes it a layer rather than
// a folder is enforced here, not in prose (see components/ui/README.md). A platform component is
// domain-free: it may import sibling primitives and generic `lib/` helpers, never a feature
// component (`../Feed`) and never a domain API slice. Without this rule the boundary erodes on
// the first "just this once" import and the directory silently becomes another components/.
files: ["src/components/ui/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
// NOTE the String.raw: these are REGEX sources, and in a plain "..." literal `\.` is an
// unknown escape that collapses to a bare `.` — the any-char wildcard. The first cut of
// this rule shipped that way and matched far less than it read like (E10 review round 1).
patterns: [
{
// `../X` (exactly one level up = a feature component). A `regex` rather than a glob:
// eslint matches `patterns` gitignore-style, where `../*` also swallows `../../lib/x`.
regex: String.raw`^\.\./(?!\.\./)`,
message:
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
},
{
// The same file by its OTHER spelling. `../../components/Feed` resolves exactly where
// `../Feed` does, so the rule above is only half the boundary; `ui/` is carved out
// because a sibling primitive is the one thing this path may legitimately name.
regex: String.raw`^\.\./\.\./components/(?!ui/)`,
message:
"components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.",
},
{
// `(/|$)` so the BARREL counts too: `../../lib/api` is the spelling every feature
// component uses, and a `/`-anchored pattern let exactly that one through.
regex: String.raw`^\.\./\.\./lib/api(/|$)`,
message:
"components/ui/ is domain-free: a platform component may not call a domain API slice. Take the data as props.",
},
],
},
],
},
},
prettier,
);
+1
View File
@@ -1,6 +1,7 @@
{
"name": "siftlode-frontend",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
"dev": "vite",
+17 -4
View File
@@ -29,12 +29,15 @@ import Welcome from "./components/Welcome";
import SetupWizard from "./components/SetupWizard";
import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import BackToTop from "./components/BackToTop";
import PageShell from "./components/PageShell";
import BackToTop from "./components/ui/BackToTop";
import PageShell from "./components/ui/PageShell";
import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster";
import { StateMessage } from "./components/QueryState";
import Toaster from "./components/ui/Toaster";
import KeyboardHelp from "./components/ui/KeyboardHelp";
import AppCommandPalette from "./components/AppCommandPalette";
import { useGlobalKeymap } from "./lib/keymap";
import { StateMessage } from "./components/ui/QueryState";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
@@ -134,6 +137,11 @@ export default function App() {
refetchInterval: (query) => (query.state.data ? 60_000 : false),
});
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette. Bound only once
// signed in, because that is where both overlays are mounted (bottom of this tree) — see
// useGlobalKeymap on why binding it above the signed-out branch is worse than not binding it.
useGlobalKeymap(!!meQuery.data);
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
// an account (it was riding the session's default) would silently swap identity when ANOTHER
// tab changes the default — e.g. that other tab adds/switches an account. Once pinned, this
@@ -412,6 +420,11 @@ export default function App() {
(collapsed or expanded) without tracking its width. */}
<Toaster />
</div>
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K).
Mounted once, outside the page area, so every surface answers "what can I press here" and
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
<KeyboardHelp />
<AppCommandPalette />
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
<Suspense fallback={null}>
{aboutOpen && (
+4 -6
View File
@@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next";
import { Info, Sparkles } from "lucide-react";
import { api } from "../lib/api";
import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version";
import Modal from "./Modal";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
function fmtDate(d?: string | null): string {
if (!d) return "—";
@@ -70,12 +71,9 @@ export default function About({
))}
</div>
<button
onClick={onOpenReleaseNotes}
className="mt-4 w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<Button variant="primary" size="lg" className="mt-4 w-full" onClick={onOpenReleaseNotes}>
<Sparkles className="w-4 h-4" /> {t("about.whatsNew")}
</button>
</Button>
</Modal>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
/**
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
+252
View File
@@ -0,0 +1,252 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react";
import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
// The admin's answer to "why can this person not get in?", and the place every recovery action for
// one account lives (E12 · S5, design option C).
//
// It exists because nothing in the admin UI showed an account's GATE STATE: an admin could see a
// "Pending approval" chip but not whether the address was confirmed, whether the password had been
// cleared by SEC-10, or which Google account was attached. Every support conversation started by
// guessing. The recovery actions sit under that readout rather than in the row, so the admin picks
// one having read what is actually wrong.
/** One line of the Access readout: an icon, a label, and the state in the tone that state deserves. */
function AccessRow({
icon: Icon,
label,
value,
tone = "muted",
}: {
icon: typeof Mail;
label: string;
value: string;
tone?: "muted" | "warn" | "ok";
}) {
const toneCls = tone === "warn" ? "text-warn" : tone === "ok" ? "text-success" : "text-muted";
return (
<div className="flex items-center gap-2 text-xs">
<Icon className={`w-3.5 h-3.5 shrink-0 ${toneCls}`} />
<span className="text-muted w-28 shrink-0">{label}</span>
<span className={`${toneCls} min-w-0 truncate`}>{value}</span>
</div>
);
}
export default function AccountPanel({
user,
onClose,
}: {
user: AdminUserRow;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
const [copied, setCopied] = useState(false);
const q = useQuery({
queryKey: qk.adminUserAccess(user.id),
queryFn: () => api.adminUserAccess(user.id),
});
// Every action here goes through a `quiet: true` endpoint, which suppresses the GLOBAL error
// dialog on the promise that the caller reports the failure inline. Without an `onError` that
// promise is broken and a refusal renders as nothing at all — on the broken-SMTP instance this
// panel exists for, the admin would read silence as "the mail went out".
const issue = useMutation({
mutationFn: () => api.createSetPasswordLink(user.id),
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
onError: () => notify({ level: "error", message: t("users.account.linkFailed") }),
});
const resend = useMutation({
mutationFn: () => api.resendUserVerification(user.id),
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
onError: (err) =>
notify({
level: "error",
message: isEmailOff(err)
? t("users.pendingAccess.resendEmailOff")
: t("users.pendingAccess.resendFailed"),
}),
});
const activate = useMutation({
mutationFn: () => api.activateUser(user.id),
onSuccess: () => invalidateUserAccess(qc, user.id),
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
});
const copy = async () => {
if (!link) return;
// Via the shared helper, whose synchronous `execCommand` path is what makes copying work on a
// plain-http LAN origin — where `navigator.clipboard` is undefined, and which is exactly the
// minimal self-host this no-email link was built for.
if (await copyText(link.url)) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
return;
}
// The link is on screen and selectable, so a failed copy is a convenience that failed — not the
// action failing. Say so rather than leaving the button looking inert.
notify({ level: "warning", message: t("users.account.copyFailed") });
};
const a: AdminUserAccess | undefined = q.data;
return (
<Modal
title={user.display_name ?? user.email.split("@")[0]}
onClose={onClose}
placement="drawer"
maxWidth="max-w-md"
>
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
{q.isError && !a ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : !a ? (
<LoadingState />
) : (
<>
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
{t("users.account.access")}
</div>
<div className="glass-card rounded-xl p-3 space-y-2 mb-5">
<AccessRow
icon={Mail}
label={t("users.account.email")}
value={
a.email_verified
? t("users.account.emailConfirmed")
: t("users.account.emailUnconfirmed")
}
tone={a.email_verified ? "ok" : "warn"}
/>
<AccessRow
icon={Clock}
label={t("users.account.approval")}
value={
a.is_active
? t("users.account.approvalDone")
: a.can_activate
? t("users.account.approvalWaitingOnYou")
: t("users.account.approvalNotRequested")
}
tone={a.is_active ? "ok" : "warn"}
/>
<AccessRow
icon={KeyRound}
label={t("users.account.password")}
value={
a.has_password
? t("users.account.passwordSet")
: a.password_revoked_at
? t("users.account.passwordRevoked")
: t("users.account.passwordNone")
}
// Not having a password is only a PROBLEM when it was taken away; a Google-only
// account never had one and is working exactly as intended.
tone={a.has_password ? "ok" : a.password_revoked_at ? "warn" : "muted"}
/>
<AccessRow
icon={Globe}
label={t("users.account.google")}
value={
!a.has_google
? t("users.account.googleNone")
: a.google_email
? t("users.account.googleAs", { email: a.google_email })
: t("users.account.googleConnected")
}
tone={a.has_google ? "ok" : "muted"}
/>
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
{!a.is_allowed && (
<AccessRow
icon={ShieldAlert}
label={t("users.account.invite")}
value={t("users.account.inviteMissing")}
tone="warn"
/>
)}
</div>
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
{t("users.account.recovery")}
</div>
<div className="space-y-2">
{a.can_activate && (
<button
onClick={() => activate.mutate()}
disabled={activate.isPending}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
>
<Check className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">{t("users.account.activate")}</span>
</button>
)}
<button
onClick={() => resend.mutate()}
disabled={!a.email_configured || a.email_verified || resend.isPending}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
>
<Send className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">{t("users.account.resend")}</span>
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
whole reason the link below exists, so naming it here is the explanation. */}
{!a.email_configured && (
<span className="text-muted">{t("users.account.mailOff")}</span>
)}
{a.email_configured && a.email_verified && (
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
)}
</button>
<button
onClick={() => issue.mutate()}
disabled={issue.isPending || user.is_demo}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
>
<KeyRound className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
</span>
</button>
</div>
{link && (
<div className="mt-4 pt-4 border-t border-border space-y-2">
<div className="flex gap-2">
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
{link.url}
</div>
<button
onClick={copy}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs border border-accent/40 text-accent transition"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? t("users.account.copied") : t("users.account.copy")}
</button>
</div>
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
</div>
)}
</>
)}
</Modal>
);
}
-78
View File
@@ -1,78 +0,0 @@
import { useState, type ReactNode } from "react";
import { MoreHorizontal } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
// fire-and-close (`onSelect`); a `disabled` item (e.g. a mutation already in flight) is inert.
export interface ActionMenuItem {
id: string;
label: string;
icon?: ReactNode; // an element (so a caller can swap in a spinning icon while pending)
onSelect: () => void;
disabled?: boolean;
title?: string; // optional native tooltip (carries a longer hint the label alone can't)
}
export default function ActionMenu({
items,
ariaLabel,
className,
trigger,
}: {
items: readonly ActionMenuItem[];
/** Accessible name for the trigger (e.g. "Catalog actions"). */
ariaLabel: string;
/** Trigger className override; omit for the default glass ⋯ icon button. */
className?: string;
/** Custom trigger content; omit for the default ⋯ icon. */
trigger?: ReactNode;
}) {
const [open, setOpen] = useState(false);
// A disabled item (a mutation already in flight) is skipped by roving keyboard nav automatically:
// PopoverItem renders it as a native `disabled` button, which useListNavigation reads and never
// focuses. No explicit disabledIndices needed. See ActionMenu.dom.test.tsx for the guard.
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={ariaLabel}
title={ariaLabel}
className={
className ??
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
}
>
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
</button>
<PopoverSurface
popover={pop}
ariaLabel={ariaLabel}
className="glass-menu min-w-52 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
{items.map((it) => (
<PopoverItem
key={it.id}
role="menuitem"
disabled={it.disabled}
label={it.label}
title={it.title}
onSelect={() => {
it.onSelect();
setOpen(false);
}}
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm outline-hidden transition disabled:opacity-50"
>
{it.icon && <span className="shrink-0 text-muted">{it.icon}</span>}
<span className="flex-1 truncate">{it.label}</span>
</PopoverItem>
))}
</PopoverSurface>
</>
);
}
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
import { Filter, X } from "lucide-react";
import type { ActiveFilter } from "../lib/useActiveFilters";
import { decideChipLayout } from "../lib/chipOverflow";
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/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
@@ -172,7 +172,7 @@ export default function ActiveFilterChips({
<div
ref={laneRef}
data-testid="active-filter-chips"
className={`relative flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
className={`${chipLaneClass} flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
aria-label={t("feed.chips.label")}
>
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree
+1 -1
View File
@@ -6,7 +6,7 @@ import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
+92 -83
View File
@@ -24,16 +24,19 @@ import {
type PendingAccessRow,
} from "../lib/api";
import { notify } from "../lib/notifications";
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import Tooltip from "./Tooltip";
import InlineNotice from "./InlineNotice";
import { Button } from "./ui/Button";
import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { Section } from "./ui/form";
import SegmentedToggle from "./SegmentedToggle";
import SegmentedToggle from "./ui/SegmentedToggle";
import { LANGUAGES, type LangCode } from "../i18n";
import { LoadingState, StateMessage } from "./QueryState";
import { LoadingState, StateMessage } from "./ui/QueryState";
import AccountPanel from "./AccountPanel";
import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs";
import { PageToolbar } from "./PageShell";
import Tabs, { usePersistedTab } from "./ui/Tabs";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
@@ -107,6 +110,9 @@ function UsersRoles({ me }: { me: Me }) {
// Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since
// with per-row disabling the admin can now start actions on several rows concurrently.
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
// The account panel (E12 S5): which user it is showing, or null. Held here rather than per row so
// only one is ever mounted.
const [panelFor, setPanelFor] = useState<AdminUserRow | null>(null);
const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id));
const endPending = (id: number) =>
setPendingIds((s) => {
@@ -192,6 +198,7 @@ function UsersRoles({ me }: { me: Me }) {
return (
<Section card title={t("users.roles.title")}>
{panelFor && <AccountPanel user={panelFor} onClose={() => setPanelFor(null)} />}
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
@@ -207,13 +214,17 @@ function UsersRoles({ me }: { me: Me }) {
const self = u.id === me.id;
return (
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
<div className="min-w-0 flex-1">
<button
onClick={() => setPanelFor(u)}
title={t("users.account.open")}
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
>
<div className="text-sm truncate">
{u.display_name ?? u.email.split("@")[0]}
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
</div>
<div className="text-2xs text-muted truncate">{u.email}</div>
</div>
</button>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
@@ -234,14 +245,16 @@ function UsersRoles({ me }: { me: Me }) {
: t("users.roles.makeAdmin")
}
>
<button
<Button
variant="glass"
size="xs"
className="shrink-0"
onClick={() => onToggle(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
icon={<Shield className="w-3.5 h-3.5" />}
>
<Shield className="w-3.5 h-3.5" />
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
</button>
</Button>
</Tooltip>
<Tooltip
hint={
@@ -254,36 +267,36 @@ function UsersRoles({ me }: { me: Me }) {
: t("users.suspend.action")
}
>
<button
<Button
variant={u.is_suspended ? "outline-warn" : "outline"}
size="icon-sm"
className="shrink-0"
onClick={() => onSuspend(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-label={
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
}
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
u.is_suspended
? "border-warn/40 text-warn hover:bg-warn/10"
: "border-border text-muted hover:text-warn"
}`}
>
{u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)}
</button>
icon={
u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)
}
/>
</Tooltip>
<Tooltip
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
>
<button
<Button
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={() => onDelete(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-label={t("users.delete.action")}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-40 transition"
>
<Trash2 className="w-4 h-4" />
</button>
icon={<Trash2 className="w-4 h-4" />}
/>
</Tooltip>
</div>
);
@@ -316,12 +329,9 @@ function PendingAccess() {
queryKey: qk.adminPendingAccess(),
queryFn: () => api.adminPendingAccess(),
});
// Activating changes /api/me's badge count too, so refresh both or the number lies until reload.
const refresh = () => {
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
qc.invalidateQueries({ queryKey: qk.me() });
qc.invalidateQueries({ queryKey: qk.adminUsers() });
};
// Activating changes /api/me's badge count too, so refresh all of them or the number lies until
// reload. Shared with the account panel's copy of this mutation (see lib/adminUsers).
const refresh = () => invalidateUserAccess(qc);
const activate = useMutation({
mutationFn: (u: PendingAccessRow) => api.activateUser(u.id),
@@ -343,10 +353,9 @@ function PendingAccess() {
onError: (err) =>
notify({
level: "error",
message:
err instanceof HttpError && isEmailOff(err)
? t("users.pendingAccess.resendEmailOff")
: t("users.pendingAccess.resendFailed"),
message: isEmailOff(err)
? t("users.pendingAccess.resendEmailOff")
: t("users.pendingAccess.resendFailed"),
}),
});
@@ -380,12 +389,6 @@ function PendingAccess() {
);
}
/** Did the resend fail because this instance has no working SMTP? */
function isEmailOff(err: HttpError): boolean {
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
return d?.code === "resend" && d.reason === "email_off";
}
// One stuck sign-up. The row names WHICH gate is shut rather than just offering buttons — the same
// reasoning as the sign-in card's blocked panel, from the admin's side: an admin about to let someone
// in should see that the address has not been confirmed yet, because on a no-SMTP instance that is
@@ -435,25 +438,28 @@ function PendingAccessRowView({
</div>
{!row.email_verified && (
<Tooltip hint={t("users.pendingAccess.resendHint")}>
<button
<Button
variant="outline"
size="icon-sm"
className="shrink-0"
onClick={onResend}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-fg disabled:opacity-50 transition"
aria-label={t("users.pendingAccess.resend")}
>
<Send className="w-4 h-4" />
</button>
icon={<Send className="w-4 h-4" />}
/>
</Tooltip>
)}
{row.can_activate && (
<Tooltip hint={t("users.pendingAccess.activateHint")}>
<button
<Button
variant="outline-accent"
size="xs"
className="shrink-0"
onClick={onActivate}
disabled={busy}
className="shrink-0 px-2.5 py-1.5 rounded-lg border border-accent/40 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-50 transition"
>
{t("users.pendingAccess.activate")}
</button>
</Button>
</Tooltip>
)}
</div>
@@ -673,12 +679,14 @@ function AdminInvites() {
placeholder={t("settings.invites.addPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
<Button
type="submit"
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
variant="glass"
className="shrink-0"
icon={<UserPlus className="w-4 h-4" />}
>
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
</button>
{t("settings.invites.add")}
</Button>
</form>
{decided.length > 0 && (
@@ -820,14 +828,15 @@ function AdminDemo() {
)}
</div>
<Tooltip hint={t("settings.demo.removeHint")}>
<button
<Button
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={() => onRemove(r)}
disabled={remove.isPending}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.demo.remove")}
>
<Trash2 className="w-4 h-4" />
</button>
icon={<Trash2 className="w-4 h-4" />}
/>
</Tooltip>
</div>
))}
@@ -848,12 +857,9 @@ function AdminDemo() {
placeholder={t("settings.demo.addPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
type="submit"
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
>
<Button variant="glass" size="md" className="shrink-0" type="submit">
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
</button>
</Button>
</form>
<div className="mt-4 pt-3 border-t border-border">
@@ -909,24 +915,26 @@ function InviteRow({
)}
</div>
<Tooltip hint={t("settings.invites.approveHint")}>
<button
<Button
variant="outline-accent"
size="icon-sm"
className="shrink-0"
onClick={onApprove}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
aria-label={t("settings.invites.approve")}
>
<Check className="w-4 h-4" />
</button>
icon={<Check className="w-4 h-4" />}
/>
</Tooltip>
<Tooltip hint={t("settings.invites.denyHint")}>
<button
<Button
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={onDeny}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.invites.deny")}
>
<X className="w-4 h-4" />
</button>
icon={<X className="w-4 h-4" />}
/>
</Tooltip>
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
exists for it the row is that account's approval gate, so it is managed from Users & roles
@@ -946,14 +954,15 @@ function InviteRow({
</button>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<button
<Button
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={onDelete}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
>
<Trash2 className="w-4 h-4" />
</button>
icon={<Trash2 className="w-4 h-4" />}
/>
</Tooltip>
)}
</div>
@@ -0,0 +1,158 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Keyboard, ListVideo, Palette, Tv } from "lucide-react";
import CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { useMe } from "../lib/useMe";
import { moduleDef, moduleOrder } from "../lib/modules";
import { SCHEMES, type Scheme } from "../lib/theme";
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
import { useNavigationActions } from "./NavigationProvider";
import { usePlaylistsActions } from "./PlaylistsProvider";
import { usePrefs } from "./PrefsProvider";
import { useSavedViews } from "./SavedViewsProvider";
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
//
// Scope decided with the product owner (2026-09-05): navigation + app-wide ACTIONS. The actions here
// are the reversible, local ones — the shortcuts sheet and the theme. Deliberately NOT here: "Sync
// from YouTube". It is a long-running job against a quota, it wants the progress and error surface
// the Channels page already gives it, and one mistyped Enter is a worse failure than the walk it
// would have saved.
//
// Saved views come from whichever module owns the current SavedViewsProvider source (feed on the
// feed, Plex on Plex), i.e. exactly the views that are applicable where you stand.
export default function AppCommandPalette() {
const { t } = useTranslation();
const open = usePaletteOpen();
const me = useMe();
const { setPage, openChannel } = useNavigationActions();
const { setSelectedId } = usePlaylistsActions();
const { theme, setTheme } = usePrefs();
const savedViews = useSavedViews();
// Only fetch the lists while the palette is actually open — it is a rarely-opened surface and
// these are otherwise the feed's and the playlist rail's own queries, shared through the cache.
const channelsQ = useQuery({
queryKey: qk.channels(),
queryFn: () => api.channels(),
enabled: open,
});
const playlistsQ = useQuery({
queryKey: qk.playlists(),
queryFn: () => api.playlists(),
enabled: open,
});
const groups = useMemo<CommandGroup[]>(() => {
const out: CommandGroup[] = [];
// Modules, in nav-rail order and gated by the account — the registry decides, not this file.
if (me) {
const pages = [...moduleOrder(me), "settings" as const];
out.push({
id: "modules",
label: t("palette.groups.modules"),
items: pages.map((page) => {
const def = moduleDef(page);
const Icon = def.icon;
return {
id: `page:${page}`,
label: t(def.labelKey),
icon: <Icon className="w-4 h-4" />,
// The longer tab title is a second name for the same place ("Channel manager" for
// "Channels"), so match on it even though the row shows the short one.
keywords: def.titleKey ? t(def.titleKey) : "",
run: () => setPage(page),
};
}),
});
}
const channels = channelsQ.data ?? [];
if (channels.length > 0) {
out.push({
id: "channels",
label: t("palette.groups.channels"),
items: channels
.filter((c) => !c.hidden)
.map((c) => ({
id: `channel:${c.id}`,
label: c.title ?? c.id,
hint: c.handle ?? undefined,
icon: <Tv className="w-4 h-4" />,
keywords: c.handle ?? "",
run: () => openChannel(c.id, c.title ?? undefined),
})),
});
}
const playlists = playlistsQ.data ?? [];
if (playlists.length > 0) {
out.push({
id: "playlists",
label: t("palette.groups.playlists"),
items: playlists.map((p) => ({
id: `playlist:${p.id}`,
label: p.name,
hint: t("palette.videoCount", { count: p.item_count }),
icon: <ListVideo className="w-4 h-4" />,
run: () => {
setSelectedId(p.id);
setPage("playlists");
},
})),
});
}
if (savedViews.views.length > 0) {
out.push({
id: "views",
label: t("palette.groups.views"),
items: savedViews.views.map((v) => ({
id: `view:${v.id}`,
label: v.name,
run: () => savedViews.apply(v),
})),
});
}
out.push({
id: "actions",
label: t("palette.groups.actions"),
items: [
{
id: "action:shortcuts",
label: t("keys.title"),
icon: <Keyboard className="w-4 h-4" />,
run: toggleKeyboardHelp,
},
...SCHEMES.map((s) => ({
id: `action:theme:${s.id}`,
label: t("palette.theme", { name: s.name }),
icon: <Palette className="w-4 h-4" />,
keywords: s.id,
run: () => setTheme({ ...theme, scheme: s.id as Scheme }),
})),
],
});
return out;
}, [
t,
me,
channelsQ.data,
playlistsQ.data,
savedViews,
theme,
setTheme,
setPage,
openChannel,
setSelectedId,
]);
return <CommandPalette open={open} onClose={closePalette} groups={groups} />;
}
+8 -6
View File
@@ -6,10 +6,11 @@ import { Trash2 } from "lucide-react";
import { api } from "../lib/api";
import { notify } from "../lib/notifications";
import { LS } from "../lib/storage";
import { Button } from "./ui/Button";
import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider";
import DataTable from "./DataTable";
import { LoadingState, StateMessage } from "./QueryState";
import DataTable from "./ui/DataTable";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { auditColumns } from "./auditColumns";
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
@@ -72,14 +73,15 @@ export default function AuditLog() {
persistKey={LS.auditTable}
controlsPosition="top"
controlsLeading={
<button
<Button
variant="outline-danger"
size="xs"
onClick={onClear}
disabled={clear.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
icon={<Trash2 className="w-3.5 h-3.5" />}
>
<Trash2 className="w-3.5 h-3.5" />
{t("audit.clear")}
</button>
</Button>
}
emptyText={t("audit.empty")}
/>
+6 -6
View File
@@ -12,15 +12,15 @@ import { useCardPager } from "../lib/useCardPager";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import Tooltip from "./Tooltip";
import Tooltip from "./ui/Tooltip";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import DataTable, { type Column } from "./DataTable";
import DataTable, { type Column } from "./ui/DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import ColumnSortControl from "./ColumnSortControl";
import Pager from "./Pager";
import { PageToolbar } from "./PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState";
import ColumnSortControl from "./ui/ColumnSortControl";
import Pager from "./ui/Pager";
import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./ConfirmProvider";
import { useChannelLayout } from "./ChannelsProvider";
@@ -1,7 +1,7 @@
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { type Column } from "./DataTable";
import VirtualGrid from "./VirtualGrid";
import { type Column } from "./ui/DataTable";
import VirtualGrid from "./ui/VirtualGrid";
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
@@ -84,7 +84,7 @@ export default function ChannelLayoutGrid<T>({
items={rows}
getKey={rowKey}
minCol={spec.minCol}
maxCol={null}
colMaxWidth={null}
rowEst={spec.rowEst}
renderItem={renderCard}
/>
+2 -2
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import Avatar from "./Avatar";
import Tooltip from "./Tooltip";
import Avatar from "./ui/Avatar";
import Tooltip from "./ui/Tooltip";
import { channelYouTubeUrl } from "../lib/format";
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
+9 -9
View File
@@ -5,7 +5,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { channelDetailPollMs } from "../lib/channelDetailPoll";
import { qk } from "../lib/queryKeys";
import { useNavigationActions } from "./NavigationProvider";
import { PriorityStepper } from "./PriorityStepper";
import { PriorityStepper } from "./ui/PriorityStepper";
import { useChannelPriority } from "../lib/useChannelPriority";
import {
ArrowLeft,
@@ -20,11 +20,11 @@ import {
RefreshCw,
SlidersHorizontal,
} from "lucide-react";
import Avatar from "./Avatar";
import Tabs from "./Tabs";
import Avatar from "./ui/Avatar";
import Tabs from "./ui/Tabs";
import Feed from "./Feed";
import ChannelAboutContent from "./ChannelAbout";
import { PageToolbar, PageToolbarSlot } from "./PageShell";
import { PageToolbar, PageToolbarSlot } from "./ui/PageShell";
import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
@@ -416,14 +416,14 @@ export default function ChannelPage({
<ExternalLink className="w-4 h-4" />
</a>
{ch?.subscribed && (
<button
<Button
variant="outline"
size="icon"
onClick={onShowInFeed}
title={t("channel.showInFeed")}
aria-label={t("channel.showInFeed")}
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-accent border border-border hover:border-accent transition"
>
<SlidersHorizontal className="w-4 h-4" />
</button>
icon={<SlidersHorizontal className="w-4 h-4" />}
/>
)}
{ch?.subscribed && !me.is_demo && (
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
+1 -1
View File
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api, type FeedFilters } from "../lib/api";
import Combobox, { type ComboboxOption } from "./Combobox";
import Combobox, { type ComboboxOption } from "./ui/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:
@@ -12,7 +12,7 @@ import {
import { useTranslation } from "react-i18next";
import { type MyStatus } from "../lib/api";
import { formatEta } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
+4 -4
View File
@@ -2,10 +2,10 @@ import { useEffect, useRef, useState } from "react";
import { Check, Plus, Sparkles, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
@@ -129,7 +129,7 @@ function SuggestedChipsLane({
}, [hidden, overflowOpen]);
return (
<div className="relative flex items-center gap-1 min-w-0 flex-1">
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
<div
aria-hidden
@@ -374,7 +374,7 @@ export function ChannelTagsControl({
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
<div
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
className={`${chipLaneClass} flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
>
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
+19 -19
View File
@@ -10,26 +10,27 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import DataTable from "./DataTable";
import Tabs, { type TabDef } from "./Tabs";
import { Button } from "./ui/Button";
import Tooltip from "./ui/Tooltip";
import DataTable from "./ui/DataTable";
import Tabs, { type TabDef } from "./ui/Tabs";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { useTagSuggestions } from "./useTagSuggestions";
import { useChannelPriority } from "../lib/useChannelPriority";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ColumnSortControl";
import FilterChipGroup from "./FilterChipGroup";
import ColumnSortControl from "./ui/ColumnSortControl";
import FilterChipGroup from "./ui/FilterChipGroup";
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
import ActionMenu, { type ActionMenuItem } from "./ui/ActionMenu";
import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "./Pager";
import Pager from "./ui/Pager";
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
import { sortRows } from "../lib/columnSort";
import { filterRows } from "./tableHeader";
import { filterRows } from "./ui/tableHeader";
import { useCardPager } from "../lib/useCardPager";
import { PageToolbar } from "./PageShell";
import { PageToolbar } from "./ui/PageShell";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider";
@@ -489,13 +490,15 @@ export default function Channels() {
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
<Tooltip hint={t("channels.tags.yourTagsHint")}>
<button
<Button
variant="outline"
size="xs"
className="shrink-0 uppercase tracking-wide"
onClick={() => setTagManagerOpen(true)}
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
icon={<Pencil className="h-3 w-3" />}
>
<Pencil className="h-3 w-3" />
{t("channels.tags.label")}
</button>
</Button>
</Tooltip>
<TagFilterChips chips={tagFilterChips} className="flex-1" />
{tagFilter.length > 0 && (
@@ -518,12 +521,9 @@ export default function Channels() {
</span>
</Tooltip>
{highConfidence.length > 0 && (
<button
onClick={acceptAllHighConfidence}
className="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:border-accent hover:text-accent transition"
>
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
</button>
</Button>
)}
</div>
)}
+2 -2
View File
@@ -22,8 +22,8 @@ import {
hasChannelsParams,
type ChannelsUrlState,
} from "../lib/channelsUrlState";
import { usePersistedTab } from "./Tabs";
import type { FilterMap } from "./tableHeader";
import { usePersistedTab } from "./ui/Tabs";
import type { FilterMap } from "./ui/tableHeader";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import type { ChannelsView } from "./Channels";
+2 -2
View File
@@ -13,9 +13,9 @@ import {
} from "../lib/chatDock";
import { onMessage, onUnread } from "../lib/messagesSocket";
import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar";
import Avatar from "./ui/Avatar";
import ChatThread from "./ChatThread";
import Overlay from "./Overlay";
import Overlay from "./ui/Overlay";
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
// page navigation (state persisted per user). Always mounted (for human users) so it also owns
+8 -6
View File
@@ -6,7 +6,8 @@ import { Send } from "lucide-react";
import { api, HttpError, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useScrollFade } from "../lib/useScrollFade";
import { LoadingState, StateMessage } from "./QueryState";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
@@ -213,15 +214,16 @@ export default function ChatThread({
placeholder={t("messages.writePlaceholder")}
className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
<Button
variant="primary"
size="icon"
className="shrink-0"
onClick={submit}
disabled={!draft.trim() || send.isPending}
className="shrink-0 p-2.5 rounded-xl btn-primary transition disabled:opacity-40"
aria-label={t("messages.send")}
title={t("messages.send")}
>
<Send className="w-4 h-4" />
</button>
icon={<Send className="w-4 h-4" />}
/>
</div>
)}
</>
+5 -5
View File
@@ -5,12 +5,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plug, RotateCcw, Send } from "lucide-react";
import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import InlineNotice from "./InlineNotice";
import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { useMe } from "../lib/useMe";
import Tabs, { usePersistedTab } from "./Tabs";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import Tabs, { usePersistedTab } from "./ui/Tabs";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { Switch } from "./ui/form";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS } from "../lib/storage";
+1 -1
View File
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
+1 -1
View File
@@ -1,7 +1,7 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays, Check, ChevronDown } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { type FeedFilters } from "../lib/api";
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
+1 -1
View File
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react";
import Banner from "./Banner";
import Banner from "./ui/Banner";
// Permanent, non-dismissible bar shown to the shared demo account so it's always clear the
// state is communal. (Replaces the old login-time toast, which re-popped on every reload.)
+3 -3
View File
@@ -19,9 +19,9 @@ import {
Trash2,
UploadCloud,
} from "lucide-react";
import Tabs from "./Tabs";
import { PageToolbar } from "./PageShell";
import { LoadingState, StateMessage } from "./QueryState";
import Tabs from "./ui/Tabs";
import { PageToolbar } from "./ui/PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState";
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
// used (the video editor in particular is heavy — filmstrip + scrubber).
const DownloadDialog = lazy(() => import("./DownloadDialog"));
+1 -1
View File
@@ -2,7 +2,7 @@ import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
import { api } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
// Renders the current app error (if any) as a modal the user must acknowledge.
+12 -13
View File
@@ -38,12 +38,13 @@ 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 { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { useWizard } from "./WizardProvider";
import SortControl from "./SortControl";
import FilterSelect from "./FilterSelect";
import ItemCount from "./ItemCount";
import SortControl from "./ui/SortControl";
import FilterSelect from "./ui/FilterSelect";
import ItemCount from "./ui/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.
@@ -530,20 +531,18 @@ export default function Feed({
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
</p>
{isDemo ? (
<button
<Button
variant="primary"
size="lg"
onClick={() => setFilters({ ...filters, scope: "all" })}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
>
{t("feed.browseLibrary")}
</button>
</Button>
) : (
<>
<button
onClick={onOpenWizard}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold btn-primary transition"
>
<Button variant="primary" size="lg" onClick={onOpenWizard}>
{t("feed.setUp")}
</button>
</Button>
<button
onClick={() => setFilters({ ...filters, scope: "all" })}
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
+1 -1
View File
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { Library, User } from "lucide-react";
import SegmentedToggle from "./SegmentedToggle";
import SegmentedToggle from "./ui/SegmentedToggle";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
// The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header
+2 -2
View File
@@ -1,8 +1,8 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import SidePanel from "./ui/SidePanel";
import PanelGroups, { type PanelItem } from "./ui/PanelGroups";
// The shared filter-rail shell, extracted from the three rails that all render the same SidePanel
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import Overlay from "./Overlay";
import Overlay from "./ui/Overlay";
import { copyText } from "../lib/clipboard";
import { usePrefs } from "./PrefsProvider";
+9 -16
View File
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
import { api, HttpError } from "../lib/api";
import * as e2ee from "../lib/e2ee";
import { Button } from "./ui/Button";
import { inputCls } from "./ui/form";
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
@@ -107,21 +108,17 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
)}
{err && <div className="text-sm text-danger">{err}</div>}
<div className="flex gap-2">
<button
onClick={cancelReset}
disabled={busy}
className="px-4 py-2 rounded-xl text-sm border border-border hover:border-accent transition disabled:opacity-40"
>
<Button variant="outline" onClick={cancelReset} disabled={busy}>
{t("common.cancel")}
</button>
<button
</Button>
<Button
variant="danger"
onClick={doReset}
disabled={busy}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-danger/90 text-danger-fg hover:bg-danger transition disabled:opacity-40"
icon={<AlertTriangle className="w-4 h-4" />}
>
<AlertTriangle className="w-4 h-4" />
{t("messages.resetButton")}
</button>
</Button>
</div>
</div>
);
@@ -157,14 +154,10 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
/>
)}
{err && <div className="text-sm text-danger">{err}</div>}
<button
onClick={submit}
disabled={busy || !pass}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition disabled:opacity-40"
>
<Button variant="primary" size="md" onClick={submit} disabled={busy || !pass}>
<Lock className="w-4 h-4" />
{configured ? t("messages.unlock") : t("messages.setUp")}
</button>
</Button>
{configured && (
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
{t("messages.forgotPass")}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { Check, Globe } from "lucide-react";
import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
+1 -1
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { jobTitle } from "./downloadRow";
+21 -17
View File
@@ -5,13 +5,14 @@ import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
import { api, type Conversation, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { LoadingState, StateMessage } from "./QueryState";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
import { useHistorySubview } from "../lib/history";
import { openChat } from "../lib/chatDock";
import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar";
import Avatar from "./ui/Avatar";
import KeyGate from "./KeyGate";
import { inputCls } from "./ui/form";
import ChatThread from "./ChatThread";
@@ -123,13 +124,10 @@ function ConversationList({
{t("messages.conversations")}
</div>
{ready && (
<button
onClick={onNew}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg btn-primary transition"
>
<Button variant="primary" size="xs" onClick={onNew}>
<SquarePen className="w-4 h-4" />
{t("messages.new")}
</button>
</Button>
)}
</div>
{(keyState === "setup" || keyState === "unlock") && <KeyGate meId={meId} />}
@@ -213,12 +211,15 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<button
<Button
variant="ghost"
size="icon-sm"
// An icon-only button needs a name: both of these announced themselves as a bare "button",
// and they are the only way out of the sub-view (E10 review round 1).
label={t("channel.back")}
onClick={onBack}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<ArrowLeft className="w-4 h-4" />
</button>
icon={<ArrowLeft className="w-4 h-4" />}
/>
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
</div>
<input
@@ -280,12 +281,15 @@ function PageThread({
return (
<div className="flex flex-col h-[65vh] min-h-80">
<div className="flex items-center gap-3 pb-3 border-b border-border">
<button
<Button
variant="ghost"
size="icon-sm"
// An icon-only button needs a name: both of these announced themselves as a bare "button",
// and they are the only way out of the sub-view (E10 review round 1).
label={t("channel.back")}
onClick={onBack}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<ArrowLeft className="w-4 h-4" />
</button>
icon={<ArrowLeft className="w-4 h-4" />}
/>
<Avatar
src={seedAvatar}
fallback={name}
+14 -13
View File
@@ -12,10 +12,11 @@ import type { Page } from "../lib/urlState";
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
import { useScrollFade } from "../lib/useScrollFade";
import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar";
import Tooltip from "./Tooltip";
import { Button } from "./ui/Button";
import AvatarImg from "./ui/Avatar";
import Tooltip from "./ui/Tooltip";
import LanguageSwitcher from "./LanguageSwitcher";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
@@ -221,22 +222,22 @@ export default function NavSidebar({
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<div className="flex flex-col items-center justify-center gap-2 pb-2 mb-1 border-b border-border">
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
<button
<Button
variant="ghost"
size="icon"
onClick={onOpenAbout}
title={t("header.account.about")}
aria-label={t("header.account.about")}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Info className="w-5 h-5" />
</button>
<button
icon={<Info className="w-5 h-5" />}
/>
<Button
variant="ghost"
size="icon"
onClick={logout}
title={t("header.account.signOut")}
aria-label={t("header.account.signOut")}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-5 h-5" />
</button>
icon={<LogOut className="w-5 h-5" />}
/>
</div>
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}>
<button
+74 -61
View File
@@ -37,9 +37,10 @@ import {
type VideoWatchedMeta,
} from "../lib/notifications";
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
import { LEVEL_STYLE } from "./Toaster";
import { PageToolbar } from "./PageShell";
import { LoadingState, StateMessage } from "./QueryState";
import { Button } from "./ui/Button";
import { LEVEL_STYLE } from "./ui/Toaster";
import { PageToolbar } from "./ui/PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useNavigationActions } from "./NavigationProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
@@ -132,22 +133,24 @@ export default function NotificationsPanel() {
</div>
{hasAny && (
<div className="flex items-center gap-2">
<button
<Button
variant="ghost"
size="xs"
onClick={markAll}
disabled={!hasUnread || readAllMut.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
icon={<CheckCheck className="w-4 h-4" />}
>
<CheckCheck className="w-4 h-4" />
{t("inbox.markAllRead")}
</button>
<button
</Button>
<Button
variant="ghost"
size="xs"
onClick={clearEverything}
disabled={clearMut.isPending}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition disabled:opacity-40"
icon={<Trash2 className="w-4 h-4" />}
>
<Trash2 className="w-4 h-4" />
{t("inbox.clearAll")}
</button>
</Button>
</div>
)}
</div>
@@ -305,43 +308,45 @@ function NotificationRow({
</ul>
)}
{isScheduler && (
<button
<Button
variant="link"
className="text-sm mt-1.5"
onClick={onOpenScheduler}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
icon={<Activity className="w-3.5 h-3.5" />}
>
<Activity className="w-3.5 h-3.5" />
{t("inbox.openScheduler")}
</button>
</Button>
)}
{isDownloadDone && (
<button
<Button
variant="link"
className="text-sm mt-1.5"
onClick={onOpenDownloads}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1.5"
icon={<Download className="w-3.5 h-3.5" />}
>
<Download className="w-3.5 h-3.5" />
{t("inbox.openDownloads")}
</button>
</Button>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{!n.read && (
<button
<Button
variant="ghost"
size="icon-sm"
onClick={onRead}
title={t("inbox.markRead")}
aria-label={t("inbox.markRead")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Check className="w-4 h-4" />
</button>
icon={<Check className="w-4 h-4" />}
/>
)}
<button
<Button
variant="ghost"
size="icon-sm"
onClick={onDismiss}
title={t("inbox.dismiss")}
aria-label={t("inbox.dismiss")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
icon={<X className="w-4 h-4" />}
/>
</div>
</div>
);
@@ -396,47 +401,52 @@ function ClientActivityRow({
</div>
{hidden ? (
<div className="flex items-center gap-3 mt-1">
<button
<Button
variant="link"
className="text-sm"
onClick={() => onFind(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
icon={<Search className="w-3.5 h-3.5" />}
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button
</Button>
<Button
variant="link"
className="text-sm"
onClick={() => onRevert(hidden)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
icon={<Eye className="w-3.5 h-3.5" />}
>
<Eye className="w-3.5 h-3.5" />
{t("notifications.unhide")}
</button>
</Button>
</div>
) : watched ? (
<div className="flex items-center gap-3 mt-1">
<button
<Button
variant="link"
className="text-sm"
onClick={() => onFind(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
icon={<Search className="w-3.5 h-3.5" />}
>
<Search className="w-3.5 h-3.5" />
{t("notifications.findInFeed")}
</button>
<button
</Button>
<Button
variant="link"
className="text-sm"
onClick={() => onRevert(watched)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
icon={<RotateCcw className="w-3.5 h-3.5" />}
>
<RotateCcw className="w-3.5 h-3.5" />
{t("notifications.unwatch")}
</button>
</Button>
</div>
) : subscribed ? (
<div className="flex items-center gap-3 mt-1">
<button
<Button
variant="link"
className="text-sm"
onClick={() => onFocusChannel(subscribed.channelName)}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
icon={<Tv className="w-3.5 h-3.5" />}
>
<Tv className="w-3.5 h-3.5" />
{t("notifications.openInManager")}
</button>
</Button>
<a
href={channelYouTubeUrl(subscribed.channelId)}
target="_blank"
@@ -448,36 +458,39 @@ function ClientActivityRow({
</a>
</div>
) : access ? (
<button
<Button
variant="link"
className="text-sm mt-1"
onClick={() => onReviewAccess()}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline mt-1"
icon={<UserPlus className="w-3.5 h-3.5" />}
>
<UserPlus className="w-3.5 h-3.5" />
{t("common.accessRequestsReview")}
</button>
</Button>
) : (
n.action &&
!n.dismissed && (
<button
<Button
variant="link"
className="mt-1 text-sm"
onClick={() => {
n.action!.onClick();
dismissClient(n.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
>
{n.action.label}
</button>
</Button>
)
)}
</div>
<button
<Button
variant="ghost"
size="icon-sm"
className="shrink-0"
onClick={onRemove}
title={t("inbox.dismiss")}
aria-label={t("inbox.dismiss")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition shrink-0"
>
<X className="w-4 h-4" />
</button>
icon={<X className="w-4 h-4" />}
/>
</div>
);
}
+8 -9
View File
@@ -5,7 +5,8 @@ import { qk } from "../lib/queryKeys";
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
import { api, type Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding";
import Overlay from "./Overlay";
import { Button } from "./ui/Button";
import Overlay from "./ui/Overlay";
// A first-login wizard that walks the user through granting YouTube access one scope at a
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
@@ -159,12 +160,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
>
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
</button>
<button
onClick={close}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
>
<Button variant="ghost" size="md" onClick={close}>
{t("onboarding.read.skip")}
</button>
</Button>
</div>
</>
)}
@@ -225,15 +223,16 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
>
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
</button>
<button
<Button
variant="ghost"
size="md"
onClick={() => {
endOnboarding(false);
onClose();
}}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
>
{t("onboarding.write.keepReadOnly")}
</button>
</Button>
</div>
</>
)}
@@ -17,13 +17,13 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
import { useSavedViews, asFilters } from "./SavedViewsProvider";
import { useActiveFilters } from "../lib/useActiveFilters";
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
import { useUndoable } from "../lib/useUndoable";
import UndoToolbar from "./UndoToolbar";
import UndoToolbar from "./ui/UndoToolbar";
// 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
+75 -75
View File
@@ -28,9 +28,9 @@ import {
VolumeX,
X,
} from "lucide-react";
import Avatar from "./Avatar";
import Avatar from "./ui/Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api";
@@ -44,7 +44,8 @@ import {
} from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { fullscreenElement } from "../lib/fullscreen";
import { useBackToClose } from "../lib/history";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { closeThenNavigate, useBackToClose } from "../lib/history";
import {
useNativeControlsYield,
useYtBigView,
@@ -329,72 +330,76 @@ export default function PlayerModal({
};
}, []);
// Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
useKeyScope("ytPlayer");
// Keyboard shortcuts + background scroll lock. The chords live in the shared keymap (`ytPlayer`
// scope) so the help sheet lists them; only the behaviour behind each id is here.
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
if (escapeHandled(e)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
if (maximizedRef.current) {
e.preventDefault();
setMaximized(false);
return;
}
onClose();
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives fullscreen,
// where the wheel goes to YouTube's own iframe. But the modal card scrolls (a long description)
// and Up/Down is how you scroll it from the keyboard — so defer whenever the focused element can
// still scroll that way, exactly like Space defers to a focused button. NOT while the video fills
// the screen though — in fullscreen OR in our own maximise — because the card is behind/off-screen
// there: deferring would scroll something invisible and leave the viewer with no volume control at
// all. Both flags, not just fullscreen: since F stopped taking the browser's lock, maximise is the
// common case and checking only `isFullscreen` made the keys dead exactly where they matter most.
const volume = (e: KeyboardEvent, up: boolean) => {
const covered = isFullscreenRef.current || maximizedRef.current;
if (!covered && canScrollBy(document.activeElement as HTMLElement | null, up ? -1 : 1))
return;
}
const el = document.activeElement as HTMLElement | null;
const tag = (el?.tagName || "").toLowerCase();
const typing =
tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
if (e.key === "f" || e.key === "F") {
if (typing) return;
e.preventDefault();
toggleFullscreen();
} else if (e.key === " " || e.code === "Space") {
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
// page from scrolling).
if (typing || tag === "button" || tag === "a") return;
e.preventDefault();
togglePlay();
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives
// fullscreen, where the wheel goes to YouTube's own iframe. But the modal card scrolls
// (a long description) and Up/Down is how you scroll it from the keyboard — so defer
// whenever the focused element can still scroll that way, exactly like Space defers to a
// focused button. NOT while the video fills the screen though — in fullscreen OR in our own
// maximise — because the card is behind/off-screen there: deferring would scroll something
// invisible and leave the viewer with no volume control at all. Both flags, not just
// fullscreen: since F stopped taking the browser's lock, maximise is the common case and
// checking only `isFullscreen` made the keys dead exactly where they matter most.
const up = e.key === "ArrowUp";
const covered = isFullscreenRef.current || maximizedRef.current;
if (typing || (!covered && canScrollBy(el, up ? -1 : 1))) return;
e.preventDefault();
nudgeVolume(up ? 5 : -5);
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
// Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the
// previous/next video in the queue (the feed's order or a playlist).
if (typing) return;
e.preventDefault();
const forward = e.key === "ArrowRight";
if (e.shiftKey) {
if (forward) goNext();
else goPrev();
} else {
nudgeSeek(forward ? 5 : -5);
}
}
e.preventDefault();
nudgeVolume(up ? 5 : -5);
};
const seek = (e: KeyboardEvent, secs: number) => {
e.preventDefault();
nudgeSeek(secs);
};
const onKey = (e: KeyboardEvent) => {
dispatch(e, "ytPlayer", {
close: (ev) => {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
if (escapeHandled(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
if (maximizedRef.current) {
ev.preventDefault();
setMaximized(false);
return;
}
onClose();
},
fullscreen: (ev) => {
ev.preventDefault();
toggleFullscreen();
},
playPause: (ev) => {
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
// page from scrolling).
if (isActivationTarget(ev)) return;
ev.preventDefault();
togglePlay();
},
volumeUp: (ev) => volume(ev, true),
volumeDown: (ev) => volume(ev, false),
seekBack: (ev) => seek(ev, -5),
seekFwd: (ev) => seek(ev, 5),
prevVideo: (ev) => {
ev.preventDefault();
goPrev();
},
nextVideo: (ev) => {
ev.preventDefault();
goNext();
},
});
};
window.addEventListener("keydown", onKey);
focusModal();
@@ -751,16 +756,11 @@ export default function PlayerModal({
onClick={() => {
const cid = active.channel_id;
const cname = active.channel_title ?? undefined;
// Closing the player pops its own history entry (useBackToClose → history.back()).
// Open the channel only AFTER that pop's popstate — pushing the channel entry
// before it would let the back remove it again (the bug: clicking the name only
// closed the player). One-shot listener fires after the popstate handler.
const open = () => {
window.removeEventListener("popstate", open);
openChannel(cid, cname);
};
window.addEventListener("popstate", open);
onClose();
// Close first, open the channel only once this player's own Back-to-close
// entry has been reclaimed — otherwise that reclaim undoes the push and
// clicking the name merely closes the player. The rule lives in history.ts
// now; the command palette needs exactly the same one.
closeThenNavigate(onClose, () => openChannel(cid, cname));
}}
className="font-medium hover:text-accent truncate text-left"
>
+179 -70
View File
@@ -21,8 +21,13 @@ import {
import { CSS } from "@dnd-kit/utilities";
import {
AlertTriangle,
ArrowDown,
ArrowDownToLine,
ArrowUp,
ArrowUpToLine,
Check,
ExternalLink,
FolderInput,
GripVertical,
ListPlus,
Pencil,
@@ -31,9 +36,10 @@ import {
RotateCcw,
Trash2,
Upload,
X,
Youtube,
} from "lucide-react";
import { api, HttpError, type Video } from "../lib/api";
import { api, HttpError, type Playlist, type Video } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName";
@@ -42,10 +48,15 @@ import { cycleSort, type SortState } from "../lib/columnSort";
import { formatRuntime } from "../lib/format";
import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar";
import { PageToolbar } from "./PageShell";
import { useChoice, useConfirm } from "./ConfirmProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { Button } from "./ui/Button";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import UndoToolbar from "./ui/UndoToolbar";
import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./ConfirmProvider";
import { usePlaylistActions } from "./usePlaylistActions";
import { usePlaylists } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import {
ColumnHead,
@@ -54,7 +65,7 @@ import {
isActive,
type Column,
type FilterMap,
} from "./tableHeader";
} from "./ui/tableHeader";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
@@ -87,11 +98,16 @@ function Row({
columns,
dragDisabled,
dragHint,
menuProps,
}: {
video: Video;
columns: Column<Video>[];
dragDisabled: boolean;
dragHint: string;
/** useContextMenu's trigger props. On the WHOLE row, not a handle: a right-click anywhere in the
* row opens the menu, the way a native list does. The row's own buttons keep their left-click
* behaviour — a right-click just isn't a button press. */
menuProps: Record<string, unknown>;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -111,6 +127,7 @@ function Row({
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
// the same lag by another route (150ms behind the pointer, on every row).
className="border-b border-border/50 hover:bg-card/40 transition-colors"
{...menuProps}
>
{columns.map((col) => (
<td
@@ -152,11 +169,10 @@ export default function Playlists() {
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
// list + its rail controls). This detail pane reacts to the selection.
const { selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const choose = useChoice();
const plActions = usePlaylistActions();
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
// The stored order is undoable: drag and "save this order" both go through `order.set`, so each
@@ -201,6 +217,9 @@ export default function Playlists() {
// a change of view would otherwise slide a different video under the index — or unmount the player
// mid-playback. PlayerModal already tracks its own queue by id; this is the parent half of that.
const [playingId, setPlayingId] = useState<string | null>(null);
// ONE context menu for the whole table, not one popover per <tr> (see ui/ContextMenu): the row it
// belongs to is `rowMenu.target`, and the panel anchors to the pointer.
const rowMenu = useContextMenu<Video>();
const listQuery = useQuery({
queryKey: qk.playlists(),
@@ -482,58 +501,65 @@ export default function Playlists() {
const removeRef = useRef(removeItem);
removeRef.current = removeItem;
async function moveItemTo(video: Video, target: Playlist) {
if (selectedId == null) return;
// Optimistic, exactly like removeItem: from THIS list's point of view a move is a removal, and
// rebasing keeps the undo stack valid for what's left instead of throwing it away.
const next = items.filter((v) => v.id !== video.id);
order.rebase((list) => list.filter((v) => v.id !== video.id));
lastSetRef.current = `${selectedId}:${idsKey(next)}`;
try {
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
// whenever the second call fails — see the endpoint's docstring.
await api.moveToPlaylist(selectedId, video.id, target.id);
notify({
level: "success",
message: t("playlists.movedToast", { title: video.title, name: plName(target) }),
});
} catch (e) {
// NOT a silent revert. `req` deliberately leaves 401/403/404 to the caller as control flow, and
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already
// impossible — the submenu never offers the current list). So the row used to snap back with no
// explanation and the shipped moveErrors strings were unreachable (E10 review round 1).
// ONLY the statuses `req` deliberately leaves to the caller. It already opens the blocking
// error dialog for 400/409/422 and 5xx and raises the connectivity notice for a bad gateway,
// so toasting unconditionally reported a transport fault twice (E10 review round 2).
if (e instanceof HttpError && (e.status === 404 || e.status === 403))
notify({ level: "error", message: e.detail || t("playlists.moveFailed") });
}
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
// key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as
// AddToPlaylist does for the same reason.
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
qc.invalidateQueries({ queryKey: qk.playlistMembership(video.id) });
}
// Keyboard/menu reordering, for the same stored order the drag handle writes — so it goes through
// `order.set` (undoable + persisted), not a direct API call. Gated by the same rule as the drag
// handle: a view (sort/group/filter) is not the stored order, so moving "up" inside one would
// write something the user never saw.
function moveInOrder(video: Video, to: "top" | "up" | "down" | "bottom") {
const from = items.findIndex((v) => v.id === video.id);
if (from < 0) return;
const target =
to === "top" ? 0 : to === "bottom" ? items.length - 1 : to === "up" ? from - 1 : from + 1;
if (target === from || target < 0 || target >= items.length) return;
order.set(arrayMove(items, from, target)); // onApply persists
}
async function saveRename() {
if (selectedId == null) return;
const name = renameValue.trim();
setRenaming(false);
if (name && name !== detail?.name) {
try {
await api.renamePlaylist(selectedId, name);
} catch (e) {
notifyYouTubeActionError(e, t("playlists.renameFailed"));
}
refreshAll();
}
if (name && name !== detail?.name) await plActions.rename(selectedId, name);
}
async function deletePlaylist() {
if (selectedId == null || !detail) return;
// ONE dialog with every outcome. It used to be two chained confirms, where the second one's
// Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist.
const offerYoutube = linked && canWrite;
const picked = await choose({
title: t("playlists.delete"),
message: offerYoutube
? t("playlists.confirmDeleteLinked", { name: plName(detail) })
: t("playlists.confirmDelete", { name: plName(detail) }),
choices: offerYoutube
? [
{ id: "here", label: t("playlists.deleteHereOnly"), danger: true },
{ id: "both", label: t("playlists.deleteBoth"), danger: true },
]
: [{ id: "here", label: t("playlists.delete"), danger: true }],
});
if (picked == null) return;
const onYoutube = picked === "both";
const deletedId = selectedId;
// Pick the next selection from what's left *now* (don't go via null, or the
// auto-select effect would re-pick the just-deleted id from the stale list).
const remaining = playlists.filter((p) => p.id !== deletedId);
setSelectedId(remaining[0]?.id ?? null);
try {
await api.deletePlaylist(deletedId, onYoutube);
} catch (err) {
// Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
// "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
notify({
level: "warning",
message:
(err instanceof HttpError && err.detail) ||
(onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
});
}
qc.removeQueries({ queryKey: qk.playlist(deletedId) });
qc.invalidateQueries({ queryKey: qk.playlists() });
if (!detail) return;
// The dialog, the "delete on YouTube too?" branch and the re-selection live in
// usePlaylistActions — the rail's context menu deletes through the SAME code.
await plActions.remove(detail);
}
function playerState(id: string, status: string) {
@@ -573,6 +599,85 @@ export default function Playlists() {
: t("playlists.dragBlockedSort");
const playingIndex = playingId ? shown.findIndex((v) => v.id === playingId) : -1;
// The row menu, built from the row the user right-clicked. Same item shape (and therefore same
// rows, same paint, same keyboard model) as any ⋯ ActionMenu in the app.
const moveTargets = playlists.filter((p) => p.id !== selectedId);
const storedOrderEditable = canEdit && !viewActive;
const ctxRow = rowMenu.target;
const ctxItems: ActionMenuItem[] = !ctxRow
? []
: [
{
id: "play",
label: t("card.play"),
icon: <Play className="w-4 h-4" />,
onSelect: () => setPlayingId(ctxRow.id),
},
...(canEdit
? ([
moveTargets.length
? {
id: "move",
label: t("playlists.moveTo"),
icon: <FolderInput className="w-4 h-4" />,
separatorBefore: true,
items: moveTargets.map((pl) => ({
id: `move-${pl.id}`,
label: plName(pl),
icon:
pl.source === "youtube" || pl.yt_playlist_id ? (
<Youtube className="w-4 h-4" />
) : (
<ListPlus className="w-4 h-4" />
),
onSelect: () => void moveItemTo(ctxRow, pl),
})),
}
: // Kept visible and disabled rather than dropped: an action that vanishes teaches
// nobody why. KNOWN GAP: the `title` that carries the why currently reaches nobody
// either — see ActionMenu's `title` prop, where the two failed attempts at fixing
// that are recorded. The row is honest about being off; it is not yet honest about
// the reason.
{
id: "move",
label: t("playlists.moveTo"),
icon: <FolderInput className="w-4 h-4" />,
separatorBefore: true,
disabled: true,
title: t("playlists.moveNoTargets"),
onSelect: () => {},
},
...(
[
["top", t("playlists.orderTop"), <ArrowUpToLine className="w-4 h-4" />],
["up", t("playlists.orderUp"), <ArrowUp className="w-4 h-4" />],
["down", t("playlists.orderDown"), <ArrowDown className="w-4 h-4" />],
["bottom", t("playlists.orderBottom"), <ArrowDownToLine className="w-4 h-4" />],
] as const
).map(([to, label, icon], i) => ({
id: `order-${to}`,
label,
icon,
separatorBefore: i === 0,
// Same gate as the drag handle. NOTE the drag handle explains itself and this row
// does not: the handle is `aria-disabled` (focusable, tooltip-capable) while a menu
// row cannot be — see ActionMenu's `title` prop for why.
disabled: !storedOrderEditable,
title: storedOrderEditable ? undefined : dragHint,
onSelect: () => moveInOrder(ctxRow, to),
})),
{
id: "remove",
label: t("playlists.removeItem"),
icon: <X className="w-4 h-4" />,
separatorBefore: true,
danger: true,
onSelect: () => void removeItem(ctxRow),
},
] satisfies ActionMenuItem[])
: []),
];
if (selectedId == null) {
return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
}
@@ -585,12 +690,9 @@ export default function Playlists() {
<AlertTriangle className="w-4 h-4 text-danger" />
{t("playlists.loadFailed")}
</span>
<button
onClick={() => detailQuery.refetch()}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
>
<Button variant="outline" size="xs" onClick={() => detailQuery.refetch()}>
{t("common.retry")}
</button>
</Button>
</div>
);
}
@@ -703,25 +805,26 @@ export default function Playlists() {
</button>
)}
{linked && detail.dirty && (
<button
<Button
variant="outline"
size="icon-sm"
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</button>
</Button>
)}
{editable && (
<button
<Button
variant="outline-danger"
size="icon-sm"
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
icon={<Trash2 className="w-3.5 h-3.5" />}
/>
)}
</div>
</div>
@@ -755,12 +858,14 @@ export default function Playlists() {
</button>
)}
{canSaveOrder && (
<button
<Button
variant="outline-accent"
size="xs"
onClick={saveOrder}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
icon={<Check className="w-3.5 h-3.5" />}
>
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
</button>
{t("playlists.saveOrder")}
</Button>
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here
(reset link + save hint + "reordering is off"), and none of them answered the
@@ -829,6 +934,7 @@ export default function Playlists() {
columns={columns}
dragDisabled={!canEdit || viewActive}
dragHint={dragHint}
menuProps={rowMenu.triggerProps(v)}
/>
))}
</tbody>
@@ -843,6 +949,9 @@ export default function Playlists() {
</DndContext>
)}
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
{playingIndex >= 0 && (
<Suspense fallback={null}>
<PlayerModal
+165 -40
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { ListVideo, Pencil, Pin, Plus, RefreshCw, Trash2, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notify } from "../lib/notifications";
@@ -10,20 +10,29 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
import { type PanelLayout } from "../lib/panelLayout";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups";
import SortControl from "./SortControl";
import { Button } from "./ui/Button";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { type PanelItem } from "./ui/PanelGroups";
import SortControl from "./ui/SortControl";
import { type SortOption } from "../lib/columnSort";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import { playlistName } from "../lib/playlistName";
import { usePlaylistActions } from "./usePlaylistActions";
type PlSort = {
key: "custom" | "name" | "count" | "duration";
key: "custom" | "name" | "count" | "duration" | "recent";
dir: "asc" | "desc";
dirtyFirst: boolean;
};
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
// One geometry for the row in both of its shapes (the selectable button, and the rename field it
// becomes) — the row must not resize the moment you rename it.
const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transition";
// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the
// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane
@@ -52,12 +61,23 @@ export default function PlaylistsRail({
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
{ key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" },
{ key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" },
// E10 S2 — "the one I just touched, first". Reads `updated_at`, which the API bumps for item
// adds/removes/moves/reorders too, not just renames (see the backend's _touch). NOT the
// default: a rail that reshuffles itself while you look at it costs more than it saves.
{ key: "recent", label: t("playlists.railSortRecent"), defaultDir: "desc" },
];
useEffect(() => {
writeAccount(LS.plSort, plSort);
}, [plSort]);
const [newName, setNewName] = useState("");
const [syncing, setSyncing] = useState(false);
const plActions = usePlaylistActions();
// Right-click / long-press anywhere on a rail row. ONE menu for the whole list (see ui/ContextMenu).
const railMenu = useContextMenu<Playlist>();
// Renaming happens IN the row that was right-clicked, not in the detail header — the point of the
// context menu is that the action lands where the gesture did.
const [renamingId, setRenamingId] = useState<number | null>(null);
const [renameValue, setRenameValue] = useState("");
const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false);
@@ -95,6 +115,8 @@ export default function PlaylistsRail({
return sign * (a.item_count - b.item_count);
case "duration":
return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0));
case "recent":
return sign * (Date.parse(a.updated_at) - Date.parse(b.updated_at));
default:
return 0;
}
@@ -141,20 +163,93 @@ export default function PlaylistsRail({
},
});
// KNOWN GAP: Enter and Escape unmount the focused <input> and re-render the row as a <button> that
// nothing focuses, so a keyboard user is dropped on <body> and loses their place in the rail (E10
// review round 1). A refocus-the-row fix was written in round 1 and REMOVED in round 3 because a
// real-browser check showed it never worked: Escape's handler does run (typing then Escape leaves
// the name unchanged, i.e. it cancels rather than blur-commits) and the row button is focusable
// when focused from outside — yet activeElement is still BODY afterwards. Three explanations were
// tried and none survived contact, so the machinery went rather than a fourth guess. Whoever picks
// this up: instrument the effect in a real browser first, do not reason about the commit order.
const renameInputRef = useRef<HTMLInputElement | null>(null);
// UNRESOLVED — do not "simplify" this back to `autoFocus` without reading the whole story.
//
// Opening this field from the context menu is a focus race: `MenuItemList` runs `onSelect()` and
// `onClose()` in one batch, so the field mounts in the same commit that unmounts the popover, and
// floating-ui's FloatingFocusManager hands focus back from a `queueMicrotask` queued in its
// layout-effect cleanup. If that microtask wins, the field is blurred straight back out and its
// own onBlur commits and closes it — "the rename field flashes and vanishes" (E10 review round 1).
//
// Three answers have been tried and none is VERIFIED. Round 1 turned floating-ui's returnFocus off
// platform-wide and broke two other surfaces (a non-focusable <tr> trigger; a scroll-dismiss that
// scrolled the list back). Round 2 replaced it with this effect, on the reasoning that a passive
// effect is scheduled after the microtask. Round 3 disputed that (React flushes passive effects
// synchronously at the end of a SyncLane commit, which would put the microtask LAST) — and a jsdom
// harness built to settle it reproduced no failure for ANY of autoFocus / this effect / a
// microtask, i.e. jsdom does not exercise the race, because focus never enters the menu there.
//
// So: this is the shape that is deliberate rather than accidental, and the question is open. It
// needs ONE real-browser check (open the rail's context menu, pick Rename, see whether the field
// survives) — not a fourth guess. Logged in the E10 plan.
useEffect(() => {
if (renamingId !== null) renameInputRef.current?.focus();
}, [renamingId]);
async function commitRename(pl: Playlist) {
const name = renameValue.trim();
setRenamingId(null);
if (name && name !== pl.name) await plActions.rename(pl.id, name);
}
const ctxPl = railMenu.target;
// The built-in Watch later list is renameable/deletable nowhere else in the UI either; the rows
// stay visible and disabled so the menu doesn't change shape from list to list.
const ctxBuiltin = ctxPl?.kind === "watch_later";
// Interpolated, not spelled out: the HU copy said "Watch later", a name the Hungarian UI never
// shows for this list ("Megnézem később") — so the message named a list the reader had never seen
// (E10 review round 1). Passing the localized name makes the two impossible to drift.
const lockedTitle = ctxBuiltin
? t("playlists.builtinLocked", { name: t("playlists.watchLater") })
: undefined;
const railItems: ActionMenuItem[] = !ctxPl
? []
: [
{
id: "rename",
label: t("playlists.rename"),
icon: <Pencil className="w-4 h-4" />,
disabled: ctxBuiltin,
title: lockedTitle,
onSelect: () => {
setRenameValue(ctxPl.name);
setRenamingId(ctxPl.id);
},
},
{
id: "delete",
label: t("playlists.delete"),
icon: <Trash2 className="w-4 h-4" />,
separatorBefore: true,
danger: true,
disabled: ctxBuiltin,
title: lockedTitle,
// The same dialog (and the same "what gets selected next" rule) as the detail header's
// trash button — one implementation, two entry points.
onSelect: () => void plActions.remove(ctxPl),
},
];
const items: PanelItem[] = [
{
id: "manage",
title: t("playlists.manageGroup"),
body: (
<div className="flex flex-col gap-2">
<button
onClick={syncYoutube}
disabled={syncing}
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent disabled:opacity-40 transition"
>
<Button variant="outline" size="xs" fullWidth onClick={syncYoutube} disabled={syncing}>
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
{t("playlists.syncYoutube")}
</button>
</Button>
{playlists.length > 1 && (
<SortControl
label={t("playlists.sortLabel")}
@@ -219,40 +314,70 @@ export default function PlaylistsRail({
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
)}
<div className="space-y-1">
{visiblePlaylists.map((pl) => (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
onClick={() => setSelectedId(pl.id)}
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
pl.id === selectedId
? "bg-accent/15 border-accent"
: "border-transparent hover:bg-card/60"
}`}
>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
<span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
{visiblePlaylists.map((pl) =>
renamingId === pl.id ? (
// The row becomes its own rename field. A <button> can't contain an <input>, so the
// whole row swaps rather than the label alone — but it KEEPS the cover, because
// that is what holds the row at its two-line height. Without it the rail jumps by
// ~12px the moment you start renaming, and every row below it moves under the
// pointer.
<div key={pl.id} className={`${ROW_BASE} bg-accent/10 border-accent`}>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatRuntime(pl.total_duration_seconds)}`}
</div>
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void commitRename(pl)}
onKeyDown={(e) => {
if (e.key === "Enter") void commitRename(pl);
// Unmounting the input fires no blur in React, so Escape really does cancel.
if (e.key === "Escape") setRenamingId(null);
}}
aria-label={t("playlists.rename")}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
</div>
</button>
))}
) : (
<button
key={pl.id}
ref={pl.id === selectedId ? selectedRef : null}
onClick={() => setSelectedId(pl.id)}
{...railMenu.triggerProps(pl)}
className={`${ROW_BASE} text-left ${
pl.id === selectedId
? "bg-accent/15 border-accent"
: "border-transparent hover:bg-card/60"
}`}
>
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
{pl.cover_thumbnail && (
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1 text-[0.8125rem] text-fg">
<span className="truncate">{plName(pl)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
/>
)}
</div>
<div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatRuntime(pl.total_duration_seconds)}`}
</div>
</div>
</button>
),
)}
</div>
<ContextMenu menu={railMenu} ariaLabel={t("playlists.railMenu")} items={railItems} />
</>
),
},
+33 -29
View File
@@ -25,20 +25,21 @@ import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
import { toggleWatchState } from "../lib/plexWatch";
import SortControl from "./SortControl";
import FilterSelect from "./FilterSelect";
import SortControl from "./ui/SortControl";
import FilterSelect from "./ui/FilterSelect";
import PlexDiscover from "./PlexDiscover";
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
import { PageToolbar } from "./PageShell";
import { PageToolbar } from "./ui/PageShell";
import ActiveFilterChips from "./ActiveFilterChips";
import SelectedViewChip from "./SelectedViewChip";
import ItemCount from "./ItemCount";
import { StateMessage } from "./QueryState";
import ItemCount from "./ui/ItemCount";
import { StateMessage } from "./ui/QueryState";
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider";
import { usePublishPlexView } from "./PlexViewBridge";
import { EpisodeCard, PlexPosterCard } from "./plexCards";
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
import { dispatch, useKeyScope } from "../lib/keymap";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
@@ -64,6 +65,20 @@ const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
// The drill-down views Backspace can step out of. "player" is deliberately absent: the player owns
// its own Backspace (stop & back to the grid), and this handler must not fire underneath it.
const BACKABLE_VIEWS: readonly string[] = [
"grid",
"playlists",
"collections",
"show",
"season",
"info",
"person",
"playlist",
"collection",
];
export default function PlexBrowse() {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -163,35 +178,24 @@ export default function PlexBrowse() {
}, [sub.view.kind]);
// Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist),
// matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here;
// and never hijack Backspace while typing in a field.
// matching the browser/mouse Back; the chord and the typing guard come from the shared keymap.
// BACKABLE_VIEWS is the ONE list behind both the guard and the scope registration, so the cheat
// sheet can never promise a "Back one level" the handler declines to perform.
const canStepBack = BACKABLE_VIEWS.includes(sub.view.kind);
useKeyScope("plexBrowse", canStepBack);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Backspace") return;
const el = document.activeElement as HTMLElement | null;
const tag = (el?.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
return;
if (
[
"grid",
"playlists",
"collections",
"show",
"season",
"info",
"person",
"playlist",
"collection",
].includes(sub.view.kind)
) {
e.preventDefault();
sub.back();
}
dispatch(e, "plexBrowse", {
back: (ev) => {
if (!canStepBack) return;
ev.preventDefault();
sub.back();
},
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [sub]);
}, [sub, canStepBack]);
// D3: entering the in-progress watch-state view auto-applies the "last watched" sort (most recently
// watched first); leaving restores the sort that was active before entering. Only the ENTER edge
@@ -6,7 +6,8 @@ import { Check, Plus } from "lucide-react";
import { api } from "../lib/api";
import { notifyCollectionEditError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider";
// Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE
@@ -138,14 +139,16 @@ export default function PlexCollectionEditor({
placeholder={t("plex.collEditor.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
<Button
variant="glass"
size="md"
className="shrink-0"
onClick={createNew}
disabled={!newName.trim() || !!busy}
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
>
<Plus className="h-4 w-4" />
{t("plex.collEditor.create")}
</button>
</Button>
</div>
{/* Filter */}
@@ -13,7 +13,7 @@ import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
import { PlexPosterCard } from "./plexCards";
import PlexLibraryPicker from "./PlexLibraryPicker";
import { toggleWatchState } from "../lib/plexWatch";
import { LoadingState, StateMessage } from "./QueryState";
import { LoadingState, StateMessage } from "./ui/QueryState";
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
+2 -2
View File
@@ -8,9 +8,9 @@ import PosterTile from "./PosterTile";
import { plexLang } from "../i18n";
import { toggleWatchState } from "../lib/plexWatch";
import { usePlex } from "./PlexProvider";
import { Carousel } from "./Carousel";
import { Carousel } from "./ui/Carousel";
import { PlexPosterCard } from "./plexCards";
import { LoadingState, StateMessage } from "./QueryState";
import { LoadingState, StateMessage } from "./ui/QueryState";
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
+1 -1
View File
@@ -16,7 +16,7 @@ import {
} from "../lib/plexDetailUi";
import { usePref } from "../lib/usePref";
import i18n from "../i18n";
import { Carousel } from "./Carousel";
import { Carousel } from "./ui/Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexCards";
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api, type PlexCard } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { LoadingState } from "./QueryState";
import { LoadingState } from "./ui/QueryState";
// A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and
// the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and
+145 -146
View File
@@ -1,4 +1,4 @@
import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
@@ -25,7 +25,6 @@ import {
Square,
Volume2,
VolumeX,
X,
} from "lucide-react";
import { api, HttpError } from "../lib/api";
import {
@@ -53,6 +52,16 @@ import {
useWallClock,
} from "../lib/plexPlayerHooks";
import { fullscreenElement } from "../lib/fullscreen";
import {
dispatch,
isActivationTarget,
isTypingTarget,
toggleKeyboardHelp,
useHelpConditions,
useKeyboardHelpOpen,
useKeyScope,
usePaletteOpen,
} from "../lib/keymap";
import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
@@ -176,10 +185,33 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
},
[tracksFade.ref],
);
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
const [helpOpen, setHelpOpen] = useState(false);
// The keyboard cheat sheet is app-wide now (ui/KeyboardHelp, opened by the global "h"), not this
// player's own overlay. Mirror its open state into a ref because usePlayerVolume still needs to
// know an overlay is up before it steals a wheel scroll for volume.
const helpOpen = useKeyboardHelpOpen();
const helpOpenRef = useRef(false);
helpOpenRef.current = helpOpen;
// Read for the Backspace guard below: these two overlays open OVER the player and own the key.
const paletteOpenRef = useRef(false);
paletteOpenRef.current = usePaletteOpen();
// On-screen confirmation for a KEYBOARD track switch. A/S have cycled audio and subtitles since
// E5, but silently — you changed the track and the only way to see what you landed on was to open
// the tracks menu, which is exactly the modal round-trip the shortcut exists to avoid. The menu
// itself needs no OSD: it shows the selection where you clicked.
const [osd, setOsd] = useState<string | null>(null);
const osdTimer = useRef<number | null>(null);
const flashOsd = useCallback((text: string) => {
setOsd(text);
if (osdTimer.current) window.clearTimeout(osdTimer.current);
osdTimer.current = window.setTimeout(() => setOsd(null), 2200);
}, []);
useEffect(
() => () => {
if (osdTimer.current) window.clearTimeout(osdTimer.current);
},
[],
);
// "I" toggles a rich info overlay (poster/cast/ratings) over the running video.
const [infoOpen, setInfoOpen] = useState(false);
const infoOpenRef = useRef(false);
@@ -577,15 +609,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
useEffect(() => setUpNextOpen(false), [id]);
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
// off whatever is layered on top (a tuning/tracks menu, then the shortcuts sheet, then the info
// overlay) before leaving the player. The arrow used to call onClose() unconditionally, so Back
// with the info/shortcuts panel open jumped straight to the feed instead of just closing the panel.
// off whatever is layered on top (a tuning/tracks menu, then the info overlay) before leaving the
// player. The arrow used to call onClose() unconditionally, so Back with the info panel open
// jumped straight to the feed instead of just closing the panel. The shortcuts sheet is no longer
// in this cascade: it is a Modal above us now, and owns its own Escape layer and Back-to-close.
const handleBack = useCallback(() => {
if (menuOpenRef.current) {
setMenuOpen(false);
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
} else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
else onClose();
@@ -603,6 +635,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
handleBack();
}, [handleBack, skipProgressRef, cancelAutoSkipRef]);
// Tell the shared cheat sheet which keys are live here, and whether ⇧← / ⇧→ apply (they step
// episodes, so they are listed only inside a series).
useKeyScope("plexPlayer");
useHelpConditions({ episode: detail?.kind === "episode" });
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
const layerIdRef = useRef<number | null>(null);
@@ -617,92 +654,101 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// --- keyboard --------------------------------------------------------------------------------
useEffect(() => {
const seekBy = (e: KeyboardEvent, dir: -1 | 1, fine: boolean) => {
e.preventDefault();
seekTo(absRef.current + dir * (fine ? fineSeekStepRef.current : seekStepRef.current));
};
const step = (e: KeyboardEvent, id: string | null | undefined) => {
e.preventDefault();
go(id);
};
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
wake(); // keyboard use also reveals the controls
switch (e.key) {
case " ":
// Keyboard use also reveals the controls — but typing in a player field is not "using the
// remote", and `dispatch` still lets Escape through from inside a field (the only key that
// opts in), which is why this is a guard around `wake` rather than an early return.
if (!isTypingTarget(e)) wake();
dispatch(e, "plexPlayer", {
playPause: (ev) => {
// Let Space activate a focused button/link natively (e.g. "Skip intro"); only toggle
// playback when the focus isn't on a control — matching PlayerModal's guard.
if (tag === "BUTTON" || tag === "A") return;
e.preventDefault();
// playback when the focus isn't on a control — the same shared rule PlayerModal uses.
if (isActivationTarget(ev)) return;
ev.preventDefault();
togglePlay();
break;
case "k":
e.preventDefault();
togglePlay();
break;
case "f":
toggleFs();
break;
case "ArrowLeft":
e.preventDefault();
if (e.shiftKey) go(prevId);
else seekTo(absRef.current - (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
break;
case "ArrowRight":
e.preventDefault();
if (e.shiftKey) go(nextId);
else seekTo(absRef.current + (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
break;
case "ArrowUp":
e.preventDefault();
},
fullscreen: () => toggleFs(),
seekBack: (ev) => seekBy(ev, -1, false),
seekFwd: (ev) => seekBy(ev, 1, false),
fineBack: (ev) => seekBy(ev, -1, true),
fineFwd: (ev) => seekBy(ev, 1, true),
prevEpisode: (ev) => step(ev, prevId),
nextEpisode: (ev) => step(ev, nextId),
volumeUp: (ev) => {
ev.preventDefault();
nudgeVolume(0.05);
break;
case "ArrowDown":
e.preventDefault();
},
volumeDown: (ev) => {
ev.preventDefault();
nudgeVolume(-0.05);
break;
case "m":
toggleMute();
break;
case "a":
case "A":
cycleAudio();
break;
case "s":
case "S":
cycleSubtitle();
break;
case "h":
case "H":
setHelpOpen((v) => !v);
break;
case "i":
case "I":
setInfoOpen((v) => !v);
break;
case "d":
case "D":
setStatsOpen((v) => !v);
break;
case "Backspace":
},
mute: () => toggleMute(),
audio: () => {
const ord = cycleAudio();
if (ord === undefined) return; // one track (or none): nothing to say
const label = detail?.audio_streams.find((a) => a.ord === (ord ?? 0))?.label;
flashOsd(`${t("plex.player.audio")} · ${label ?? "—"}`);
},
subtitle: () => {
const ord = cycleSubtitle();
if (ord === undefined) return;
const label =
ord == null
? t("plex.player.subOff")
: (detail?.subtitle_streams.find((x) => x.ord === ord)?.label ?? "—");
flashOsd(`${t("plex.player.subtitles")} · ${label}`);
},
info: () => setInfoOpen((v) => !v),
stats: () => setStatsOpen((v) => !v),
back: (ev) => {
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
e.preventDefault();
// Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY
// and its focused close button is not a typing target, so the key reaches us regardless.
// Two wrong answers so far: deferring to nothing at all (round 1's bug — the cheat sheet
// took ⌫ and the player stopped underneath it), then deferring to `anyLayerAbove` outright
// (round 1's fix, round 2's bug — the player's OWN gear/tracks popovers push layers too, so
// ⌫ stopped closing them), then naming just the sheet and palette (round 2's fix, round 3's
// bug — the app-wide ErrorDialog is neither, so ⌫ tore the player down under it).
// The rule that holds: the player's own panels answer first, because `handleBack`'s cascade
// is what closes them; anything else layered above us takes the key.
const ownPanelOpen =
menuOpenRef.current ||
infoOpenRef.current ||
statsOpenRef.current ||
upNextOpenRef.current;
if (!ownPanelOpen && layerIdRef.current != null && anyLayerAbove(layerIdRef.current))
return;
ev.preventDefault();
if (skipProgressRef.current != null)
cancelAutoSkipRef.current(); // cancel auto-skip, don't go back
else handleBack();
break;
case "Escape":
},
escape: (ev) => {
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the
// consumed-event flag first (UX-8): a layer's `document` listener runs before this `window`
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
if (escapeHandled(e)) break;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
if (escapeHandled(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (menuOpenRef.current) {
setMenuOpen(false);
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
} else if (infoOpenRef.current) setInfoOpen(false);
else if (statsOpenRef.current) setStatsOpen(false);
else if (upNextOpenRef.current) setUpNextOpen(false);
else if (!fullscreenElement()) onClose();
break;
}
},
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
@@ -715,6 +761,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
toggleMute,
cycleAudio,
cycleSubtitle,
flashOsd,
t,
detail,
prevId,
nextId,
@@ -846,15 +894,30 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
)}
{/* Keyboard track-switch OSD. Sits ABOVE the control bar's own space so it reads even while the
bar is showing, and inside the player element so it survives fullscreen. `aria-live` because
the whole point is confirming something you cannot otherwise see.
The REGION is always mounted and only the bubble is conditional: a polite region has to
pre-exist in the accessibility tree for a later insertion to be announced, so one created
together with its own first message is unreliably read — and since `osd` clears after 2.2s,
conditioning the wrapper re-created it for every switch, not just the first (E10 review
round 1). Toaster.tsx carries the same rule and the same shape. */}
<div
className="pointer-events-none absolute inset-x-0 bottom-24 z-menu flex justify-center px-4"
aria-live="polite"
>
{osd && (
<div className="max-w-full truncate rounded-full border border-white/15 bg-black/75 px-4 py-2 text-sm text-white shadow-2xl">
{osd}
</div>
)}
</div>
{/* While a panel/menu is open or an auto-skip countdown is running, take over the browser/mouse
Back button (history popstate) so it closes/cancels that instead of dropping to the grid. */}
{(menuOpen ||
tracksOpen ||
helpOpen ||
infoOpen ||
statsOpen ||
upNextOpen ||
skipProgress != null) && <BackClose onBack={backIntent} />}
{(menuOpen || tracksOpen || infoOpen || statsOpen || upNextOpen || skipProgress != null) && (
<BackClose onBack={backIntent} />
)}
{/* Top bar */}
<div
@@ -1197,7 +1260,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
<Info className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
<Ctrl label={t("plex.player.help")} onClick={toggleKeyboardHelp}>
<Keyboard className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.download")} onClick={download}>
@@ -1236,70 +1299,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
/>
)}
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
{helpOpen && (
<div
className="absolute inset-0 z-menu grid place-items-center bg-black/60 p-4"
onClick={(e) => {
e.stopPropagation();
setHelpOpen(false);
}}
>
<div
className="w-[min(92vw,26rem)] rounded-2xl border border-white/15 bg-neutral-900/95 p-5 text-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 font-semibold">
<Keyboard className="w-5 h-5" /> {t("plex.player.help")}
</div>
<button
onClick={() => setHelpOpen(false)}
className="p-1 rounded-lg hover:bg-white/10"
aria-label={t("plex.player.close")}
>
<X className="w-5 h-5" />
</button>
</div>
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
{(
[
[["Space", "K"], "plex.player.keys.playPause"],
[["←", "→"], "plex.player.keys.seek"],
[["Ctrl ←", "Ctrl →"], "plex.player.keys.fineSeek"],
...(detail?.kind === "episode"
? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]]
: []),
[["↑", "↓"], "plex.player.keys.volume"],
[["M"], "plex.player.keys.mute"],
[["A"], "plex.player.keys.audio"],
[["S"], "plex.player.keys.subtitle"],
[["F"], "plex.player.keys.fullscreen"],
[["I"], "plex.player.keys.info"],
[["D"], "plex.player.keys.stats"],
[["⌫", "Esc"], "plex.player.keys.back"],
[["H"], "plex.player.keys.help"],
] as [string[], string][]
).map(([keys, labelKey]) => (
<Fragment key={labelKey}>
<dt className="flex flex-wrap gap-1">
{keys.map((k) => (
<kbd
key={k}
className="rounded-sm bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
>
{k}
</kbd>
))}
</dt>
<dd className="text-white/80">{t(labelKey)}</dd>
</Fragment>
))}
</dl>
</div>
</div>
)}
{/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
{infoOpen && detail && (
<div
+7 -4
View File
@@ -5,7 +5,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Minus, Plus } from "lucide-react";
import { api, type PlexPlaylist } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
@@ -102,14 +103,16 @@ export default function PlexPlaylistAdd({
placeholder={t("plex.playlistAdd.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
<Button
variant="glass"
size="md"
className="shrink-0"
onClick={createNew}
disabled={!newName.trim() || busy}
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
>
<Plus className="h-4 w-4" />
{t("plex.playlistAdd.create")}
</button>
</Button>
</div>
<div
+4 -6
View File
@@ -35,7 +35,8 @@ import { api, type PlexCard } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
import { LoadingState, StateMessage } from "./QueryState";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
@@ -514,13 +515,10 @@ export default function PlexPlaylistView({
return (
<div className="mx-auto w-[90%] max-w-[1100px] p-4">
<button
onClick={onBack}
className="glass-card glass-hover mb-4 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs"
>
<Button variant="glass" size="xs" className="mb-4" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
{t("plex.backToLibrary")}
</button>
</Button>
<div className="glass rounded-2xl p-4 sm:p-6">
<div className="mb-4 flex flex-wrap items-center gap-3">
+1 -1
View File
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import SegmentedToggle from "./SegmentedToggle";
import SegmentedToggle from "./ui/SegmentedToggle";
import { usePlex, usePlexActions } from "./PlexProvider";
import { usePlexViewControls } from "./PlexViewBridge";
+1 -1
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Film, Tv } from "lucide-react";
import SearchBar from "./SearchBar";
import ActionMenu from "./ActionMenu";
import ActionMenu from "./ui/ActionMenu";
import { usePlex, usePlexActions } from "./PlexProvider";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
+4 -4
View File
@@ -9,10 +9,10 @@ import { type PanelLayout } from "../lib/panelLayout";
import { type MediaSourceKind } from "../lib/mediaSource";
import { useDebounced } from "../lib/useDebounced";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup from "./FilterChipGroup";
import Combobox, { type ComboboxOption } from "./Combobox";
import { type PanelItem } from "./ui/PanelGroups";
import SegmentedToggle from "./ui/SegmentedToggle";
import FilterChipGroup from "./ui/FilterChipGroup";
import Combobox, { type ComboboxOption } from "./ui/Combobox";
import SavedViewsPopover from "./SavedViewsPopover";
import PersonalViewsModal from "./PersonalViewsModal";
import { useSavedViews } from "./SavedViewsProvider";
+1 -1
View File
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import SegmentedToggle from "./SegmentedToggle";
import SegmentedToggle from "./ui/SegmentedToggle";
import { usePlexViewControls } from "./PlexViewBridge";
// The Discover ⟷ Browse switch, hosted in the header next to the library-scope pill (All/Movies/Shows).
+1 -1
View File
@@ -3,7 +3,7 @@ import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider";
import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
import { inputCls } from "./ui/form";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { Bug, Sparkles } from "lucide-react";
import { RELEASE_NOTES } from "../lib/releaseNotes";
import Modal from "./Modal";
import Modal from "./ui/Modal";
function fmtDate(d: string): string {
const t = new Date(d);
@@ -1,7 +1,7 @@
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 { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/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
+5 -8
View File
@@ -19,11 +19,12 @@ import {
} from "lucide-react";
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { LoadingState, StateMessage } from "./QueryState";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useConfirm } from "./ConfirmProvider";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Tooltip from "./ui/Tooltip";
const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
@@ -283,13 +284,9 @@ function MaintenanceSettings({
aria-label={t("scheduler.maintenance.batchLabel")}
className="w-28 bg-surface border border-border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
onClick={save}
disabled={!dirty || saving}
className="glass-card glass-hover px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
>
<Button variant="glass" size="sm" onClick={save} disabled={!dirty || saving}>
{t("common.save")}
</button>
</Button>
</div>
<div className="text-2xs text-muted mt-1.5">
{t("scheduler.maintenance.batchNote", {
+14 -18
View File
@@ -7,17 +7,17 @@ import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-r
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api";
import { LS, useAccountPersistedState } from "../lib/storage";
import Avatar from "./Avatar";
import Avatar from "./ui/Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Tooltip from "./ui/Tooltip";
import { inputCls, Section, SettingRow, Switch } from "./ui/form";
import { Button } from "./ui/Button";
import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider";
import { StateMessage } from "./QueryState";
import { StateMessage } from "./ui/QueryState";
import { useWizard } from "./WizardProvider";
import { usePrefs } from "./PrefsProvider";
import Tabs from "./Tabs";
import Tabs from "./ui/Tabs";
import { useMe } from "../lib/useMe";
// The Settings page edits server-persisted preferences. Each change applies locally for instant
@@ -324,12 +324,9 @@ function AccessRow({
</span>
) : (
<Tooltip hint={t("settings.account.enableHint")}>
<button
onClick={onEnable}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
<Button variant="glass" size="sm" className="shrink-0" onClick={onEnable}>
{t("settings.account.enable")}
</button>
</Button>
</Tooltip>
)}
</div>
@@ -498,13 +495,15 @@ function SignInMethods({ me }: { me: Me }) {
{t("settings.account.sessions.hint")}
</p>
</div>
<button
<Button
variant="glass"
size="sm"
className="shrink-0"
onClick={logoutOthers}
disabled={sessBusy}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition disabled:opacity-40"
>
{t("settings.account.sessions.action")}
</button>
</Button>
</div>
</div>
</Section>
@@ -676,7 +675,7 @@ export function DownloadAuth() {
<div className="flex items-center gap-2 shrink-0">
{h.configured && (
<Button
variant="danger-outline"
variant="danger-soft"
size="sm"
onClick={() => onRemove(h.host_key)}
disabled={remove.isPending}
@@ -833,13 +832,10 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
<p className="text-xs text-muted leading-relaxed mb-2">
{t("settings.account.deleteHint")}
</p>
<button
onClick={onDeleteAccount}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-danger/40 text-danger hover:bg-danger/10 transition"
>
<Button variant="danger-soft" size="md" onClick={onDeleteAccount}>
<Trash2 className="w-4 h-4" />
{t("settings.account.deleteAccount")}
</button>
</Button>
</Section>
)}
</>
+6 -3
View File
@@ -4,6 +4,7 @@ import { Check, ChevronLeft, ChevronRight, Loader2, Send } from "lucide-react";
import { api, HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
import { Button } from "./ui/Button";
import { inputCls } from "./ui/form";
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
@@ -239,15 +240,17 @@ export default function SetupWizard() {
placeholder={t("setup.smtp.testTo")}
className={inputCls}
/>
<button
<Button
variant="glass"
size="md"
className="shrink-0"
type="button"
onClick={sendTest}
disabled={testState === "sending" || !testTo.trim()}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Send className={`w-4 h-4 ${testState === "sending" ? "animate-pulse" : ""}`} />
{t("setup.smtp.test")}
</button>
</Button>
</div>
)}
{testState === "ok" && (
+25 -19
View File
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import clsx from "clsx";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
@@ -138,31 +138,39 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
value={fullUrl}
className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden"
/>
<button
<Button
variant="ghost"
size="icon-sm"
onClick={copy}
aria-label={t("downloads.share.copy")}
title={t("downloads.share.copy")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
>
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</button>
<button
</Button>
{/* All three on the same rung. Only Copy was migrated in S1, which left one 2rem button
between two 1.75rem ones with a different radius and hover fill — and, on a coarse
pointer, `.touch-target` grew ONLY the migrated one to 2.75rem while the destructive
Revoke stayed small (E10 review round 1). */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
setNewPass("");
setEditPass((v) => !v);
}}
title={
label={
link.has_password
? t("downloads.share.changePassword")
: t("downloads.share.setPassword")
}
className={clsx(
"p-1.5 rounded-md hover:bg-surface transition",
link.has_password ? "text-accent" : "text-muted hover:text-fg",
)}
>
<Lock className="w-4 h-4" />
</button>
<button
// On the ICON, not the button: `clsx` does not merge Tailwind, so a colour className loses
// to the variant's own `text-muted`/`hover:text-fg` by stylesheet order and the state was
// simply invisible (E10 review round 2). The Copy button above already does it this way.
icon={<Lock className={`w-4 h-4 ${link.has_password ? "text-accent" : ""}`} />}
/>
<Button
variant="ghost"
size="icon-sm"
onClick={async () => {
if (
await confirm({
@@ -174,11 +182,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
)
revoke.mutate();
}}
title={t("downloads.share.revoke")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-danger transition"
>
<Trash2 className="w-4 h-4" />
</button>
label={t("downloads.share.revoke")}
icon={<Trash2 className="w-4 h-4 text-danger" />}
/>
</div>
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
<span className="inline-flex items-center gap-1">
+2 -2
View File
@@ -11,12 +11,12 @@ import TagManager from "./TagManager";
import SavedViewsPopover from "./SavedViewsPopover";
import { useSavedViews } from "./SavedViewsProvider";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups";
import { type PanelItem } from "./ui/PanelGroups";
import { type MediaSourceKind } from "../lib/mediaSource";
import ChannelPicker from "./ChannelPicker";
import DateFilterSelect from "./DateFilterSelect";
import YearFilter from "./YearFilter";
import Combobox, { type ComboboxOption } from "./Combobox";
import Combobox, { type ComboboxOption } from "./ui/Combobox";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useMe } from "../lib/useMe";
+14 -5
View File
@@ -6,11 +6,13 @@ import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { useWizard } from "./WizardProvider";
import { LS, useAccountPersistedState } from "../lib/storage";
import Tooltip from "./Tooltip";
import { LoadingState, StateMessage } from "./QueryState";
import FilterChipGroup from "./FilterChipGroup";
import Tabs from "./Tabs";
import Tooltip from "./ui/Tooltip";
import { LoadingState, StateMessage } from "./ui/QueryState";
import FilterChipGroup from "./ui/FilterChipGroup";
import Tabs from "./ui/Tabs";
import { Section, SettingRow } from "./ui/form";
import { useMe } from "../lib/useMe";
@@ -51,6 +53,8 @@ export default function Stats() {
function Overview({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
// The 403 case needs somewhere to go, or the toast names a fix the user cannot reach from here.
const { openWizard } = useWizard();
const status = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
const usage = useQuery({ queryKey: qk.myUsage(), queryFn: api.myUsage });
const s = status.data;
@@ -64,7 +68,12 @@ function Overview({ me }: { me: Me }) {
message: t("stats.sync.synced", { count: r.subscriptions ?? 0 }),
});
},
onError: () => notify({ level: "error", message: t("stats.sync.syncFailed") }),
// The same helper and the same wizard handle as the Channels page: it translates the 403 into
// "connect YouTube" WITH a button that opens the wizard — instead of surfacing the route's bare
// English detail under a Hungarian UI — and delegates everything else to notifyActionError so
// the server's own reason wins, which is what makes the SEC-14 cooldown read as a pace limit
// rather than "Sync failed".
onError: (e) => notifyYouTubeActionError(e, t("stats.sync.syncFailed"), openWizard),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
+1 -1
View File
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api";
import { formatViews } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
// Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full
+4 -4
View File
@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
import { filterChipClass } from "./FilterChipGroup";
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { filterChipClass } from "./ui/FilterChipGroup";
import { useAnchoredPopover, PopoverSurface } from "./ui/AnchoredPopover";
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
@@ -74,7 +74,7 @@ export default function TagFilterChips({
const moreLabel = t("channels.tags.moreCount", { count: hidden });
return (
<div className={`relative flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
read each chip's right edge, the rem gap and the pill width off real boxes. */}
<div
+8 -5
View File
@@ -6,9 +6,10 @@ import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../lib/api";
import { notify } from "../lib/notifications";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
// hover popover on the count listing the tagged channels (each a link that focuses it in the
@@ -203,13 +204,15 @@ export default function TagManager({
placeholder={t("tagManager.newPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
<Button
type="submit"
variant="outline"
size="sm"
className="shrink-0"
disabled={!newName.trim()}
className="shrink-0 px-3 py-1.5 rounded-md border border-border text-sm text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-40 transition"
>
{t("tagManager.add")}
</button>
</Button>
</form>
</div>
</Modal>
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { UploadCloud, File as FileIcon } from "lucide-react";
import Modal from "./Modal";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
import { api } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";

Some files were not shown because too many files have changed in this diff Show More