Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2a8503496 | ||
|
|
a389306885 | ||
|
|
32c05897f4 | ||
|
|
2e319be569 | ||
|
|
852b227ea2 | ||
|
|
06b85586e6 | ||
|
|
fc32188fd2 | ||
|
|
9538fb1b7a | ||
|
|
5051809940 | ||
|
|
64dde6fd36 | ||
|
|
38921fb997 | ||
|
|
2d1a7ff374 | ||
|
|
361ca7872e | ||
|
|
81b7660232 | ||
|
|
7caa2636e4 | ||
|
|
1e894d378d |
@@ -12,12 +12,12 @@ provider.
|
||||
|
||||
## The gate — never merge or ship red
|
||||
|
||||
The local gate is **ruff + typecheck (3 tsc projects) + eslint + prettier --check + knip + vitest +
|
||||
pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
|
||||
The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc projects) + eslint + prettier
|
||||
--check + knip + vitest + pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
|
||||
`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review.
|
||||
|
||||
- Full gate (agents with the private ops dispatcher): `siftlode check`.
|
||||
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` and `pytest` (the DB-lane needs Postgres).
|
||||
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` + `ruff format --check backend` and `pytest` (the DB-lane needs Postgres).
|
||||
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
|
||||
|
||||
## Workflow
|
||||
@@ -26,12 +26,33 @@ pytest**. It runs on every `publish`, which **refuses to build on failure — th
|
||||
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 is a deliberate, user-owned step** (`siftlode release` from the feature branch: merge to
|
||||
`main` → publish → deploy → verify `/api/version`). Do not self-ship; bring work to a green,
|
||||
committed state and stop for the user's go-ahead.
|
||||
- **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
|
||||
should assume or leak them.
|
||||
|
||||
## Collaboration — how to work with the human
|
||||
|
||||
The human is the product owner: they bring ideas and requirements. You are the technical lead — you own
|
||||
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*
|
||||
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
|
||||
choose from before building.
|
||||
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
|
||||
arguments, push back or propose an alternative — don't comply blindly.
|
||||
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
|
||||
bugs; do a real visual check of any UI change.
|
||||
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
|
||||
implementation and debugging especially: no restating the same point reworded, no filler.
|
||||
|
||||
## Architecture shape (don't fight it)
|
||||
|
||||
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
||||
@@ -79,5 +100,6 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
|
||||
---
|
||||
|
||||
*A fuller written-conventions doc (`docs/development.md`) is planned; once it lands, this file slims to a
|
||||
pointer plus the gate/workflow/no-attribution rules. Until then, the table above is the source of truth.*
|
||||
*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.*
|
||||
|
||||
@@ -303,6 +303,54 @@ def test_remove_item_writes_through_to_plex(db, monkeypatch):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _resync_create(db, plex, lib, rk):
|
||||
"""Create-path resync stub: the new rating_key has no local row yet, so materialize it."""
|
||||
col = PlexCollection(rating_key=rk, library_id=lib.id, title="Fresh Coll", child_count=1)
|
||||
db.add(col)
|
||||
db.flush()
|
||||
return col
|
||||
|
||||
|
||||
def test_create_writes_through_to_plex(db, monkeypatch):
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_create)
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert "create_collection" in stub.calls
|
||||
body = r.json()
|
||||
assert body["id"] == "NEW" # the stub's new ratingKey
|
||||
assert body["editable"] is True # create marks the new collection editable
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_create_resync_failure_is_422(db, monkeypatch):
|
||||
# The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with
|
||||
# the honest "reconcile on the next sync" detail, not a false "write failed".
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
|
||||
def _boom(db_, plex, lib_, rk):
|
||||
raise PlexError("read failed")
|
||||
|
||||
client = _admin_client(db, user, monkeypatch, stub, resync=_boom)
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
assert "reconcile" in r.json()["detail"].lower()
|
||||
assert "create_collection" in stub.calls
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mutation_failure_returns_422_write_failed(db, monkeypatch):
|
||||
# The Plex mutation itself raised → the write never landed → 422 "Plex write failed", and resync
|
||||
# is never reached (stub.calls holds only the mutating call).
|
||||
|
||||
+2
-1
@@ -29,8 +29,9 @@ A change is done when all of this is green. CI runs the same checks server-side
|
||||
**Backend**
|
||||
|
||||
```bash
|
||||
# lint
|
||||
# lint + format check
|
||||
python -m ruff check --config backend/ruff.toml backend scripts
|
||||
python -m ruff format --check --config backend/ruff.toml backend scripts
|
||||
|
||||
# tests — pure-logic + the DB-backed integration lane, against an ephemeral Postgres.
|
||||
# The backend tree is bind-mounted, so it always tests your working files.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
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";
|
||||
|
||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||
@@ -131,52 +132,36 @@ export default function ActiveFilterChips({
|
||||
const chipRefs = useRef<Array<HTMLSpanElement | null>>([]);
|
||||
const plusRef = useRef<HTMLSpanElement | null>(null);
|
||||
const clearRef = useRef<HTMLSpanElement | null>(null);
|
||||
// How the row is currently laid out: all chips, a fit-count + "+N", or a single count pill.
|
||||
const [layout, setLayout] = useState<{ mode: "all" | "some" | "count"; visible: number }>({
|
||||
mode: "all",
|
||||
visible: filters.length,
|
||||
});
|
||||
|
||||
const n = filters.length;
|
||||
// Re-measure whenever the chip set changes (labels or count) — captures both in one dep.
|
||||
const chipsKey = filters.map((f) => f.key + f.label).join("|");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const lane = laneRef.current;
|
||||
const labelEl = labelRef.current;
|
||||
if (!lane || !labelEl || n === 0) return;
|
||||
|
||||
const measure = () => {
|
||||
const rightOf = (el: HTMLElement | null | undefined) =>
|
||||
el ? el.offsetLeft + el.offsetWidth : 0;
|
||||
const chips = chipRefs.current.slice(0, n);
|
||||
const plusEl = plusRef.current;
|
||||
// Measure the real rendered gap off the box between the last chip and the "+N" sample, so it
|
||||
// scales with the text-size slider (gap-1.5 is rem) rather than being frozen in px.
|
||||
const lastChip = chips[n - 1];
|
||||
const gap = plusEl && lastChip ? Math.max(0, plusEl.offsetLeft - rightOf(lastChip)) : 6;
|
||||
// "all" mode also renders a Clear all button after the chips (when n > 1), so reserve its width.
|
||||
const clearW = n > 1 ? gap + (clearRef.current?.offsetWidth ?? 0) : 0;
|
||||
|
||||
setLayout(
|
||||
decideChipLayout({
|
||||
n,
|
||||
labelRight: rightOf(labelEl),
|
||||
chipRights: chips.map((c) => rightOf(c)),
|
||||
plusW: plusEl?.offsetWidth ?? 0,
|
||||
clearW,
|
||||
avail: lane.clientWidth,
|
||||
gap,
|
||||
narrowPx: NARROW_PX,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(lane);
|
||||
return () => ro.disconnect();
|
||||
}, [n, chipsKey]);
|
||||
// The shared measurement core hands over the chip right edges + gap + pill width + lane width; this
|
||||
// policy adds the feed row's own framing (the leading label's right edge, and the trailing "Clear
|
||||
// all" button's width, reserved only when it renders — n > 1) before deciding all/some/count.
|
||||
const layout = useChipOverflowFit<{ mode: "all" | "some" | "count"; visible: number }>({
|
||||
rowRef: laneRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef: plusRef,
|
||||
deps: `${n}|${chipsKey}`,
|
||||
initial: { mode: "all", visible: n },
|
||||
decide: (g) => {
|
||||
const labelEl = labelRef.current;
|
||||
const labelRight = labelEl ? labelEl.offsetLeft + labelEl.offsetWidth : 0;
|
||||
const clearW = n > 1 ? g.gap + (clearRef.current?.offsetWidth ?? 0) : 0;
|
||||
return decideChipLayout({
|
||||
n,
|
||||
labelRight,
|
||||
chipRights: g.chipRights,
|
||||
plusW: g.pillW,
|
||||
clearW,
|
||||
avail: g.avail,
|
||||
gap: g.gap,
|
||||
narrowPx: NARROW_PX,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (n === 0) return null;
|
||||
|
||||
|
||||
@@ -77,8 +77,10 @@ export function Carousel({
|
||||
|
||||
const label = ariaLabel ?? (typeof title === "string" ? title : undefined);
|
||||
|
||||
// Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover`
|
||||
// removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap.
|
||||
const arrowBtn =
|
||||
"absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
|
||||
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
|
||||
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
|
||||
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../lib/api";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { 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:
|
||||
@@ -154,62 +156,25 @@ export function ChannelTagsControl({
|
||||
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
|
||||
|
||||
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
|
||||
// natural widths; we read each chip's right edge and compute EXACTLY how many fit, reserving the
|
||||
// pill's width whenever any overflow. One pass (no iterative re-render), so the "+N" count is always
|
||||
// right as tags are added/removed. Re-measures on width/scale change via ResizeObserver — off real
|
||||
// rem boxes, never a px guess.
|
||||
// natural widths; the shared measure core (useChipOverflowFit) reads each chip's right edge, the rem
|
||||
// gap and the pill width off real boxes, and fitChipsWithPill computes EXACTLY how many fit (reserving
|
||||
// the pill whenever any overflow). One pass — the count is right as tags are added/removed/renamed —
|
||||
// and it re-measures on width/scale change via the hook's ResizeObserver.
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureRef = useRef<HTMLDivElement | null>(null);
|
||||
const [visible, setVisible] = useState(attached.length);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
// Key on id AND name so a RENAME (which changes a chip's width) re-runs the fit, not just add/remove.
|
||||
const tagsKey = attached.map((tg) => `${tg.id}:${tg.name}`).join("|");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!row || !measure) return;
|
||||
const compute = () => {
|
||||
const nodes = Array.from(measure.children) as HTMLElement[];
|
||||
const pill = nodes[nodes.length - 1];
|
||||
if (!pill) return;
|
||||
const pillW = pill.offsetWidth;
|
||||
const chips = nodes.slice(0, -1);
|
||||
// Measure the real rendered gap (gap-1 is rem — it scales with the font slider) off the box
|
||||
// between the last chip and the sample pill, rather than assuming a px constant.
|
||||
const lastChip = chips[chips.length - 1];
|
||||
const gap = lastChip
|
||||
? Math.max(0, pill.offsetLeft - (lastChip.offsetLeft + lastChip.offsetWidth))
|
||||
: 0;
|
||||
const avail = row.clientWidth;
|
||||
// Whole-set fast path: if the LAST chip's right edge already fits, every chip fits and no "+N"
|
||||
// pill is needed. The reservation loop below reserves the pill's width for every chip except
|
||||
// the last and breaks at the first overflow, so on its own it never reaches the only "does the
|
||||
// whole set fit?" test — it would collapse chips that would have fitted (e.g. a short trailing
|
||||
// chip narrower than the pill), leaving "+N" beside empty space.
|
||||
const lastRight = lastChip ? lastChip.offsetLeft + lastChip.offsetWidth : 0;
|
||||
if (lastRight <= avail) {
|
||||
setVisible(chips.length);
|
||||
return;
|
||||
}
|
||||
let count = chips.length;
|
||||
for (let i = 0; i < chips.length; i++) {
|
||||
const chip = chips[i];
|
||||
if (!chip) break;
|
||||
const right = chip.offsetLeft + chip.offsetWidth; // right edge within the measurer
|
||||
const moreAfter = i < chips.length - 1;
|
||||
if (right > avail - (moreAfter ? pillW + gap : 0)) {
|
||||
count = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setVisible(count);
|
||||
};
|
||||
compute();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(compute);
|
||||
ro.observe(row);
|
||||
return () => ro.disconnect();
|
||||
}, [tagsKey]);
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: tagsKey,
|
||||
initial: attached.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
|
||||
const hidden = attached.length - visible;
|
||||
|
||||
@@ -249,16 +214,27 @@ export function ChannelTagsControl({
|
||||
<div
|
||||
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
>
|
||||
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */}
|
||||
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. Each
|
||||
chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its right edge;
|
||||
the pill is the sample the hook reserves room for. */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{attached.map((tg) => (
|
||||
<TagChip key={tg.id} tag={tg} />
|
||||
{attached.map((tg, i) => (
|
||||
<span
|
||||
key={tg.id}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<TagChip tag={tg} />
|
||||
</span>
|
||||
))}
|
||||
<span className={pillClass}>+{attached.length}</span>
|
||||
<span ref={pillRef} className={pillClass}>
|
||||
+{attached.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
|
||||
@@ -19,14 +19,6 @@ describe("loadAccountFilters — legacy tagMode migration on seed", () => {
|
||||
expect(f.q).toBe("hi"); // other fields preserved through the merge
|
||||
});
|
||||
|
||||
it("migrates a legacy tagMode:'and' from the default-view mirror when no last-session blob exists", () => {
|
||||
localStorage.setItem(
|
||||
accountKey(LS.defaultViewFilters, ID)!,
|
||||
JSON.stringify({ tagMode: "and" }),
|
||||
);
|
||||
expect(loadAccountFilters(ID).tagModes.topic).toBe("and");
|
||||
});
|
||||
|
||||
it("leaves tagModes at defaults when nothing is stored", () => {
|
||||
expect(loadAccountFilters(ID).tagModes).toEqual({ language: "or", topic: "or", other: "or" });
|
||||
});
|
||||
|
||||
@@ -51,16 +51,16 @@ const DEFAULT_FILTERS: FeedFilters = {
|
||||
|
||||
const FILTERS_KEY = LS.filters;
|
||||
|
||||
// This account's own persisted filters (per-account keys — never another account's). A chosen
|
||||
// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When
|
||||
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
|
||||
// post-login effect loads the real ones once the id arrives.
|
||||
// This account's own persisted filters (per-account keys — never another account's). When the
|
||||
// account isn't known yet (first load, before the tab is pinned), start from defaults; the post-login
|
||||
// effect loads the real ones once the id arrives.
|
||||
export function loadAccountFilters(id: number | null): FeedFilters {
|
||||
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The
|
||||
// starred default view only SEEDS a fresh account that has never stored filters: it drives the
|
||||
// very first load, after which that state persists like any other. (Re-apply the default view
|
||||
// from the sidebar to return to it.)
|
||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
|
||||
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
|
||||
// SavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
|
||||
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
|
||||
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
|
||||
const fKey = accountKey(FILTERS_KEY, id);
|
||||
let hasStored = false;
|
||||
try {
|
||||
@@ -68,19 +68,14 @@ export function loadAccountFilters(id: number | null): FeedFilters {
|
||||
} catch {
|
||||
/* localStorage may be unavailable */
|
||||
}
|
||||
// Migrate a persisted legacy global `tagMode` into the per-category `tagModes` on every seed path,
|
||||
// matching what the saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes
|
||||
// MUST run on the RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects
|
||||
// DEFAULT_TAG_MODES (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
|
||||
// Migrate a persisted legacy global `tagMode` into the per-category `tagModes`, matching what the
|
||||
// saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes MUST run on the
|
||||
// RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects DEFAULT_TAG_MODES
|
||||
// (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
|
||||
if (hasStored && fKey) {
|
||||
const raw = readJSON<Partial<FeedFilters>>(fKey, {});
|
||||
return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) };
|
||||
}
|
||||
const dvKey = accountKey(LS.defaultViewFilters, id);
|
||||
const def = dvKey ? readJSON<Partial<FeedFilters> | null>(dvKey, null) : null;
|
||||
if (def) {
|
||||
return { ...DEFAULT_FILTERS, ...def, tagModes: coerceTagModes(def) };
|
||||
}
|
||||
return { ...DEFAULT_FILTERS };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The scroll-restoration contract PageScroller implements: offsets are remembered per
|
||||
// (history entry id + content key), so Back to an entry restores where you left it while a forward
|
||||
// navigation (a new entry id) or a new content key opens at the top. jsdom lays nothing out, so we
|
||||
// feed the <main> fake metrics, drive restoreScroll's rAF loop synchronously, and steer the entry id
|
||||
// through window.history.state (what ensureNavId/peekNavId read) — firing the nav bus (notifyNav)
|
||||
// stands in for a Back/Forward that doesn't re-render the scroller.
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
import PageScroller from "./PageScroller";
|
||||
import { notifyNav } from "../lib/history";
|
||||
|
||||
beforeAll(() => {
|
||||
// useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none.
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// restoreScroll re-applies the offset over a few rAF frames (the feed needs time to reach height);
|
||||
// run rAF synchronously so a restore lands within the test tick.
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", () => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function setEntry(id: number) {
|
||||
window.history.replaceState({ sfScroll: id }, "");
|
||||
}
|
||||
|
||||
function sizedMain(container: HTMLElement): HTMLElement {
|
||||
const main = container.querySelector("main")!;
|
||||
// Enough scrollable room for a 300px offset (restoreScroll only sets when scrollHeight-clientHeight
|
||||
// >= the target), and a writable scrollTop jsdom otherwise no-ops.
|
||||
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(main, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(main, "scrollTop", { configurable: true, writable: true, value: 0 });
|
||||
return main;
|
||||
}
|
||||
|
||||
describe("PageScroller scroll memory", () => {
|
||||
it("restores per history entry: a forward nav opens at the top, Back restores the offset", () => {
|
||||
setEntry(1);
|
||||
const { container } = render(<PageScroller scrollKey="e:k1">x</PageScroller>);
|
||||
const main = sizedMain(container);
|
||||
|
||||
// The user scrolls entry 1 to 200 — recorded live on scroll.
|
||||
main.scrollTop = 200;
|
||||
fireEvent.scroll(main);
|
||||
|
||||
// Forward navigation → a fresh entry id, same content key → the view opens at the top.
|
||||
setEntry(2);
|
||||
notifyNav();
|
||||
expect(main.scrollTop).toBe(0);
|
||||
|
||||
// Back to entry 1 → its saved offset is restored.
|
||||
setEntry(1);
|
||||
notifyNav();
|
||||
expect(main.scrollTop).toBe(200);
|
||||
});
|
||||
|
||||
it("restores per content key within one entry: a new key resets to top, returning restores", () => {
|
||||
setEntry(10);
|
||||
const { container, rerender } = render(<PageScroller scrollKey="e10:a">x</PageScroller>);
|
||||
const main = sizedMain(container);
|
||||
|
||||
main.scrollTop = 300;
|
||||
fireEvent.scroll(main);
|
||||
|
||||
// A new content key (e.g. a fresh feed filter) in the SAME entry starts at the top.
|
||||
rerender(<PageScroller scrollKey="e10:b">x</PageScroller>);
|
||||
expect(main.scrollTop).toBe(0);
|
||||
|
||||
// Returning to the first key restores its offset.
|
||||
rerender(<PageScroller scrollKey="e10:a">x</PageScroller>);
|
||||
expect(main.scrollTop).toBe(300);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import Avatar from "./Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
||||
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||
import type { YTPlayer } from "../lib/youtube";
|
||||
@@ -337,7 +337,10 @@ export default function PlayerModal({
|
||||
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.
|
||||
// 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;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import Modal from "./Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
@@ -102,8 +102,9 @@ export default function PlexCollectionEditor({
|
||||
setMembers((prev) => new Set(prev).add(col.id));
|
||||
refresh();
|
||||
} catch (e) {
|
||||
// Surfaces the 409 when the name duplicates an existing collection (Plex conflates same names).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
// The backend rejects a duplicate name with 409 (Plex conflates same names) — localize it to
|
||||
// nameTaken rather than surfacing the server's raw English detail.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S4 Play-all — buildQueue's show→episode expansion and playAll's decision branch, the honest-states
|
||||
// behaviour the deep-review flagged as untested. A leaf member queues as itself; a show member is
|
||||
// expanded (lazily, on click) into its episodes in season→episode order; a show that expands to zero
|
||||
// episodes must NOT silently no-op but surface the `playEmpty` info notice. api.plexCollection /
|
||||
// api.plexShow are stubbed so the queries resolve synchronously; notify is spied to assert the notice.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
beforeAll(() => {
|
||||
// Poster grids / carousels wire a ResizeObserver; jsdom ships none.
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
// Spy notify without dropping the module's other exports (the render subtree uses them).
|
||||
const h = vi.hoisted(() => ({ notify: vi.fn() }));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify: (...a: unknown[]) => h.notify(...a),
|
||||
}));
|
||||
|
||||
import PlexCollectionView from "./PlexCollectionView";
|
||||
import { api, type PlexCard, type PlexCollectionDetail, type PlexShowDetail } from "../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
h.notify.mockClear();
|
||||
});
|
||||
|
||||
const card = (id: string, type: "movie" | "show"): PlexCard =>
|
||||
({
|
||||
id,
|
||||
type,
|
||||
title: id,
|
||||
year: 2000,
|
||||
duration_seconds: 3600,
|
||||
thumb: `/api/plex/image/${id}`,
|
||||
playable: "direct",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
}) as unknown as PlexCard;
|
||||
|
||||
const detail = (items: PlexCard[]): PlexCollectionDetail =>
|
||||
({
|
||||
id: "col1",
|
||||
title: "My Collection",
|
||||
thumb: "/api/plex/image/col1",
|
||||
year_min: null,
|
||||
year_max: null,
|
||||
content_rating: null,
|
||||
child_count: items.length,
|
||||
total_runtime_s: 0,
|
||||
items,
|
||||
can_edit: false,
|
||||
editable: false,
|
||||
}) as unknown as PlexCollectionDetail;
|
||||
|
||||
const showDetail = (episodeIds: string[][]): PlexShowDetail =>
|
||||
({
|
||||
seasons: episodeIds.map((eps) => ({ episodes: eps.map((id) => ({ id })) })),
|
||||
}) as unknown as PlexShowDetail;
|
||||
|
||||
function mount(items: PlexCard[], onPlay = vi.fn()) {
|
||||
vi.spyOn(api, "plexCollection").mockResolvedValue(detail(items));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexCollectionView
|
||||
collectionId="col1"
|
||||
onBack={() => {}}
|
||||
onPlay={onPlay}
|
||||
onOpenCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return onPlay;
|
||||
}
|
||||
|
||||
describe("PlexCollectionView — Play-all", () => {
|
||||
it("queues a leaf member as itself", async () => {
|
||||
const onPlay = mount([card("movie1", "movie")]);
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
|
||||
expect(onPlay).toHaveBeenCalledWith("movie1", ["movie1"]);
|
||||
expect(h.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expands a show member into its episodes in season→episode order", async () => {
|
||||
const spyShow = vi
|
||||
.spyOn(api, "plexShow")
|
||||
.mockResolvedValue(showDetail([["s1e1", "s1e2"], ["s2e1"]]));
|
||||
const onPlay = mount([card("show1", "show")]);
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
|
||||
expect(spyShow).toHaveBeenCalledWith("show1");
|
||||
expect(onPlay).toHaveBeenCalledWith("s1e1", ["s1e1", "s1e2", "s2e1"]);
|
||||
});
|
||||
|
||||
it("surfaces the playEmpty notice (not a silent no-op) when a show expands to zero episodes", async () => {
|
||||
vi.spyOn(api, "plexShow").mockResolvedValue(showDetail([])); // no seasons → empty queue
|
||||
const onPlay = mount([card("show1", "show")]);
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "info", message: "plex.collection.playEmpty" }),
|
||||
),
|
||||
);
|
||||
expect(onPlay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,13 @@ import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "luc
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { notifyActionError, notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { showEpisodeKeys } from "../lib/plexQueue";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
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";
|
||||
|
||||
@@ -57,6 +59,9 @@ export default function PlexCollectionView({
|
||||
const [name, setName] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Building the play queue can await show → episode expansion (see buildQueue); disable Play/Shuffle
|
||||
// meanwhile so a double-tap can't fire two queues.
|
||||
const [queueBusy, setQueueBusy] = useState(false);
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexCollections() });
|
||||
@@ -64,8 +69,48 @@ export default function PlexCollectionView({
|
||||
};
|
||||
|
||||
const items = d?.items ?? [];
|
||||
// The Play queue is the playable-leaf members (movies/episodes) in order — a show can't queue directly.
|
||||
const playable = items.filter((c) => c.type !== "show").map((c) => c.id);
|
||||
// Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so
|
||||
// Play-all expands it into its episodes (buildQueue). Either kind means there's something to play —
|
||||
// this is what a show-collection needs: before, `playable` held only leaves, so a show-collection's
|
||||
// Play-all was empty and did nothing.
|
||||
const leafKeys = items.filter((c) => c.type !== "show").map((c) => c.id);
|
||||
const hasShows = items.some((c) => c.type === "show");
|
||||
const canPlay = leafKeys.length > 0 || hasShows;
|
||||
|
||||
// Build the ordered play queue: leaf members as themselves, each show member expanded into its
|
||||
// episodes (season then episode order). Shows are fetched lazily HERE (on Play-all/Shuffle click),
|
||||
// in parallel, but concatenated in member order so the queue follows the grid — not on page load,
|
||||
// which would fetch every show up front for a button that may never be pressed.
|
||||
async function buildQueue(): Promise<string[]> {
|
||||
const perMember = await Promise.all(
|
||||
items.map(async (c) => {
|
||||
if (c.type !== "show") return [c.id];
|
||||
const detail = await qc.fetchQuery({
|
||||
queryKey: qk.plexShow(c.id),
|
||||
queryFn: () => api.plexShow(c.id),
|
||||
});
|
||||
return showEpisodeKeys(detail);
|
||||
}),
|
||||
);
|
||||
return perMember.flat();
|
||||
}
|
||||
|
||||
async function playAll(shuffle: boolean) {
|
||||
setQueueBusy(true);
|
||||
try {
|
||||
let queue = await buildQueue();
|
||||
if (shuffle) queue = shuffled(queue);
|
||||
// canPlay gates on "has a show member", but a show can expand to zero episodes (unmatched /
|
||||
// empty). An enabled Play-all that silently no-ops is a dishonest state, so say so plainly.
|
||||
if (queue.length > 0) onPlay(queue[0]!, queue);
|
||||
else notify({ level: "info", message: t("plex.collection.playEmpty") });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.playFailed"));
|
||||
} finally {
|
||||
setQueueBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canEdit = isAdmin && !!d?.can_edit;
|
||||
// A plain (non-smart, non-external) collection can be admin-enabled for editing. Shares
|
||||
// isIntrinsicReadOnly with the browse tile so the lock/edit affordance can't drift between them.
|
||||
@@ -99,7 +144,8 @@ export default function PlexCollectionView({
|
||||
invalidate();
|
||||
setRenaming(false);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
// The backend enforces per-library name uniqueness — localize its 409 to nameTaken.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -170,9 +216,9 @@ export default function PlexCollectionView({
|
||||
<DetailHero
|
||||
thumb={d.thumb ?? `/api/plex/image/${d.id}`}
|
||||
posterTitle={t("plex.play")}
|
||||
posterDisabled={playable.length === 0}
|
||||
showPlayOverlay={playable.length > 0}
|
||||
onPosterClick={() => playable.length > 0 && onPlay(playable[0]!, playable)}
|
||||
posterDisabled={!canPlay || queueBusy}
|
||||
showPlayOverlay={canPlay}
|
||||
onPosterClick={() => canPlay && !queueBusy && playAll(false)}
|
||||
customizeMenu={null}
|
||||
>
|
||||
{renaming ? (
|
||||
@@ -231,19 +277,18 @@ export default function PlexCollectionView({
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{playable.length > 0 && (
|
||||
{canPlay && (
|
||||
<>
|
||||
<ActionBtn
|
||||
primary
|
||||
onClick={() => onPlay(playable[0]!, playable)}
|
||||
onClick={() => playAll(false)}
|
||||
disabled={queueBusy}
|
||||
icon={Play}
|
||||
label={t("plex.collection.playAll")}
|
||||
/>
|
||||
<ActionBtn
|
||||
onClick={() => {
|
||||
const order = shuffled(playable);
|
||||
onPlay(order[0]!, order);
|
||||
}}
|
||||
onClick={() => playAll(true)}
|
||||
disabled={queueBusy}
|
||||
icon={Shuffle}
|
||||
label={t("plex.collection.shuffle")}
|
||||
/>
|
||||
@@ -342,14 +387,7 @@ function AddItemPanel({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [term, setTerm] = useState("");
|
||||
const debounced = useDebounced(term.trim(), 300);
|
||||
const [added, setAdded] = useState<Set<string>>(new Set());
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexLibrary("collection-add", scope, debounced),
|
||||
queryFn: () => api.plexLibrary({ scope, q: debounced, limit: 12 }),
|
||||
enabled: debounced.length > 0,
|
||||
});
|
||||
const results = q.data?.items ?? [];
|
||||
|
||||
async function add(card: PlexCard) {
|
||||
try {
|
||||
@@ -381,43 +419,24 @@ function AddItemPanel({
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{debounced.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : q.isError ? (
|
||||
<p className="text-muted text-sm">{t("common.loadError")}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{results.map((c) => {
|
||||
const inColl = existing.has(c.id) || added.has(c.id);
|
||||
return (
|
||||
<li key={c.id} className="flex items-center gap-3">
|
||||
<img
|
||||
src={c.thumb}
|
||||
alt=""
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{c.title}
|
||||
{c.year != null && <span className="text-muted"> · {c.year}</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => add(c)}
|
||||
disabled={inColl}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<PlexLibraryPicker
|
||||
scope={scope}
|
||||
term={term}
|
||||
emptyHint={<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>}
|
||||
renderAction={(c) => {
|
||||
const inColl = existing.has(c.id) || added.has(c.id);
|
||||
return (
|
||||
<button
|
||||
onClick={() => add(c)}
|
||||
disabled={inColl}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the shared library search-and-pick list (F6): it owns the debounced query and the
|
||||
// full loading/error/empty/no-results/list state machine so the two callers (AddItemPanel in
|
||||
// PlexCollectionView, NewCollectionPanel in plexBrowseLists) can't drift. The two things a caller
|
||||
// steers — the `enabled` gate (NewCollectionPanel holds it off until the collection name is valid)
|
||||
// and the `emptyHint` shown before a term is typed — are the pieces with no test before this file, so
|
||||
// they're covered here. i18n is key-passthrough and api/debounce are mocked so the test drives state
|
||||
// directly.
|
||||
import { type ComponentProps } from "react";
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
// Identity debounce so a passed term is reflected immediately.
|
||||
vi.mock("../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v }));
|
||||
|
||||
// api.plexLibrary stub — returns whatever the current test queued.
|
||||
const plexLibrary = vi.fn();
|
||||
vi.mock("../lib/api", () => ({ api: { plexLibrary: (...a: unknown[]) => plexLibrary(...a) } }));
|
||||
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
|
||||
const card = (id: string, title: string, year = 2000) => ({
|
||||
id,
|
||||
type: "movie" as const,
|
||||
title,
|
||||
year,
|
||||
thumb: `/api/plex/image/${id}`,
|
||||
});
|
||||
const result = (items: ReturnType<typeof card>[]) => ({
|
||||
scope: "both",
|
||||
total: items.length,
|
||||
offset: 0,
|
||||
limit: 12,
|
||||
items,
|
||||
episodes: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
plexLibrary.mockReset();
|
||||
plexLibrary.mockResolvedValue(result([]));
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const renderPicker = (props: Partial<ComponentProps<typeof PlexLibraryPicker>> = {}) => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexLibraryPicker
|
||||
scope="both"
|
||||
term=""
|
||||
renderAction={(c) => <button>act:{c.id}</button>}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("PlexLibraryPicker", () => {
|
||||
it("renders nothing and fires no query while disabled, even with a term", () => {
|
||||
const { container } = renderPicker({ enabled: false, term: "matrix" });
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(plexLibrary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the emptyHint (and fires no query) before a term is typed", () => {
|
||||
renderPicker({ term: "", emptyHint: <p>EMPTY_HINT</p> });
|
||||
expect(screen.getByText("EMPTY_HINT")).toBeTruthy();
|
||||
expect(plexLibrary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queries the library once enabled + a term are both present and renders the rows via renderAction", async () => {
|
||||
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix"), card("m2", "Reloaded")]));
|
||||
renderPicker({ term: "matrix" });
|
||||
|
||||
await waitFor(() => expect(screen.getByText("act:m1")).toBeTruthy());
|
||||
expect(screen.getByText("act:m2")).toBeTruthy();
|
||||
expect(screen.getByText("The Matrix")).toBeTruthy();
|
||||
expect(plexLibrary).toHaveBeenCalledWith({ scope: "both", q: "matrix", limit: 12 });
|
||||
});
|
||||
|
||||
it("renders the honest error state (not an empty/no-results state) when the query fails", async () => {
|
||||
plexLibrary.mockRejectedValue(new Error("boom"));
|
||||
renderPicker({ term: "matrix" });
|
||||
|
||||
await waitFor(() => expect(screen.getByText("common.loadError")).toBeTruthy());
|
||||
expect(screen.queryByText("plex.collection.addNoResults")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the no-results message when the query returns an empty list", async () => {
|
||||
plexLibrary.mockResolvedValue(result([]));
|
||||
renderPicker({ term: "zzzz" });
|
||||
|
||||
await waitFor(() => expect(screen.getByText("plex.collection.addNoResults")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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";
|
||||
|
||||
// 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
|
||||
// render the same result rows, differing only in the per-row action. Callers keep their own chrome
|
||||
// (search input, name field, close button) and supply `term` + `renderAction`; this owns the query,
|
||||
// the loading/error/empty states, and the row markup so those can't drift between the two surfaces.
|
||||
export default function PlexLibraryPicker({
|
||||
scope,
|
||||
term,
|
||||
enabled = true,
|
||||
emptyHint = null,
|
||||
renderAction,
|
||||
}: {
|
||||
scope: string;
|
||||
/** The raw search term; debounced internally. */
|
||||
term: string;
|
||||
/** Gate the query off (e.g. until a collection name is valid). When false, renders nothing. */
|
||||
enabled?: boolean;
|
||||
/** Shown while the (debounced) term is empty — a hint, or nothing. */
|
||||
emptyHint?: ReactNode;
|
||||
/** The trailing control for each result row (Add / Create-with / …). */
|
||||
renderAction: (card: PlexCard) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const debounced = useDebounced(term.trim(), 300);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexLibrary("collection-pick", scope, debounced),
|
||||
queryFn: () => api.plexLibrary({ scope, q: debounced, limit: 12 }),
|
||||
enabled: enabled && debounced.length > 0,
|
||||
});
|
||||
const results = q.data?.items ?? [];
|
||||
|
||||
if (!enabled) return null;
|
||||
if (debounced.length === 0) return <>{emptyHint}</>;
|
||||
if (q.isLoading) return <LoadingState label={t("plex.loading")} />;
|
||||
if (q.isError) return <p className="text-muted text-sm">{t("common.loadError")}</p>;
|
||||
if (results.length === 0)
|
||||
return <p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>;
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{results.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-3">
|
||||
<img
|
||||
src={c.thumb}
|
||||
alt=""
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{c.title}
|
||||
{c.year != null && <span className="text-muted"> · {c.year}</span>}
|
||||
</span>
|
||||
{renderAction(c)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
||||
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
|
||||
|
||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
@@ -602,7 +602,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
case "Escape":
|
||||
// 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).
|
||||
// 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 (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||
else if (menuOpenRef.current) {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S2 — the single default-application path. On the saved-views query's FIRST resolve, SavedViewsProvider
|
||||
// seeds the starred default view's filters into a genuinely fresh module (no stored filters), for BOTH
|
||||
// the feed and Plex — but leaves them alone once the account has its own stored filters. This is the
|
||||
// fix for the old feed-only LS mirror that raced FeedFiltersProvider's login effect and never applied a
|
||||
// Plex default at all. The sub-providers are mocked so we can assert exactly which setFilters is called;
|
||||
// api.savedViews is stubbed and driven through a real QueryClient.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
page: "feed" as "feed" | "plex",
|
||||
setFeedFilters: vi.fn(),
|
||||
setPlexFilters: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("./NavigationProvider", () => ({ useNavigation: () => ({ page: h.page }) }));
|
||||
vi.mock("./FeedFiltersProvider", () => ({
|
||||
useFeedFilters: () => ({
|
||||
tags: [],
|
||||
channelIds: [],
|
||||
tagModes: { language: "or", topic: "or", other: "or" },
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
}),
|
||||
useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }),
|
||||
}));
|
||||
vi.mock("./PlexProvider", () => ({
|
||||
usePlex: () => ({ filters: {} }),
|
||||
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
|
||||
}));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider";
|
||||
import { api, setActiveAccount, type SavedView } from "../lib/api";
|
||||
import { LS, accountKey } from "../lib/storage";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
|
||||
const ID = 7;
|
||||
const view = (over: Partial<SavedView>): SavedView => ({
|
||||
id: 1,
|
||||
name: "v",
|
||||
filters: {},
|
||||
position: 0,
|
||||
is_default: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
const { loading } = useSavedViews();
|
||||
return <div data-testid="loading">{String(loading)}</div>;
|
||||
}
|
||||
|
||||
async function mountResolved(views: SavedView[]) {
|
||||
vi.spyOn(api, "savedViews").mockResolvedValue(views);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SavedViewsProvider>
|
||||
<Probe />
|
||||
</SavedViewsProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
// Wait for the query to resolve so the first-resolve effect has run.
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(ID));
|
||||
h.page = "feed";
|
||||
h.setFeedFilters.mockClear();
|
||||
h.setPlexFilters.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
||||
it("feed: seeds the starred default into a fresh module (no stored filters)", async () => {
|
||||
await mountResolved([
|
||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||
]);
|
||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
|
||||
});
|
||||
|
||||
it("feed: on a fresh tab the query resolves before the account pins — defers, then seeds on the pin", async () => {
|
||||
// The provider mounts above <App>, which pins the tab-account only after /api/me; the ungated
|
||||
// views query resolves first. Seeding must WAIT for the account (not seed nothing and burn its
|
||||
// one-shot — the pre-fix regression that left a fresh tab on DEFAULT_FILTERS until a reload).
|
||||
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
|
||||
await mountResolved([
|
||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||
]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
// App pins the account; the reactive id re-fires the effect and the deferred seed lands.
|
||||
act(() => setActiveAccount(ID));
|
||||
await waitFor(() => expect(h.setFeedFilters).toHaveBeenCalledTimes(1));
|
||||
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when the account already has stored filters", async () => {
|
||||
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" }));
|
||||
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when no view is starred as default", async () => {
|
||||
await mountResolved([view({ id: 2, is_default: false, filters: { scope: "all" } })]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("plex: seeds the starred default into a fresh module (parity with the feed)", async () => {
|
||||
h.page = "plex";
|
||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
expect(h.setPlexFilters).toHaveBeenCalledTimes(1);
|
||||
expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] });
|
||||
});
|
||||
|
||||
it("plex: does NOT seed when plex filters are already stored", async () => {
|
||||
h.page = "plex";
|
||||
localStorage.setItem(accountKey(LS.plexFilters, ID)!, JSON.stringify({ genres: ["Comedy"] }));
|
||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
expect(h.setPlexFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -12,10 +20,11 @@ import {
|
||||
type PlexFilters,
|
||||
type SavedView,
|
||||
} from "../lib/api";
|
||||
import { filtersToParams } from "../lib/urlState";
|
||||
import { preferSticky } from "../lib/savedViews";
|
||||
import { filtersToParams, hasFilterParams } from "../lib/urlState";
|
||||
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { accountKey, LS, writeJSON } from "../lib/storage";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { useActiveAccountId } from "../lib/activeAccount";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
@@ -46,20 +55,35 @@ interface ViewConfig {
|
||||
keyOf: (f: Record<string, unknown>) => string;
|
||||
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
|
||||
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
|
||||
/** Feed only: mirror the default view's filters to localStorage so it seeds before the network returns. */
|
||||
syncDefaultMirror?: (views: SavedView[]) => void;
|
||||
/** Whether this module is a genuinely fresh start the starred default may seed on first load: no
|
||||
* stored filters for the active account, and — feed only — no URL filter override. */
|
||||
canSeedDefault: () => boolean;
|
||||
}
|
||||
|
||||
const feedKeyOf = (f: Record<string, unknown>) =>
|
||||
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
|
||||
const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
|
||||
|
||||
// Mirror the feed default view's filters to localStorage (read by FeedFiltersProvider on load).
|
||||
function syncFeedDefaultMirror(views: SavedView[]): void {
|
||||
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
||||
if (!key) return;
|
||||
const def = views.find((v) => v.is_default);
|
||||
writeJSON(key, def ? def.filters : null);
|
||||
// True once this account has its own persisted filters for `lsKey` — the signal that the last
|
||||
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
|
||||
// report "stored" so a default never seeds blind over an indeterminate state.
|
||||
function storedFiltersPresent(lsKey: string): boolean {
|
||||
const key = accountKey(lsKey, getActiveAccount());
|
||||
if (!key) return true;
|
||||
try {
|
||||
return localStorage.getItem(key) != null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Feed also yields to a URL that carries filter params (a shared/bookmarked view).
|
||||
function feedCanSeedDefault(): boolean {
|
||||
if (hasFilterParams(new URLSearchParams(window.location.search))) return false;
|
||||
return !storedFiltersPresent(LS.filters);
|
||||
}
|
||||
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
|
||||
function plexCanSeedDefault(): boolean {
|
||||
return !storedFiltersPresent(LS.plexFilters);
|
||||
}
|
||||
|
||||
export interface SavedViewsContext {
|
||||
@@ -105,6 +129,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
keyOf: plexKeyOf,
|
||||
asFilters: (raw) => coercePlexFilters(raw) as unknown as Record<string, unknown>,
|
||||
stripForSave: (f) => f, // Plex has no transient field to drop
|
||||
canSeedDefault: plexCanSeedDefault,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -119,7 +144,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
const { seed: _seed, ...rest } = f;
|
||||
return rest;
|
||||
},
|
||||
syncDefaultMirror: syncFeedDefaultMirror,
|
||||
canSeedDefault: feedCanSeedDefault,
|
||||
};
|
||||
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
|
||||
|
||||
@@ -140,13 +165,35 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
setModalOpen(false);
|
||||
}, [source]);
|
||||
|
||||
// Feed only: keep the default-view mirror (read on load) in sync with the server state. Depend on the
|
||||
// mirror fn (a module-stable ref, or undefined for plex) NOT the whole `cfg` object — cfg re-memoizes
|
||||
// on every live filter edit, which would fire this synchronous localStorage write far beyond its intent.
|
||||
const syncMirror = cfg.syncDefaultMirror;
|
||||
// The ONE default-application path for both modules. On the views query's FIRST resolve for a
|
||||
// source, seed the starred default view's filters — but only into a genuinely fresh module (no
|
||||
// stored filters, no URL override; see cfg.canSeedDefault). Applying via cfg.setFilters persists
|
||||
// it, so it happens once and the normal last-view-wins path takes over, exactly as the storage
|
||||
// semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's
|
||||
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
|
||||
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
|
||||
// 60s background refetch never re-seeds over the filters you're editing.
|
||||
//
|
||||
// WAIT FOR THE ACCOUNT PIN. This provider mounts above <App>, which pins the tab-account only after
|
||||
// /api/me — but viewsQuery is ungated, so on a fresh tab it resolves FIRST, while the account is null.
|
||||
// canSeedDefault() reads the per-account stored-filters key, which is indeterminate (→ "assume stored")
|
||||
// with no account, so evaluating it too early would decline to seed AND burn the one-shot guard, never
|
||||
// re-running once the account pins. Gating on the reactive account id makes the effect re-fire on the
|
||||
// pin (useActiveAccountId subscribes), and defers consuming the guard until canSeedDefault is real.
|
||||
const accountId = useActiveAccountId();
|
||||
const seededRef = useRef<Set<ViewSource>>(new Set());
|
||||
useEffect(() => {
|
||||
if (viewsQuery.data) syncMirror?.(viewsQuery.data);
|
||||
}, [viewsQuery.data, syncMirror]);
|
||||
const data = viewsQuery.data;
|
||||
if (!data || accountId == null || seededRef.current.has(source)) return;
|
||||
seededRef.current.add(source);
|
||||
const def = pickDefaultToSeed(data, cfg.canSeedDefault());
|
||||
if (!def) return;
|
||||
cfg.setFilters(cfg.asFilters(def.filters));
|
||||
setStickyId(def.id);
|
||||
// cfg is read fresh on this resolve and the per-source guard makes the body run once, so cfg
|
||||
// (which re-memoizes on every filter edit) is deliberately kept out of the deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewsQuery.data, source, accountId]);
|
||||
|
||||
const currentKey = cfg.keyOf(cfg.filters);
|
||||
const activeView = useMemo(
|
||||
|
||||
@@ -262,9 +262,12 @@ function Thumb({
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)}
|
||||
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. */}
|
||||
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only:
|
||||
reveal on hover AND on keyboard focus-within (else a Tab-focused button is invisible), and
|
||||
`reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a
|
||||
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
|
||||
{onOpen && (
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity reveal-hover">
|
||||
{inProgress ? (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -4,19 +4,13 @@ import type { TFunction } from "i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import {
|
||||
api,
|
||||
HttpError,
|
||||
isIntrinsicReadOnly,
|
||||
type PlexCard,
|
||||
type PlexCollection,
|
||||
} from "../lib/api";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import PosterTile from "./PosterTile";
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
|
||||
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
||||
// collections have no movie runtime).
|
||||
@@ -86,7 +80,6 @@ function NewCollectionPanel({
|
||||
const [name, setName] = useState("");
|
||||
const [term, setTerm] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const debounced = useDebounced(term.trim(), 300);
|
||||
const trimmed = name.trim();
|
||||
// Advisory only: `existingNames` is the search-scoped, all-library collection union, whereas the
|
||||
// backend enforces uniqueness PER library (Plex only conflates same-named collections within one
|
||||
@@ -94,12 +87,6 @@ function NewCollectionPanel({
|
||||
// same-name collection in another library would be un-creatable. The backend's 409 is authoritative.
|
||||
const dupHint = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase());
|
||||
const nameOk = trimmed.length > 0;
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexLibrary("collection-new", debounced),
|
||||
queryFn: () => api.plexLibrary({ scope: "both", q: debounced, limit: 12 }),
|
||||
enabled: nameOk && debounced.length > 0,
|
||||
});
|
||||
const results = q.data?.items ?? [];
|
||||
|
||||
async function create(card: PlexCard) {
|
||||
if (!nameOk || busy) return;
|
||||
@@ -108,13 +95,8 @@ function NewCollectionPanel({
|
||||
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
|
||||
onCreated(col.id);
|
||||
} catch (e) {
|
||||
// The backend is authoritative on per-library name uniqueness — localize its 409 to nameTaken
|
||||
// (notifyActionError would otherwise surface the server's raw English detail).
|
||||
if (e instanceof HttpError && e.status === 409) {
|
||||
notify({ level: "error", message: t("plex.collection.nameTaken") });
|
||||
} else {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
// The backend is authoritative on per-library name uniqueness — its 409 is localized to nameTaken.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -155,38 +137,21 @@ function NewCollectionPanel({
|
||||
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
{!nameOk || debounced.length === 0 ? null : q.isLoading ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : q.isError ? (
|
||||
<p className="text-muted text-sm">{t("common.loadError")}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{results.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-3">
|
||||
<img
|
||||
src={c.thumb}
|
||||
alt=""
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{c.title}
|
||||
{c.year != null && <span className="text-muted"> · {c.year}</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => create(c)}
|
||||
disabled={busy}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("plex.collection.createWith")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<PlexLibraryPicker
|
||||
scope="both"
|
||||
term={term}
|
||||
enabled={nameOk}
|
||||
renderAction={(c) => (
|
||||
<button
|
||||
onClick={() => create(c)}
|
||||
disabled={busy}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("plex.collection.createWith")}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { plexLang } from "../i18n";
|
||||
import { pickRatingBadge } from "../lib/plexRating";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { showEpisodeKeys } from "../lib/plexQueue";
|
||||
import {
|
||||
ActionBtn,
|
||||
BackBtn,
|
||||
@@ -306,7 +307,7 @@ export function PlexShowView({
|
||||
|
||||
const show = d?.show;
|
||||
const showLib = show?.library ?? undefined;
|
||||
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
|
||||
const allKeys = showEpisodeKeys(d);
|
||||
const watched = show?.status === "watched";
|
||||
const {
|
||||
artBg,
|
||||
@@ -568,7 +569,7 @@ export function PlexSeasonView({
|
||||
// Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls
|
||||
// straight into the next season's first — the same cross-season order the show page already uses.
|
||||
// seasonKeys stays only for the season-scoped "add to playlist" action.
|
||||
const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
|
||||
const allKeys = showEpisodeKeys(d);
|
||||
const watched = se?.status === "watched";
|
||||
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade } = useDetailPrefs();
|
||||
useCrossfadeBackdrop(
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"added": "Added",
|
||||
"deleteConfirm": "Delete the collection \"{{title}}\"? This removes it from Plex too.",
|
||||
"editFailed": "Collection edit failed",
|
||||
"playFailed": "Couldn't build the play queue — try again.",
|
||||
"playEmpty": "Nothing to play in this collection yet.",
|
||||
"newCollection": "New collection",
|
||||
"namePlaceholder": "Collection name",
|
||||
"nameTaken": "A collection with this name already exists.",
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"added": "Hozzáadva",
|
||||
"deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót? Ez a Plexből is eltávolítja.",
|
||||
"editFailed": "A kollekció szerkesztése sikertelen",
|
||||
"playFailed": "Nem sikerült összeállítani a lejátszási sort — próbáld újra.",
|
||||
"playEmpty": "Ebben a kollekcióban még nincs mit lejátszani.",
|
||||
"newCollection": "Új kollekció",
|
||||
"namePlaceholder": "Kollekció neve",
|
||||
"nameTaken": "Már létezik ilyen nevű kollekció.",
|
||||
|
||||
@@ -581,6 +581,16 @@ body {
|
||||
.reveal-idle {
|
||||
display: none;
|
||||
}
|
||||
/* Sibling of `.reveal` for a MOUSE-ONLY hover-reveal: unlike `.reveal` (shown at rest on touch),
|
||||
a `.reveal-hover` control is removed entirely on a device that can't hover, so it is never an
|
||||
invisible tap/Tab target. Pair it with the site's own `opacity-0 group-hover:… group-focus-within:…`
|
||||
(which still governs the hover + keyboard case). For a full-card/edge affordance whose action is
|
||||
already reachable on touch another way — VideoCard's play overlay (tapping the card plays/resumes)
|
||||
and the Carousel arrows (swiping scrolls the strip) — where a permanent scrim/edge button is not
|
||||
wanted. Group-agnostic (encodes only the touch removal), so it works under any `.group`/`group\/…`. */
|
||||
.reveal-hover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// jsdom (not node): importing ../api pulls in i18n, which reads navigator.language at module load.
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Spy on the toast sink so we can assert exactly what each error maps to.
|
||||
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
|
||||
|
||||
import { HttpError } from "./api";
|
||||
import { notify } from "./notifications";
|
||||
import { notifyCollectionEditError } from "./actionErrors";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => notifyMock.mockClear());
|
||||
|
||||
describe("notifyCollectionEditError", () => {
|
||||
it("maps a backend 409 to the friendly nameTaken message (not the raw server detail)", () => {
|
||||
notifyCollectionEditError(new HttpError(409, "already exists"), "EDIT_FAILED", "NAME_TAKEN");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "NAME_TAKEN" });
|
||||
});
|
||||
|
||||
it("falls back to the server detail for a non-409 HttpError", () => {
|
||||
notifyCollectionEditError(new HttpError(500, "server boom"), "EDIT_FAILED", "NAME_TAKEN");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "server boom" });
|
||||
});
|
||||
|
||||
it("falls back to the generic edit-failed message for a non-HttpError", () => {
|
||||
notifyCollectionEditError(new Error("network"), "EDIT_FAILED", "NAME_TAKEN");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EDIT_FAILED" });
|
||||
});
|
||||
});
|
||||
@@ -12,3 +12,22 @@ export function notifyActionError(err: unknown, fallbackMessage: string): void {
|
||||
const detail = err instanceof HttpError ? err.detail : null;
|
||||
notify({ level: "error", message: detail || fallbackMessage });
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection write errors. The backend enforces per-library name uniqueness and returns 409 on a
|
||||
* duplicate; localize that specific case to the friendly `nameTaken` copy (notifyActionError would
|
||||
* otherwise surface the server's raw English detail). Everything else falls back to the generic
|
||||
* edit-failed handling (which still prefers the server's reason when present). Both args are already
|
||||
* translated so this stays i18n-agnostic and unit-testable.
|
||||
*/
|
||||
export function notifyCollectionEditError(
|
||||
err: unknown,
|
||||
editFailedMessage: string,
|
||||
nameTakenMessage: string,
|
||||
): void {
|
||||
if (err instanceof HttpError && err.status === 409) {
|
||||
notify({ level: "error", message: nameTakenMessage });
|
||||
return;
|
||||
}
|
||||
notifyActionError(err, editFailedMessage);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideChipLayout, type ChipFitInput } from "./chipOverflow";
|
||||
import { decideChipLayout, fitChipsWithPill, type ChipFitInput } from "./chipOverflow";
|
||||
|
||||
// Build measurer-style right edges for `n` chips of a fixed width, starting after a label, with a gap.
|
||||
function rights(labelRight: number, widths: number[], gap: number): number[] {
|
||||
@@ -91,3 +91,29 @@ describe("decideChipLayout", () => {
|
||||
expect(decideChipLayout(input)).toEqual({ mode: "count", visible: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitChipsWithPill", () => {
|
||||
// Three chips of width 50, gap 6, starting at 0 -> right edges 50, 106, 162.
|
||||
const chipRights = rights(0, [50, 50, 50], 6);
|
||||
|
||||
it("shows all chips (no pill) when the last chip's right edge fits — the whole-set fast path", () => {
|
||||
expect(fitChipsWithPill({ chipRights, pillW: 40, gap: 6, avail: 200 })).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty chip set", () => {
|
||||
expect(fitChipsWithPill({ chipRights: [], pillW: 40, gap: 6, avail: 200 })).toBe(0);
|
||||
});
|
||||
|
||||
it("reserves the pill width for all but the last chip when overflowing", () => {
|
||||
// avail 120: whole set (162) doesn't fit. chip0 right 50 <= 120-(40+6)=74 ok; chip1 right 106 > 74
|
||||
// -> dropped at i=1 -> visible 1.
|
||||
expect(fitChipsWithPill({ chipRights, pillW: 40, gap: 6, avail: 120 })).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the whole set via the fast path even when a reserve-for-all loop would drop chips", () => {
|
||||
// Right edges 56, 112, 118; avail 120: the whole set fits (118 <= 120). A naive loop that reserves
|
||||
// pill+gap (46) for every non-last chip would drop at chip1 (112 > 120-46=74) and show "+2" beside
|
||||
// empty space — the fast path returns all 3 instead.
|
||||
expect(fitChipsWithPill({ chipRights: [56, 112, 118], pillW: 40, gap: 6, avail: 120 })).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Pure fit decision for a single-line chip lane that must share its width with fixed trailing
|
||||
// controls (the feed's applied-filter row + Sort/View). Lifted out of ActiveFilterChips so the
|
||||
// all/some/count math is unit-testable without a layout engine (jsdom reports 0-width boxes), the
|
||||
// same split as lib/dateFilter and lib/columnSort. The caller measures real widths off a hidden
|
||||
// mirror (including the live rem gap, so nothing is frozen in px) and hands them here.
|
||||
//
|
||||
// A sibling one-line-fit measurer lives in ChannelTags (ChannelTagsControl); if a third appears,
|
||||
// promote the DOM-measurement half to a shared hook and keep this as its policy core.
|
||||
// Pure fit decisions for a single-line chip lane that collapses its overflow into a "+N" pill.
|
||||
// Lifted out of the components so the math is unit-testable without a layout engine (jsdom reports
|
||||
// 0-width boxes), the same split as lib/dateFilter and lib/columnSort. The shared DOM-measurement
|
||||
// half — the layout-effect + ResizeObserver that reads real rem boxes off a hidden mirror — lives in
|
||||
// lib/useChipOverflowFit; it hands the measured geometry to one of these policies:
|
||||
// - decideChipLayout: the feed's applied-filter row, which also shares its width with fixed trailing
|
||||
// controls (Sort/View), reserves a leading label + a trailing "Clear all", and collapses to a
|
||||
// single count pill on a narrow lane.
|
||||
// - fitChipsWithPill: the chips-only channel tag cell (ChannelTagsControl) — just "how many fit
|
||||
// before the +N pill".
|
||||
|
||||
export interface ChipFitInput {
|
||||
/** Number of chips. */
|
||||
@@ -51,3 +53,37 @@ export function decideChipLayout(p: ChipFitInput): ChipFit {
|
||||
if (k === 0 && labelRight + gap + plusW > avail) return { mode: "count", visible: 0 };
|
||||
return { mode: "some", visible: k };
|
||||
}
|
||||
|
||||
// The chips-only policy (ChannelTagsControl): how many chips fit on the line, reserving the "+N" pill's
|
||||
// width whenever any overflow. Returns the visible count.
|
||||
export interface ChipsWithPillInput {
|
||||
/** Right edge (offsetLeft + offsetWidth) of each chip, index-aligned. */
|
||||
chipRights: number[];
|
||||
/** Width of the "+N" pill. */
|
||||
pillW: number;
|
||||
/** Available lane width. */
|
||||
avail: number;
|
||||
/** The real (rem-scaled) inter-item gap. */
|
||||
gap: number;
|
||||
}
|
||||
|
||||
export function fitChipsWithPill(p: ChipsWithPillInput): number {
|
||||
const { chipRights, pillW, avail, gap } = p;
|
||||
const n = chipRights.length;
|
||||
if (n === 0) return 0;
|
||||
// Whole-set fast path: if the LAST chip's right edge already fits, every chip fits and NO pill is
|
||||
// needed. The reservation loop below reserves the pill for every chip except the last and breaks at
|
||||
// the first overflow, so on its own it would collapse a short trailing chip (narrower than the pill)
|
||||
// that would actually have fitted, leaving "+N" beside empty space.
|
||||
if ((chipRights[n - 1] ?? 0) <= avail) return n;
|
||||
let count = n;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const right = chipRights[i] ?? 0;
|
||||
const moreAfter = i < n - 1;
|
||||
if (right > avail - (moreAfter ? pillW + gap : 0)) {
|
||||
count = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Guards the UX-8 fix: one Escape must close only the TOPMOST layer, never both an open popover AND
|
||||
// the player beneath it. The bug was that the popover's Escape listener (on `document`) fires before
|
||||
// the player's (on `window`), and closing the popover flushes its layer off the stack synchronously —
|
||||
// so by the time the player's `anyLayerAbove()` lookup runs, the stack already reads "nothing above"
|
||||
// and the player closes too. The fix stamps the shared KeyboardEvent when a layer acts; the player
|
||||
// reads the stamp instead of the (now-mutated) stack. This test reproduces that exact ordering with
|
||||
// the stack primitives — the layer removes itself between the two listeners, mimicking the flush.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
pushLayer,
|
||||
removeLayer,
|
||||
anyLayerAbove,
|
||||
isTopmost,
|
||||
markEscapeHandled,
|
||||
escapeHandled,
|
||||
} from "./layerStack";
|
||||
|
||||
// Keep the module-level stack clean between cases (ids never repeat, but membership must not leak).
|
||||
const opened: number[] = [];
|
||||
function open(): number {
|
||||
const id = pushLayer();
|
||||
opened.push(id);
|
||||
return id;
|
||||
}
|
||||
afterEach(() => {
|
||||
while (opened.length) removeLayer(opened.pop()!);
|
||||
});
|
||||
|
||||
describe("layerStack Escape coordination (UX-8)", () => {
|
||||
it("a player defers even when the acting layer is popped mid-dispatch", () => {
|
||||
const playerId = open();
|
||||
const popoverId = open();
|
||||
const playerClose = vi.fn();
|
||||
|
||||
// Popover: the `document` listener from useLayer — topmost acts, stamps the event, and (the
|
||||
// regression) its close synchronously removes the layer before the window listener runs.
|
||||
const onDocumentKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isTopmost(popoverId)) {
|
||||
markEscapeHandled(e);
|
||||
removeLayer(popoverId); // the synchronous React flush the real close triggers
|
||||
}
|
||||
};
|
||||
// Player: the `window` listener — fires AFTER the document one, reading an already-mutated stack.
|
||||
const onWindowKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (escapeHandled(e)) return; // the fix
|
||||
if (anyLayerAbove(playerId)) return; // the old, now-insufficient guard
|
||||
playerClose();
|
||||
};
|
||||
|
||||
const e = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
onDocumentKey(e);
|
||||
onWindowKey(e);
|
||||
|
||||
// The stamp holds even though the stack no longer shows a layer above the player...
|
||||
expect(escapeHandled(e)).toBe(true);
|
||||
expect(anyLayerAbove(playerId)).toBe(false); // ...which is exactly why anyLayerAbove alone failed
|
||||
expect(playerClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a plain Escape with no layer above still closes the player", () => {
|
||||
const playerId = open();
|
||||
const playerClose = vi.fn();
|
||||
|
||||
const e = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
if (!escapeHandled(e) && !anyLayerAbove(playerId)) playerClose();
|
||||
|
||||
expect(playerClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("stamps are per-event: a fresh Escape is not treated as already-handled", () => {
|
||||
const first = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
markEscapeHandled(first);
|
||||
const second = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
expect(escapeHandled(second)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,23 @@ export function anyLayerAbove(id: number): boolean {
|
||||
return i !== -1 && i < stack.length - 1;
|
||||
}
|
||||
|
||||
// UX-8 — a layer that ACTS on an Escape stamps the event here; a lower layer with its own key handler
|
||||
// (the players, listening on `window`) reads the stamp instead of re-reading the stack. This closes
|
||||
// the "one Escape closes BOTH the popover and the player" bug: the popover's Escape listener is on
|
||||
// `document` and fires before the player's on `window` (bubble order), and closing the popover flushes
|
||||
// its layer off the stack synchronously — so by the time the player's `anyLayerAbove()` runs, the stack
|
||||
// already reads "nothing above" and the player ALSO closes. Tagging the one shared KeyboardEvent object
|
||||
// (every listener in a single dispatch is handed the same instance) is immune to that mid-dispatch
|
||||
// mutation; `anyLayerAbove` stays as the fallback for a layer above with no Escape handler of its own.
|
||||
// WeakSet so a spent event is GC'd without a manual sweep.
|
||||
const handledEscapes = new WeakSet<KeyboardEvent>();
|
||||
export function markEscapeHandled(e: KeyboardEvent): void {
|
||||
handledEscapes.add(e);
|
||||
}
|
||||
export function escapeHandled(e: KeyboardEvent): boolean {
|
||||
return handledEscapes.has(e);
|
||||
}
|
||||
|
||||
/** Register an open layer for as long as `active` is true, and get live predicates to read inside your
|
||||
* own key handler. Pass `onEscape` to also install a topmost-only Escape listener (the common case —
|
||||
* modals, popovers). Omit it for a layer that owns a bigger key handler and only needs `anyAbove()`
|
||||
@@ -57,7 +74,12 @@ export function useLayer(
|
||||
let onKey: ((e: KeyboardEvent) => void) | undefined;
|
||||
if (escRef.current) {
|
||||
onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isTopmost(id)) escRef.current?.();
|
||||
if (e.key === "Escape" && isTopmost(id)) {
|
||||
// Stamp BEFORE closing so a player's `window` listener (fires after this `document` one)
|
||||
// sees the Escape as consumed even though closing us pops this layer off the stack.
|
||||
markEscapeHandled(e);
|
||||
escRef.current?.();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { showEpisodeKeys } from "./plexQueue";
|
||||
import type { PlexCard, PlexSeasonDetail, PlexShowDetail } from "./api";
|
||||
|
||||
const ep = (id: string): PlexCard => ({ id }) as PlexCard;
|
||||
const season = (episodes: PlexCard[]): PlexSeasonDetail =>
|
||||
({ episodes }) as unknown as PlexSeasonDetail;
|
||||
const show = (seasons: PlexSeasonDetail[]): PlexShowDetail =>
|
||||
({ seasons }) as unknown as PlexShowDetail;
|
||||
|
||||
describe("showEpisodeKeys", () => {
|
||||
it("flattens seasons in order, episodes in order, concatenated across seasons", () => {
|
||||
const detail = show([
|
||||
season([ep("s1e1"), ep("s1e2")]),
|
||||
season([ep("s2e1"), ep("s2e2"), ep("s2e3")]),
|
||||
]);
|
||||
expect(showEpisodeKeys(detail)).toEqual(["s1e1", "s1e2", "s2e1", "s2e2", "s2e3"]);
|
||||
});
|
||||
|
||||
it("returns [] for a show with no seasons", () => {
|
||||
expect(showEpisodeKeys(show([]))).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for a season with no episodes (the enabled-but-empty Play-all case)", () => {
|
||||
expect(showEpisodeKeys(show([season([])]))).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for null/undefined so an in-flight query's data passes straight through", () => {
|
||||
expect(showEpisodeKeys(null)).toEqual([]);
|
||||
expect(showEpisodeKeys(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PlexShowDetail } from "./api";
|
||||
|
||||
// The ordered episode rating-keys of a show: season order, then episode order within each season —
|
||||
// the play-queue order the show/season pages and the collection Play-all all rely on. Kept in ONE
|
||||
// place so that invariant (and any future explicit sort, e.g. pushing season 0 "Specials" last)
|
||||
// lives at a single site rather than the three hand-rolled `seasons.flatMap(...)` copies it grew
|
||||
// into. `detail` is nullable so callers can pass an in-flight query's `data` straight through.
|
||||
export function showEpisodeKeys(detail: PlexShowDetail | null | undefined): string[] {
|
||||
return (detail?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
|
||||
}
|
||||
@@ -14,6 +14,23 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.72.1",
|
||||
date: "2026-08-24",
|
||||
summary:
|
||||
"Polish and fixes on top of the Plex overhaul: Play-all now works on a collection of shows, your default Personal View applies on a fresh visit, and card buttons behave properly on touch and keyboard.",
|
||||
features: [
|
||||
"Play-all (and Shuffle) now work on a collection of shows — they play through every episode in order, not just standalone movies. If a collection has nothing playable yet, it now tells you instead of doing nothing.",
|
||||
"Your starred default Personal View now applies automatically the first time you open Siftlode in a fresh browser or tab — on both the feed and Plex — without needing a reload.",
|
||||
],
|
||||
fixes: [
|
||||
"Card actions that appear on hover (like Mark watched) no longer sit invisibly over the card on touchscreens, and no longer catch keyboard Tab focus while hidden. On touch you just tap the card; with a keyboard, focusing the card reveals them.",
|
||||
"In the video player, pressing Escape once now closes only the open “Add to playlist” menu; a second Escape closes the player — instead of both closing at once.",
|
||||
],
|
||||
chores: [
|
||||
"Shared the filter-chip overflow logic behind the feed and channel-tag rows, and put the whole batch through two multi-agent deep-review rounds (findings fixed and re-reviewed until clean).",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.72.0",
|
||||
date: "2026-08-23",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { preferSticky } from "./savedViews";
|
||||
import { pickDefaultToSeed, preferSticky } from "./savedViews";
|
||||
|
||||
const v = (id: number) => ({ id, name: `v${id}` });
|
||||
|
||||
@@ -24,3 +24,24 @@ describe("preferSticky", () => {
|
||||
expect(preferSticky([v(1), v(2)], null)?.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickDefaultToSeed", () => {
|
||||
const dv = (id: number, is_default = false) => ({ id, name: `v${id}`, is_default });
|
||||
|
||||
it("returns the starred default when the module is a fresh start", () => {
|
||||
expect(pickDefaultToSeed([dv(1), dv(2, true)], true)?.id).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null when the module already has stored filters / a URL override (canSeed=false)", () => {
|
||||
expect(pickDefaultToSeed([dv(1), dv(2, true)], false)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when there is no starred default, even on a fresh start", () => {
|
||||
expect(pickDefaultToSeed([dv(1), dv(2)], true)).toBeNull();
|
||||
expect(pickDefaultToSeed([], true)).toBeNull();
|
||||
});
|
||||
|
||||
it("picks the first default deterministically if several are somehow flagged", () => {
|
||||
expect(pickDefaultToSeed([dv(1, true), dv(2, true)], true)?.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,3 +10,18 @@ export function preferSticky<T extends { id: number }>(
|
||||
): T | null {
|
||||
return matches.find((v) => v.id === stickyId) ?? matches[0] ?? null;
|
||||
}
|
||||
|
||||
/** The starred default view to seed a module's filters with on first load, or null to leave them.
|
||||
* Seeds ONLY when the module is a genuinely fresh start (`canSeed`: no stored filters for this
|
||||
* account and — feed only — no URL filter override); otherwise the last-session/URL/manual filters
|
||||
* win, exactly as they do after any edit. First `is_default` wins if somehow several are flagged, so
|
||||
* the choice is deterministic. This is the single default-application path for BOTH the feed and Plex
|
||||
* (SavedViewsProvider drives it on the views query's first resolve) — it replaced the feed-only
|
||||
* localStorage mirror that raced the login effect and left Plex with no default at all. */
|
||||
export function pickDefaultToSeed<T extends { is_default: boolean }>(
|
||||
views: T[],
|
||||
canSeed: boolean,
|
||||
): T | null {
|
||||
if (!canSeed) return null;
|
||||
return views.find((v) => v.is_default) ?? null;
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ export const LS = {
|
||||
hints: "siftlode.hints",
|
||||
lang: "siftlode.lang",
|
||||
filters: "siftlode.filters",
|
||||
// Mirror of the default saved view's filters, so it can be applied synchronously on load
|
||||
// (before the async saved-views query resolves). Kept in sync by SavedViewsWidget.
|
||||
defaultViewFilters: "siftlode.defaultViewFilters",
|
||||
page: "siftlode.page",
|
||||
perfMode: "siftlode.perfMode",
|
||||
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The shared chip-overflow measure core (S3). jsdom has no layout engine — every offset* / clientWidth
|
||||
// is 0 — so we stub those on the real ref'd nodes and assert the hook (a) reads the geometry off them
|
||||
// (chip right edges, pill width, the gap between the last chip and the pill, and the lane's available
|
||||
// width), (b) returns the caller's `decide` result, and (c) re-measures when `deps` changes.
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { useRef } from "react";
|
||||
import { render, screen, cleanup, act } from "@testing-library/react";
|
||||
import { useChipOverflowFit, type ChipRowGeometry } from "./useChipOverflowFit";
|
||||
|
||||
function stub(
|
||||
el: HTMLElement,
|
||||
props: Partial<Record<"offsetLeft" | "offsetWidth" | "clientWidth", number>>,
|
||||
) {
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
Object.defineProperty(el, k, { configurable: true, get: () => v });
|
||||
}
|
||||
}
|
||||
|
||||
let lastGeom: ChipRowGeometry | null = null;
|
||||
|
||||
function Harness({ deps }: { deps: string }) {
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps,
|
||||
initial: -1,
|
||||
decide: (g) => {
|
||||
lastGeom = g;
|
||||
// A trivial policy: count the chips whose right edge fits the lane.
|
||||
return g.chipRights.filter((r) => r <= g.avail).length;
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<div ref={rowRef} data-testid="row" />
|
||||
<span
|
||||
ref={(el) => {
|
||||
chipRefs.current[0] = el;
|
||||
}}
|
||||
data-testid="chip0"
|
||||
/>
|
||||
<span
|
||||
ref={(el) => {
|
||||
chipRefs.current[1] = el;
|
||||
}}
|
||||
data-testid="chip1"
|
||||
/>
|
||||
<span ref={pillRef} data-testid="pill" />
|
||||
<div data-testid="visible">{visible}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
lastGeom = null;
|
||||
});
|
||||
|
||||
describe("useChipOverflowFit", () => {
|
||||
it("reads geometry off the refs and returns the decide() result; re-measures on deps change", () => {
|
||||
const { rerender } = render(<Harness deps="a" />);
|
||||
|
||||
// First pass ran against jsdom's 0-sized boxes.
|
||||
expect(lastGeom).toEqual({ avail: 0, chipRights: [0, 0], pillW: 0, gap: 0 });
|
||||
|
||||
// Give the boxes real geometry, then bump deps to force a re-measure.
|
||||
stub(screen.getByTestId("row"), { clientWidth: 100 });
|
||||
stub(screen.getByTestId("chip0"), { offsetLeft: 0, offsetWidth: 30 }); // right edge 30
|
||||
stub(screen.getByTestId("chip1"), { offsetLeft: 36, offsetWidth: 30 }); // right edge 66
|
||||
stub(screen.getByTestId("pill"), { offsetLeft: 72, offsetWidth: 20 }); // gap = 72 - 66 = 6
|
||||
|
||||
act(() => {
|
||||
rerender(<Harness deps="b" />);
|
||||
});
|
||||
|
||||
expect(lastGeom).toEqual({ avail: 100, chipRights: [30, 66], pillW: 20, gap: 6 });
|
||||
// decide counted both chips (30 and 66 both <= 100).
|
||||
expect(screen.getByTestId("visible").textContent).toBe("2");
|
||||
});
|
||||
|
||||
it("drops a chip whose right edge exceeds the lane, via the caller's policy", () => {
|
||||
const { rerender } = render(<Harness deps="a" />);
|
||||
stub(screen.getByTestId("row"), { clientWidth: 40 });
|
||||
stub(screen.getByTestId("chip0"), { offsetLeft: 0, offsetWidth: 30 }); // right edge 30 <= 40
|
||||
stub(screen.getByTestId("chip1"), { offsetLeft: 36, offsetWidth: 30 }); // right edge 66 > 40
|
||||
stub(screen.getByTestId("pill"), { offsetLeft: 72, offsetWidth: 20 });
|
||||
|
||||
act(() => {
|
||||
rerender(<Harness deps="b" />);
|
||||
});
|
||||
|
||||
expect(lastGeom).toEqual({ avail: 40, chipRights: [30, 66], pillW: 20, gap: 6 });
|
||||
// Only chip0 (right edge 30) fits the 40-wide lane; chip1 (66) is dropped by the policy.
|
||||
expect(screen.getByTestId("visible").textContent).toBe("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
// The shared DOM-measure-and-fit core for a single-line chip lane that collapses its overflow into a
|
||||
// trailing "+N" pill. Owns the layout-effect + ResizeObserver + initial-measure wiring and reads the
|
||||
// common geometry off a hidden mirror (each chip's right edge, the real rem gap, the pill width, and
|
||||
// the lane's available width); each caller layers its OWN fit policy in `decide` and gets that result
|
||||
// back. This was hand-rolled twice — the feed's applied-filter row (ActiveFilterChips, whose policy
|
||||
// also reserves a leading label + a trailing "Clear all" and can collapse to a single count pill) and
|
||||
// the channel tag cell (ChannelTagsControl, chips-only) — with the same measurement half copied in
|
||||
// both. The policy halves live in lib/chipOverflow.ts; this owns the measurement half.
|
||||
//
|
||||
// Coordinate note: `chipRights` are offsets within the hidden mirror (its children's offsetParent),
|
||||
// while `avail` is the VISIBLE lane's clientWidth — the two are comparable because the mirror is laid
|
||||
// out `absolute left-0 top-0` over the lane's start, so a mirror-relative right edge is the position a
|
||||
// chip would take in the real lane. Reads real rem boxes, so nothing is frozen in px (the text-size
|
||||
// slider scales the gap and chip widths, and the fit follows).
|
||||
|
||||
export interface ChipRowGeometry {
|
||||
/** Available lane width — the visible row's clientWidth. */
|
||||
avail: number;
|
||||
/** Right edge (offsetLeft + offsetWidth) of each chip in the measurer, index-aligned to the chips. */
|
||||
chipRights: number[];
|
||||
/** Width of the trailing sample "+N" pill in the measurer. */
|
||||
pillW: number;
|
||||
/** The real rendered inter-item gap (rem-scaled), read off the box between the last chip and the pill. */
|
||||
gap: number;
|
||||
}
|
||||
|
||||
export function useChipOverflowFit<R>(args: {
|
||||
/** The VISIBLE lane; its clientWidth is the available width, and resize is observed on it. */
|
||||
rowRef: RefObject<HTMLElement | null>;
|
||||
/** The measurer's chip elements, index-aligned (a hidden mirror of the full chip set). */
|
||||
chipsRef: RefObject<Array<HTMLElement | null>>;
|
||||
/** The sample "+N" pill in the measurer. */
|
||||
pillRef: RefObject<HTMLElement | null>;
|
||||
/** Re-measure when this changes (a key over the chip set — count + labels, so a rename re-fits too). */
|
||||
deps: unknown;
|
||||
/** The caller's fit policy over the measured geometry. */
|
||||
decide: (geom: ChipRowGeometry) => R;
|
||||
/** Value used until the first measure (and while the refs aren't mounted). */
|
||||
initial: R;
|
||||
}): R {
|
||||
const { rowRef, chipsRef, pillRef, deps, decide, initial } = args;
|
||||
const [result, setResult] = useState<R>(initial);
|
||||
// Read `decide` fresh each run without making it an effect dependency: the effect re-runs on `deps`
|
||||
// (and resize), and a caller's decide closure is rebuilt every render — depending on it would
|
||||
// resubscribe the ResizeObserver on every render.
|
||||
const decideRef = useRef(decide);
|
||||
decideRef.current = decide;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
const pill = pillRef.current;
|
||||
if (!row || !pill) return;
|
||||
const rightOf = (el: HTMLElement | null | undefined) =>
|
||||
el ? el.offsetLeft + el.offsetWidth : 0;
|
||||
const run = () => {
|
||||
const chips = (chipsRef.current ?? []).filter((c): c is HTMLElement => c != null);
|
||||
const lastChip = chips[chips.length - 1];
|
||||
setResult(
|
||||
decideRef.current({
|
||||
avail: row.clientWidth,
|
||||
chipRights: chips.map(rightOf),
|
||||
pillW: pill.offsetWidth,
|
||||
gap: lastChip ? Math.max(0, pill.offsetLeft - rightOf(lastChip)) : 0,
|
||||
}),
|
||||
);
|
||||
};
|
||||
run();
|
||||
// jsdom has no ResizeObserver — the one-shot measure above still runs there (unit tests drive it).
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(run);
|
||||
ro.observe(row);
|
||||
return () => ro.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deps]);
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user