release: 0.98.0 (feature/e31-design-review-2)
gate / frontend (push) Successful in 8m29s
gate / backend (push) Successful in 9m54s

This commit is contained in:
2026-10-03 02:58:46 +02:00
177 changed files with 5981 additions and 1514 deletions
+14
View File
@@ -7,6 +7,20 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.98.0 — 2026-10-03
### New
- Search is much faster: a short or common word now answers in a fraction of a second, with the newest matches ranked by relevance.
- The feed, the channel list and the Plex pages load faster, and Plex cards fetch a picture sized to the card.
- While a new search counts its results, the count dims instead of showing the old number.
### Fixed
- A list whose next page failed to load says so and offers Retry, instead of asking again and again.
- The filter rail's tag counts follow the current search.
- Smaller fixes to layout, keyboard focus and wording.
## 0.97.0 — 2026-10-01
### New
+1 -1
View File
@@ -1 +1 @@
0.97.0
0.98.0
@@ -0,0 +1,83 @@
"""videos: the per-channel index covers what the channel manager and the feed sidebar read (E31 S4d)
The channel manager (`GET /api/channels`) sums each subscribed channel's videos: count, last upload, total
duration. The feed sidebar (`/feed/summary`, `/feed/years`) counts the reader's videos per channel and per
year under the default filters: available, not via explore, not a short, not live. Every one of them found
its rows through `ix_videos_channel_pub_desc` (channel_id, published_at DESC NULLS LAST, id) and then read
each video's heap page for the rest: ~270k rows at ~2.5 KB each, 681 MB on prod. That stays in cache only
while nothing else needs the memory. Measured on prod (2026-10-02): `/api/channels` 51–61 ms warm, but
2.25 s and 1.96 s on a first open in the browser and 6.4 s under EXPLAIN with 30k blocks read from disk;
`/feed/summary` and `/feed/years` ~140 ms warm, ~800 ms cold.
The same key with those columns INCLUDEd makes them Index Only Scans. On a prod-copy localdev, after a
VACUUM, the manager's sums read 0 heap pages and the summary reads the 25 MB index instead of the heap.
The visibility map is what lets them skip the heap; prod's autovacuum kept it complete (relallvisible ==
relpages). The key columns are unchanged, so every reader of the old index (the per-channel feed, the FK
cascade, the sync upsert, channel detail) rides this one. The old index is dropped.
tests/test_channel_list_index_only.py holds the queries and this column list together.
CONCURRENTLY (autocommit_block) builds it without blocking a live prod. IF (NOT) EXISTS keeps the drift
test's downgrade → re-upgrade round trip idempotent. A re-run after a build that failed part-way rebuilds
the INVALID leftover, and the old index is dropped only once the new one is valid: a re-run that kept the
leftover and dropped the old one left `videos` with no usable channel_id index (E31 S5 review).
"""
from alembic import op
from sqlalchemy import text
revision: str = "0095_videos_channel_cover_index"
down_revision: str | None = "0094_plex_genres_hu_everywhere"
branch_labels: str | None = None
depends_on: str | None = None
def _valid(name: str) -> bool | None:
"""The index's `indisvalid`, or None when there is no index of that name."""
row = (
op.get_bind()
.execute(
text(
"SELECT i.indisvalid FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid "
"WHERE c.relname = :name"
),
{"name": name},
)
.first()
)
return None if row is None else bool(row[0])
def _build(name: str, ddl: str) -> None:
"""CREATE INDEX CONCURRENTLY `name` (`ddl` is what follows the name), re-runnable. A build that
failed part-way leaves an INVALID index under the name, and `IF NOT EXISTS` would keep it: the
planner never uses it, and nothing says so (E31 S5 review). Such a leftover is dropped and built
again."""
if _valid(name) is False:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
op.execute(f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {name} {ddl}")
def _swap(build: str, ddl: str, drop: str) -> None:
_build(build, ddl)
if not _valid(build):
raise RuntimeError(f"{build} did not build valid; {drop} is kept")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {drop}")
def upgrade() -> None:
with op.get_context().autocommit_block():
_swap(
"ix_videos_channel_pub_cover",
"ON videos (channel_id, published_at DESC NULLS LAST, id) "
"INCLUDE (duration_seconds, unavailable_since, via_explore, is_short, live_status)",
"ix_videos_channel_pub_desc",
)
def downgrade() -> None:
with op.get_context().autocommit_block():
_swap(
"ix_videos_channel_pub_desc",
"ON videos (channel_id, published_at DESC NULLS LAST, id ASC)",
"ix_videos_channel_pub_cover",
)
@@ -0,0 +1,86 @@
"""videos: an index per feed sort, so a sorted page stops after its rows (E31 S4e)
The feed's views, duration and title sorts had no index. Every page (the first and each next cursor)
read the whole videos heap, filtered it and kept a top-N: on prod ~350 ms a page for views/duration,
550–780 ms for title (the ICU comparison on every row), 2.3 s for page 2 when the copies of one request
queued. With an index in the exact ORDER BY (`feedquery.order_by`: the key, NULLS LAST, then id) the
planner walks it and stops after the page. On a prod-copy localdev every plain sort fell from 180–310 ms
to ~1 ms, deep pages (20) to 3–5 ms, and narrow filters (a tag, a channel, a year, nothing found) stayed
at 0.3–25 ms.
One index per direction: a backward scan of `DESC NULLS LAST` gives `ASC NULLS FIRST`, not the
`ASC NULLS LAST` the asc sorts ask for. The title pair is per reader language (`app.collation.ICU`):
an index only serves an ORDER BY in its own collation.
Cost: ~130 MB (localdev: 10 + 10 + 8 + 8 + 4 × 23). Writes stay cheap: view_count and duration are
written by the enrich, which rewrites indexed columns already (the revalidation job only touches
last_checked_at), so no HOT update is lost. A search combined with one of these sorts can now walk the
index too: a term matching ~2 % of the videos took 130–200 ms instead of 7 (still under the plain sort it
replaces); rarer and broader terms stay fast. Fencing the index off under a search made broad terms
(`m`, `the`) take 2 s, so it is left to the planner.
The title indexes are the schema's first ones on an ICU collation: an ICU upgrade in the Postgres image
reorders them, and `app.collation.heal_drift` rebuilds them at startup when it sees one.
tests/test_feed_sort_indexes.py holds each sort and its index together.
CONCURRENTLY (autocommit_block) builds them without blocking a live prod; IF (NOT) EXISTS keeps the drift
test's downgrade → re-upgrade round trip idempotent.
"""
from alembic import op
from sqlalchemy import text
revision: str = "0096_videos_sort_indexes"
down_revision: str | None = "0095_videos_channel_cover_index"
branch_labels: str | None = None
depends_on: str | None = None
# name → the indexed ORDER BY (feedquery.sort_keys + order_by: key NULLS LAST, then id).
INDEXES = {
"ix_videos_views_desc": "view_count DESC NULLS LAST, id",
"ix_videos_views_asc": "view_count ASC NULLS LAST, id",
"ix_videos_duration_desc": "duration_seconds DESC NULLS LAST, id",
"ix_videos_duration_asc": "duration_seconds ASC NULLS LAST, id",
"ix_videos_title_en": '(lower(title) COLLATE "en-x-icu") ASC NULLS LAST, id',
"ix_videos_title_en_desc": '(lower(title) COLLATE "en-x-icu") DESC NULLS LAST, id',
"ix_videos_title_hu": '(lower(title) COLLATE "hu-x-icu") ASC NULLS LAST, id',
"ix_videos_title_hu_desc": '(lower(title) COLLATE "hu-x-icu") DESC NULLS LAST, id',
}
def _valid(name: str) -> bool | None:
"""The index's `indisvalid`, or None when there is no index of that name."""
row = (
op.get_bind()
.execute(
text(
"SELECT i.indisvalid FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid "
"WHERE c.relname = :name"
),
{"name": name},
)
.first()
)
return None if row is None else bool(row[0])
def _build(name: str, ddl: str) -> None:
"""CREATE INDEX CONCURRENTLY `name` (`ddl` is what follows the name), re-runnable. A build that
failed part-way leaves an INVALID index under the name, and `IF NOT EXISTS` would keep it: the
planner never uses it, and nothing says so (E31 S5 review). Such a leftover is dropped and built
again."""
if _valid(name) is False:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
op.execute(f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {name} {ddl}")
def upgrade() -> None:
with op.get_context().autocommit_block():
for name, columns in INDEXES.items():
_build(name, f"ON videos ({columns})")
def downgrade() -> None:
with op.get_context().autocommit_block():
for name in INDEXES:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
+46 -2
View File
@@ -7,17 +7,25 @@ sort names one: Hungarian puts "Ö" after "O" and "Cs" after "Cu", English folds
`collated` wraps an ORDER BY expression — and because a keyset cursor compares with the SAME expression
(`feedquery.sort_keys`), the cursor pages in the same order it sorted in. `sorted_words` orders a Python
list the same way through the database, so a list built in Python matches one built in SQL exactly."""
list the same way through the database, so a list built in Python matches one built in SQL exactly.
The feed's title sorts ride indexes built in these collations (migration 0096), and an index keeps the
order of the ICU that built it. `postgres:16-alpine` is a floating tag: an image update can bring a newer
ICU, after which those indexes disagree with the comparisons a query makes (a keyset page skips or
repeats a title). `heal_drift` rebuilds them at startup once Postgres reports the version moved."""
from typing import Any
from sqlalchemy import SQLColumnExpression, column, func, select, values
from sqlalchemy import Engine, SQLColumnExpression, column, func, select, text, values
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.types import String
from app import logs
from app.mailtext import normalize
log = logs.get_logger(__name__)
ICU: dict[str, str] = {"en": "en-x-icu", "hu": "hu-x-icu"}
@@ -37,3 +45,39 @@ def sorted_words(db: Session, words: list[str], lang: str | None) -> list[str]:
return []
w = values(column("w", String), name="words").data([(x,) for x in words])
return list(db.scalars(select(w.c.w).order_by(collated(w.c.w, lang), w.c.w)))
def heal_drift(engine: Engine) -> list[str]:
"""Rebuild every index on an ICU collation whose library version moved, then record the new version.
Returns the rebuilt indexes. CONCURRENTLY: the feed keeps reading while it runs."""
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
drifted = conn.execute(
text(
"SELECT oid, collname FROM pg_collation WHERE collname = ANY(:names) "
"AND collversion IS DISTINCT FROM pg_collation_actual_version(oid)"
),
{"names": list(ICU.values())},
).all()
if not drifted:
return []
indexes = list(
conn.execute(
text(
"SELECT DISTINCT d.objid::regclass::text FROM pg_depend d "
"JOIN pg_class c ON c.oid = d.objid AND c.relkind = 'i' "
"WHERE d.classid = 'pg_class'::regclass "
"AND d.refclassid = 'pg_collation'::regclass AND d.refobjid = ANY(:oids)"
),
{"oids": [row.oid for row in drifted]},
).scalars()
)
log.warning(
"ICU collation version changed (%s): rebuilding %d indexes",
", ".join(row.collname for row in drifted),
len(indexes),
)
for name in indexes:
conn.execute(text(f"REINDEX INDEX CONCURRENTLY {name}"))
for row in drifted:
conn.execute(text(f'ALTER COLLATION "{row.collname}" REFRESH VERSION'))
return indexes
+78 -10
View File
@@ -12,7 +12,7 @@ import binascii
import json
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select
from sqlalchemy import BigInteger, Select, and_, case, cast, false, func, or_, select
from sqlalchemy.orm import Session, aliased
from app.collation import collated
@@ -265,7 +265,8 @@ def filtered_query(
# the queries that surfaced the video > description; see Video.search_vector). YouTube-like
# "fuzzy" matching: word-order-independent, multi-word AND, prefix on the word being typed,
# accent-insensitive (unaccent_simple). The channel name still matches as a substring so
# channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort.
# channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort, over the
# newest RANK_WINDOW matches only (see there).
rank_expr = None
if q:
# Resolve the channels whose NAME matches up front (there are only a few hundred channels, so
@@ -543,6 +544,54 @@ def sort_keys(sort: str, seed: int, rank_expr=None, lang: str | None = None) ->
return table.get(sort) or table["newest"]
# Relevance ranks only the newest RANK_WINDOW matches (E31): ts_rank reads every match's tsvector, and
# a broad word matches most of the library ("a" ranked 193k rows in 6.8 s on prod, against 1.1 s to just
# find them). The rows past the window keep rank 0, so they follow the ranked ones newest first; a word
# that broad has no meaningful relevance order anyway, and a specific one fits the window whole.
RANK_WINDOW = 2000
def rank_window_start(db: Session, query: Select) -> datetime | None:
"""The published_at the ranked window starts at: the RANK_WINDOW-th newest match, None when fewer
match (rank them all). The newest-first walk stops after RANK_WINDOW matches (40 ms for "a")."""
return db.scalar(
query.with_only_columns(Video.published_at)
.order_by(Video.published_at.desc())
.offset(RANK_WINDOW - 1)
.limit(1)
)
def windowed_rank(rank_expr, since: datetime | None):
"""`rank_expr` inside the window, 0 past it. CASE evaluates ts_rank only for the rows inside."""
if since is None:
return rank_expr
return case((Video.published_at >= since, rank_expr), else_=0)
def windowed_relevance_rows(
db: Session, query: Select, keys: list[dict], window: datetime, cursor_id: str | None, n: int
) -> list:
"""Up to `n` rows of the windowed relevance order, read as its two halves: the window ranked, then
the rest newest first (every window row sorts before every row past it). As one ORDER BY the
planner computes the CASE for every match and sorts them all (2.7 s for "a" on prod); split, each
half rides an index (80 ms + 5 ms). With a cursor, `keys` carry its values (keyset_predicate)."""
pub_key = keys[1] # relevance: [rank, published_at]
rows: list = []
if cursor_id is None or pub_key["value"] >= window:
inside = query.where(Video.published_at >= window).order_by(*order_by(keys))
if cursor_id is not None:
inside = inside.where(keyset_predicate(keys, cursor_id))
rows = list(db.execute(inside.limit(n)).all())
if len(rows) < n:
newest = [pub_key] # past the window every rank is 0: the order is newest first
rest = query.where(Video.published_at < window).order_by(*order_by(newest))
if cursor_id is not None:
rest = rest.where(keyset_predicate(newest, cursor_id))
rows += db.execute(rest.limit(n - len(rows))).all()
return rows
def order_by(keys: list[dict]):
cols = []
for k in keys:
@@ -589,26 +638,45 @@ def keyset_predicate(keys: list[dict], cursor_id: str):
return rec(0)
def encode_cursor(keys: list[dict], row) -> str:
def encode_cursor(keys: list[dict], row, window: datetime | None = None) -> str:
"""`window` is the relevance window's start: the cursor carries it, so every page ranks against the
window page one did. Recomputed per page, a video ingested mid-scroll would push the window's oldest
row to rank 0, behind the cursor, and the reader would see it twice."""
vals = []
for i, k in enumerate(keys):
v = getattr(row, f"_k{i}")
vals.append(v.isoformat() if (v is not None and k["kind"] == "dt") else v)
payload = json.dumps({"k": vals, "id": row.id})
body: dict = {"k": vals, "id": row.id}
if window is not None:
body["w"] = window.isoformat()
payload = json.dumps(body)
return base64.urlsafe_b64encode(payload.encode()).decode()
def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str]:
def _cursor_dt(v):
"""A cursor's timestamp: aware or refused. The server writes only aware ones (timestamptz), and
windowed_relevance_rows compares them in Python, where a naive one beside an aware one raises — a
500 for a hand-made cursor instead of the 400 (E31 review 2)."""
if v is None:
return None
dt = datetime.fromisoformat(v)
if dt.tzinfo is None:
raise ValueError("naive cursor timestamp")
return dt
def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str, datetime | None]:
try:
payload = json.loads(base64.urlsafe_b64decode(cursor.encode()))
raw = payload["k"]
if len(raw) != len(keys):
raise ValueError("key arity mismatch")
vals = [
datetime.fromisoformat(v) if (v is not None and k["kind"] == "dt") else v
for k, v in zip(keys, raw)
]
return vals, payload["id"]
vals = [_cursor_dt(v) if k["kind"] == "dt" else v for k, v in zip(keys, raw)]
for k, v in zip(keys, vals):
if v is None and not k["nullable"]:
raise ValueError("null value for a non-nullable key")
window = payload.get("w")
return vals, payload["id"], _cursor_dt(window) if window else None
except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError) as exc:
# Framework-free: the route handler translates this into an HTTP 400 so the
# read-model stays web-layer-agnostic (see routes/feed.get_feed).
+18 -2
View File
@@ -24,11 +24,12 @@ from sqlalchemy.exc import DataError
from starlette.middleware.sessions import SessionMiddleware
from app import bodylimit
from app import collation
from app import logsink
from app import state
from app import sysconfig
from app.config import settings
from app.db import SessionLocal
from app.db import SessionLocal, engine
from app.plex import stream as plex_stream
from app.plex import subtitles as plex_subtitles
from app.routes import (
@@ -97,6 +98,9 @@ async def lifespan(app: FastAPI):
# owns, so racing it against early playback is safe.
sweep = asyncio.create_task(_sweep_hls_orphans())
reaper = asyncio.create_task(_reap_hls_sessions())
# Fire and forget as well: a rebuild after an ICU upgrade takes seconds, and the title sorts serve
# meanwhile (CONCURRENTLY) in the old index's order, as they did before the restart.
heal = asyncio.create_task(_heal_collation_drift())
start_scheduler()
try:
yield
@@ -106,7 +110,7 @@ async def lifespan(app: FastAPI):
# pending ("Task was destroyed but it is pending!" — noise that reads like a bug during
# triage). Cancelling does NOT interrupt the rmtree thread itself; this only makes our own
# bookkeeping honest.
for task in (sweep, reaper):
for task in (sweep, reaper, heal):
task.cancel()
with suppress(asyncio.CancelledError):
await task
@@ -139,6 +143,18 @@ async def _reap_hls_sessions() -> None:
log.exception("HLS session reap failed")
async def _heal_collation_drift() -> None:
"""Background startup check of the ICU collation the title-sort indexes are built in (see
`collation.heal_drift`). A failure is logged, never fatal: the sorts still answer, only possibly in
the old order."""
try:
await asyncio.to_thread(collation.heal_drift, engine)
except asyncio.CancelledError:
raise
except Exception:
log.exception("ICU collation drift check failed")
async def _sweep_hls_orphans() -> None:
"""Background half of the startup HLS sweep (see lifespan). Owns its own errors: a failure here
must never take the app down, and a cancelled shutdown is not one."""
+9 -4
View File
@@ -330,10 +330,12 @@ class Video(Base, TimestampMixin):
__tablename__ = "videos"
id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id
# No standalone channel_id index: the composite `ix_videos_channel_pub_desc`
# (channel_id, published_at DESC NULLS LAST, id) from migration 0067 leads with channel_id, so it
# serves every channel_id lookup (FK cascade, sync upsert, channel-detail) AND the per-channel feed
# sort. Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`.
# No standalone channel_id index: the composite `ix_videos_channel_pub_cover`
# (channel_id, published_at DESC NULLS LAST, id) from migration 0095 leads with channel_id, so it
# serves every channel_id lookup (FK cascade, sync upsert, channel-detail) and the per-channel feed
# sort. Its INCLUDEd columns make the channel manager's sums and the feed sidebar's counts Index Only
# Scans (no heap): a column those queries start reading belongs in it (test_channel_list_index_only).
# Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`.
channel_id: Mapped[str] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles)
original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved
@@ -347,6 +349,9 @@ class Video(Base, TimestampMixin):
DateTime(timezone=True), index=True, nullable=False, server_default=func.now()
)
thumbnail_url: Mapped[str | None] = mapped_column(String(1024))
# The feed's views / duration / title sorts each ride a migration-defined index in their exact
# ORDER BY, one per direction (and per ICU collation for title): migration 0096, held to
# `feedquery.order_by` by test_feed_sort_indexes.
duration_seconds: Mapped[int | None] = mapped_column(Integer)
view_count: Mapped[int | None] = mapped_column(BigInteger)
like_count: Mapped[int | None] = mapped_column(BigInteger)
+17 -3
View File
@@ -23,9 +23,23 @@ log = logs.get_logger(__name__)
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
_CT_EXT = {ct: ext for ext, ct in _EXT_CT}
# Target box per variant — Plex resizes to these server-side. Posters fill ≤176px cells (grid/info/
# strips), so 400px covers 2× DPR; the faint backdrop art only needs ~1280px. Keeps images ~tens of KB.
SIZES = {"thumb": (400, 600), "art": (1280, 720)}
# Target box per variant — Plex resizes to these server-side. The full poster (400px) covers a detail
# page's ≤224px poster at 2× DPR, the full art (1280px) a hero backdrop. A card is much smaller: a grid
# poster is ~144px and a shelf's landscape art ~212px, so the `_sm` pair serves those at 1.33× DPR
# (E31 S4e: cards fetched the 400 poster, 42 KB, and the 1280 art for a 212px picture). The browser
# picks between a pair itself (`srcset`, built by the frontend's `lib/plexImage` cardImage, whose
# widths plexImage.contract.test holds to these).
SIZES = {
"thumb": (400, 600),
"thumb_sm": (240, 360),
"art": (1280, 720),
"art_sm": (480, 270),
}
def picture(variant: str) -> str:
"""Which stored picture a size variant is cut from: the poster (`thumb`) or the backdrop (`art`)."""
return "art" if variant.startswith("art") else "thumb"
def key_for(db: Session, rating_key: str, variant: str) -> str | None:
+13
View File
@@ -24,6 +24,19 @@ def report(current: int, total: int | None = None, phase: str | None = None) ->
sink(current, total, phase)
class JobFailed(RuntimeError):
"""A job's own failure, said so the reader's language can name it (E31): the client reads
`scheduler.failure.<reason>` and interpolates `params` (keep them to values the reader may see —
numbers, ids, short codes). The message is the English line the log and `last_error` keep. An
upstream's own error (a timeout, a refused connection) is not one of these: it stays as raised,
being diagnostic."""
def __init__(self, reason: str, message: str, **params: object):
super().__init__(message)
self.reason = reason
self.params = params
class bind:
"""Context manager that binds a progress sink for the duration of a job run."""
+21 -14
View File
@@ -6,7 +6,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy import Select, func, select, update
from sqlalchemy.orm import Session
from app.collation import collated, sorted_words
@@ -40,6 +40,22 @@ router = APIRouter(prefix="/api/channels", tags=["channels"])
log = logs.get_logger(__name__)
def channel_aggregates(channel_ids: list[str]) -> Select:
"""Per-channel stored count, last upload and total duration, in one grouped pass. Every column it
reads is in `ix_videos_channel_pub_cover` (migration 0095), so Postgres answers it from the index
alone; a test holds that (test_channel_list_index_only)."""
return (
select(
Video.channel_id,
func.count(Video.id),
func.max(Video.published_at),
func.coalesce(func.sum(Video.duration_seconds), 0),
)
.where(Video.channel_id.in_(channel_ids))
.group_by(Video.channel_id)
)
@router.get("")
def list_channels(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(
@@ -49,24 +65,15 @@ def list_channels(user: User = Depends(current_user), db: Session = Depends(get_
.order_by(Subscription.priority.desc(), collated(Channel.title, lang_for_user(user)))
).all()
# Per-channel aggregates over the shared catalog in ONE grouped pass: stored count, last
# upload, total duration. Cheap — a single grouped scan, so computed on read rather than
# denormalised (measure-first: reads aren't frequent/hot enough to warrant cached columns).
# Per-channel aggregates over the shared catalog (channel_aggregates), computed on read rather than
# denormalised: an Index Only Scan over a ~20 MB index, where it used to read every video's heap page
# (681 MB on prod, a 2–6 s first open once something else had pushed it out of memory, E31 S4d).
# (The Normal/Shorts/Live breakdown moved to the channel-detail page in R14, so it is no longer
# computed here — the manager table dropped its N/S/L column.)
channel_ids = [c.id for _, c in rows]
agg: dict[str, dict] = {}
if channel_ids:
for cid, n, last_up, total_dur in db.execute(
select(
Video.channel_id,
func.count(Video.id),
func.max(Video.published_at),
func.coalesce(func.sum(Video.duration_seconds), 0),
)
.where(Video.channel_id.in_(channel_ids))
.group_by(Video.channel_id)
).all():
for cid, n, last_up, total_dur in db.execute(channel_aggregates(channel_ids)).all():
agg[cid] = {
"stored": n,
"last_video_at": iso(last_up),
+28 -10
View File
@@ -19,8 +19,11 @@ from app.feedquery import (
filtered_query,
keyset_predicate,
order_by,
rank_window_start,
serialize,
sort_keys,
windowed_rank,
windowed_relevance_rows,
)
from app.models import (
POSITION_MAX_S,
@@ -109,30 +112,45 @@ def get_feed(
db: Session = Depends(get_db),
) -> dict:
query, rank_expr = filtered_query(db, user, **params)
keys = sort_keys(sort, seed, rank_expr, lang_for_user(user))
# Expose each key column on the row so we can build the next cursor from the last item.
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
query = query.order_by(*order_by(keys))
lang = lang_for_user(user)
keys = sort_keys(sort, seed, rank_expr, lang)
vals, cursor_id, window = None, None, None
if cursor:
# The read-model raises a plain ValueError on a malformed cursor; translate it to the
# HTTP 400 here so feedquery stays web-framework-free.
try:
vals, cursor_id = decode_cursor(keys, cursor)
vals, cursor_id, window = decode_cursor(keys, cursor)
except ValueError:
# `from None`: the ValueError is an expected bad-input signal, not an error to chain.
raise refused(400, "invalid_cursor") from None
ranked = sort == "relevance" and rank_expr is not None
if ranked and not cursor:
window = rank_window_start(db, query)
if ranked and window is not None:
keys = sort_keys(sort, seed, windowed_rank(rank_expr, window), lang)
# Expose each key column on the row so we can build the next cursor from the last item.
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
if vals is not None:
for k, v in zip(keys, vals):
k["value"] = v
query = query.where(keyset_predicate(keys, cursor_id))
rows = db.execute(query.limit(limit + 1)).all()
if ranked and window is not None:
rows = windowed_relevance_rows(db, query, keys, window, cursor_id, limit + 1)
else:
query = query.order_by(*order_by(keys))
if cursor_id is not None:
query = query.where(keyset_predicate(keys, cursor_id))
rows = db.execute(query.limit(limit + 1)).all()
has_more = len(rows) > limit
page = rows[:limit]
return {
"items": [serialize(r) for r in page],
"next_cursor": encode_cursor(keys, page[-1]) if (has_more and page) else None,
"next_cursor": (
encode_cursor(keys, page[-1], window if ranked else None)
if (has_more and page)
else None
),
"has_more": has_more,
"limit": limit,
}
+23 -5
View File
@@ -94,13 +94,31 @@ def _system_partner() -> dict:
return {"id": SYSTEM_PARTNER_ID, "name": SYSTEM_NAME, "avatar_url": None}
def _serialize_msg(m: Message) -> dict:
# The welcome's opening line in every language — how a stored copy is recognised. It was rendered
# once, in the language of the day it was created, so an account that later switched language kept
# reading it in the old one (E31 S1: the owner's, in English). Recognised here, it is served in the
# reader's language instead. Derived from WELCOME itself, so a change to an OPENING line, or a
# language taken out of WELCOME, orphans the copies already stored: they are served as stored. The
# one retired language (German, 2026-07) left none on prod (checked 2026-10-02).
_WELCOME_OPENINGS = {text.partition("\n")[0] for text in WELCOME.values()}
def _system_body(body: str | None, lang: str | None) -> str | None:
"""A system message's text for a reader of `lang`: the welcome in their language, any other as
stored."""
if body is None or lang is None or body.partition("\n")[0] not in _WELCOME_OPENINGS:
return body
return WELCOME.get(lang, WELCOME["en"])
def _serialize_msg(m: Message, lang: str | None = None) -> dict:
"""`lang`: the reader's language, for a system message's text (a user message is ciphertext)."""
return {
"id": m.id,
"kind": m.kind,
"sender_id": m.sender_id,
"recipient_id": m.recipient_id,
"body": m.body, # only set for system messages
"body": _system_body(m.body, lang) if m.kind == "system" else m.body,
"ciphertext": m.ciphertext,
"iv": m.iv,
"read_at": iso(m.read_at),
@@ -124,7 +142,7 @@ def ensure_welcome(db: Session, user: User) -> None:
db.add(m)
db.commit()
db.refresh(m)
manager.push(user.id, {"type": "message", "message": _serialize_msg(m)})
manager.push(user.id, {"type": "message", "message": _serialize_msg(m, lang_for_user(user))})
# --- E2EE key directory / blob store -----------------------------------------
@@ -313,7 +331,7 @@ def list_conversations(user: User = Depends(require_human), db: Session = Depend
items.append(
{
"partner": partner,
"last_message": _serialize_msg(m),
"last_message": _serialize_msg(m, lang_for_user(user)),
"unread": int(unread.get(pid, 0)),
}
)
@@ -366,7 +384,7 @@ def get_thread(
# refreshes live instead of waiting for the next 20s poll. The client re-fetches the
# count on this signal, so no number is carried here.
manager.push(user.id, {"type": "unread"})
return {"partner": partner, "items": [_serialize_msg(m) for m in msgs]}
return {"partner": partner, "items": [_serialize_msg(m, lang_for_user(user)) for m in msgs]}
@router.post("")
+20 -8
View File
@@ -358,6 +358,24 @@ def facets(
uid = user.id
# The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a
# full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups
# (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call
# (E31 S4e). The same filter on the same rows, so the counts do not change
# (test_plex_facets_grid_parity).
shows_in_scope: list[int] | None = None
if show_ids and show in ("watched", "in_progress", "unwatched"):
agg = rollup.agg_subquery(db, uid).subquery()
in_scope = select(PlexShow.id).outerjoin(agg, agg.c.show_id == PlexShow.id)
in_scope = apply_show_state_filter(
in_scope,
func.coalesce(agg.c.total, 0),
func.coalesce(agg.c.watched, 0),
func.coalesce(agg.c.inprog, 0),
show,
)
shows_in_scope = list(db.scalars(in_scope.where(PlexShow.library_id.in_(show_ids))))
def wsq(query, model, ids, pp, kind_movie, with_dur):
"""Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a
facet aggregation query, so the facets reflect exactly the visible result set."""
@@ -376,14 +394,8 @@ def facets(
st = aliased(PlexState)
query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid))
query = apply_movie_state_filter(query, st, show)
else:
if show in ("watched", "in_progress", "unwatched"):
agg = rollup.agg_subquery(db, uid).subquery()
query = query.outerjoin(agg, agg.c.show_id == model.id)
tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0)
query = apply_show_state_filter(query, tot, wat, inp, show)
elif shows_in_scope is not None:
query = query.where(model.id.in_(shows_in_scope))
return query
genre_counts: dict[str, int] = {}
+2 -2
View File
@@ -65,7 +65,7 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
brief session only to resolve the image key + read the Plex config, then RELEASES it before the
fetch. Auth is the signed-session check (no DB user-load)."""
_require_session(request)
v = "art" if variant == "art" else "thumb"
v = variant if variant in imgcache.SIZES else "thumb"
w, h = imgcache.SIZES[v]
# Size is part of the cache key: Plex resizes server-side (see PlexClient.image_bytes), so we cache
# the small variant. The width tag also means changing a target size just re-caches, never serves a
@@ -77,7 +77,7 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
# Cold miss: short-lived session for the key + Plex client config, released before the slow fetch.
try:
with SessionLocal() as db:
key = imgcache.key_for(db, str(rating_key), v)
key = imgcache.key_for(db, str(rating_key), imgcache.picture(v))
if not key:
raise not_found("image")
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
+8 -3
View File
@@ -231,17 +231,22 @@ def _job(name: str, fn) -> None:
db.rollback()
log.exception("job %s failed", name, extra=logs.event("scheduler.job.failed", job=name))
err = str(exc) or exc.__class__.__name__
# A failure the job named (progress.JobFailed) rides along as data, as a result does, so the
# client says it in the reader's language; `err` stays the English line.
failure = (
{"failure": exc.reason, **exc.params} if isinstance(exc, progress.JobFailed) else None
)
_record(
name,
status="error",
last_finished=_now_iso(),
last_error=err,
result=None,
result=failure,
progress=None,
)
if actor_id is not None:
_notify_done(db, actor_id, name, "error", err)
_audit_run(db, actor_id, name, "error", err, None)
_notify_done(db, actor_id, name, "error", err, failure)
_audit_run(db, actor_id, name, "error", err, failure)
finally:
# ALWAYS release the claim, even if the body raised before its own status update — so a
# failure can never wedge the job as permanently "running".
+21 -6
View File
@@ -83,6 +83,8 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# outage from one permanently dead feed id — which is the whole difference on an instance with
# a single subscription, where BOTH render as 100% failed.
first_error: str | None = None
# The same first failure as data, for the reader's language: whose feed, and why (`reasons`' key).
first_failure: tuple[str, str] | None = None
# What failed, as counts per reason (`http_404`, `transport`, `apply`, …) plus a few examples —
# the ONE warning a poll writes. One line per failed channel was what turned YouTube's RSS outage
# of 2026-09-16 into 11k warnings in four hours; each channel's own line is DEBUG now.
@@ -92,18 +94,19 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# hand the same rows to the same broken write) — the feed fallback's candidates.
unread: list[str] = []
def fail(reason: str, detail: str, *, unexpected: str | None = None) -> None:
def fail(reason: str, channel_id: str, detail: str, *, unexpected: str | None = None) -> None:
"""Count a channel that did not get polled. `unexpected` is the message for a failure that
is a bug on our side: its FIRST occurrence per reason gets a traceback at ERROR, the rest
DEBUG — a read-only database fails every channel the same way, and 979 identical tracebacks
tell nobody anything the first one didn't. Call it from inside the `except` block."""
nonlocal failed, first_error
nonlocal failed, first_error, first_failure
if unexpected is not None:
level = logging.ERROR if reasons[reason] == 0 else logging.DEBUG
log.log(level, unexpected, exc_info=True)
failed += 1
reasons[reason] += 1
first_error = first_error or detail
first_failure = first_failure or (channel_id, reason)
if len(examples) < RSS_FAILURE_EXAMPLES:
examples.append(detail)
@@ -125,13 +128,16 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# bug on our side and earns a traceback (the first of its kind per poll).
except RssError as exc:
log.debug("RSS poll skipped for channel %s: %s", channel.id, exc)
fail(exc.reason, f"{channel.id}: {exc}")
fail(exc.reason, channel.id, f"{channel.id}: {exc}")
unread.append(channel.id)
# silent-ok: `fail(unexpected=…)` is the report — it logs at ERROR with the
# traceback, once per reason per poll, which is the flood control this loop needs.
except Exception as exc: # noqa: BLE001 — one channel must not stop the poll
fail(
"error", f"{channel.id}: {exc}", unexpected=f"RSS fetch failed for {channel.id}"
"error",
channel.id,
f"{channel.id}: {exc}",
unexpected=f"RSS fetch failed for {channel.id}",
)
else:
try:
@@ -144,6 +150,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
db.rollback()
fail(
"apply",
channel.id,
f"{channel.id} (apply): {exc}",
unexpected=f"RSS apply failed for {channel.id}",
)
@@ -180,9 +187,17 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# "fix this subscription"; `Connection refused` says "the egress is down".
# The fallback's count rides along: a red card during an RSS outage should still say
# whether the uploads are arriving another way.
raise RuntimeError(
# As data too (E31): the audit log and the inbox say it in the reader's language, the first
# failure as its channel and its reason code (`http_404` vs `transport` is that difference).
first_channel, first_reason = first_failure or ("", "error")
raise progress.JobFailed(
"rss_all_failed",
f"RSS poll failed for all {total} channels — first: {first_error}"
f" (read through the API instead: {fallback['read']})"
f" (read through the API instead: {fallback['read']})",
total=total,
channel=first_channel,
cause=first_reason,
read=fallback["read"],
)
return {"new": new + fallback["new"], "failed": failed, "fallback": fallback["read"]}
+33 -8
View File
@@ -10,6 +10,7 @@ stored in the DB and stay env-only).
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import logs, security
@@ -45,8 +46,8 @@ class ConfigSpec:
# Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it
# off env into the admin UI; the admin page renders it automatically from this list. The ORDER is the
# admin page's tab order (first appearance of each group), so keep a group's keys together.
# off env into the admin UI; the admin page renders it automatically from this list. A group's keys
# stay together, in the order its section lists them; a NEW group also needs its place in SECTIONS.
SPECS: tuple[ConfigSpec, ...] = (
# --- Access / registration ---
ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
@@ -219,6 +220,16 @@ SPECS: tuple[ConfigSpec, ...] = (
# is a config.py default — both stay out of SPECS, like plex_sync_interval_min.)
)
# The admin page's sections (E31, the owner's pick): sixteen groups as tabs wrapped to three rows at 133%
# zoom, so they are a vertical list under four headings, in this order. Every group of SPECS sits in
# exactly one section (test-held), so a new group cannot fall off the page.
SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = (
("access", ("access", "google", "email")),
("youtube", ("youtube", "quota", "backfill", "shorts", "batch", "explore")),
("media", ("downloads", "plex", "tmdb")),
("system", ("maintenance", "audit", "diagnostics", "issues")),
)
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
@@ -297,7 +308,11 @@ def _raw_override(db: Session, key: str) -> str | None:
per key so the operator gets a real signal without a per-request traceback. Recovery is documented in
docs/self-hosting.md: the key must never change once secrets are stored. (B-core-2)
"""
row = db.get(SystemConfig, key)
return _raw_of(db.get(SystemConfig, key), key)
def _raw_of(row: SystemConfig | None, key: str) -> str | None:
"""`_raw_override` over a row already read (describe() reads them all in one query)."""
if row is None or row.value is None:
return None
if not row.encrypted:
@@ -317,8 +332,11 @@ def _raw_override(db: Session, key: str) -> str | None:
def get(db: Session, key: str):
"""Effective value: the DB override (typed) if set, else the env/config default."""
s = _BY_KEY[key]
raw = _raw_override(db, key)
return _effective(_BY_KEY[key], _raw_override(db, key))
def _effective(s: ConfigSpec, raw: str | None):
"""`get` over an override already read."""
if raw is None:
return _default(s)
if raw == "" and not s.allow_empty:
@@ -410,8 +428,12 @@ def describe(db: Session) -> dict:
"""Admin-UI metadata + current values, grouped. Secret values are never echoed —
only whether an override is set and whether an env default exists."""
groups: dict[str, list[dict]] = {}
# Every override in one query: a db.get per key was one round trip each (two for a key with no
# row, which the session cannot cache), ~40 ms of the page's ~55 on prod (E31 S4d).
rows = {r.key: r for r in db.scalars(select(SystemConfig))}
for s in SPECS:
is_set = db.get(SystemConfig, s.key) is not None
row = rows.get(s.key)
is_set = row is not None
item: dict = {
"key": s.key,
"type": s.type,
@@ -426,7 +448,10 @@ def describe(db: Session) -> dict:
if s.secret:
item["default_is_set"] = bool(_default(s))
else:
item["value"] = get(db, s.key)
item["value"] = _effective(s, _raw_of(row, s.key))
item["default"] = _default(s)
groups.setdefault(s.group, []).append(item)
return {"groups": groups, "secrets_manageable": secrets_manageable()}
sections = [
{"id": sid, "groups": [g for g in members if g in groups]} for sid, members in SECTIONS
]
return {"groups": groups, "sections": sections, "secrets_manageable": secrets_manageable()}
+10 -2
View File
@@ -60,16 +60,24 @@ def now_utc() -> datetime:
return datetime.now(timezone.utc)
# The shortest last word that is prefix-matched. A 1–2 letter prefix matches a large share of the
# library and ts_rank then scores every match: "m" took ~5–8 s on prod against ~100 ms for a whole
# word (E31 S4e). A shorter last word is matched whole instead ("tv", "4k" still find themselves).
MIN_PREFIX_LEN = 3
def to_tsquery_str(q: str) -> str | None:
"""Turn free-text into a to_tsquery() string: word runs ANDed together, the LAST word
prefix-matched (`:*`) for as-you-type search — e.g. "ti amo magyar" → "ti & amo & magyar:*".
prefix-matched (`:*`) for as-you-type search — e.g. "ti amo magyar" → "ti & amo & magyar:*" —
once it has MIN_PREFIX_LEN characters ("ti amo m" → "ti & amo & m").
Only `\\w` runs containing an alphanumeric are kept, so no tsquery operators can leak in
(the string is built from sanitised tokens, never the raw query). Shared by the YouTube feed
search and the Plex library search, hence this neutral home rather than either domain module."""
tokens = [t for t in re.findall(r"\w+", q, flags=re.UNICODE) if any(c.isalnum() for c in t)]
if not tokens:
return None
tokens[-1] = tokens[-1] + ":*"
if len(tokens[-1]) >= MIN_PREFIX_LEN:
tokens[-1] = tokens[-1] + ":*"
return " & ".join(tokens)
@@ -0,0 +1,83 @@
"""The channel manager's sums and the feed sidebar's counts read the index alone (E31 S4d).
`GET /api/channels` counts each subscribed channel's videos and sums their durations; `/feed/summary`
and `/feed/years` count the reader's videos per channel and per year under the default filters. They
used to read every video's heap page for that (681 MB on prod), a 2–6 s first open once something else
had pushed the table out of memory. Migration 0095 makes `ix_videos_channel_pub_cover` carry every
column those queries read, so Postgres answers them with Index Only Scans. This test holds the queries
and the index together: a column added to one and not the other fails it. DB lane.
"""
import re
from sqlalchemy import text
from sqlalchemy.dialects import postgresql
from app.feedquery import filtered_query
from app.models import Video
from app.routes.channels import channel_aggregates
INDEX = "ix_videos_channel_pub_cover"
# The feed's own defaults (routes/feed.py _feed_params): what the sidebar asks on a plain open.
FEED_DEFAULTS = {
"tags": [],
"tag_modes": {"language": "or", "topic": "or", "other": "or"},
"untagged": False,
"channel_ids": [],
"q": None,
"min_duration": None,
"max_duration": None,
"max_age_days": None,
"published_after": None,
"published_before": None,
"show_normal": True,
"include_shorts": False,
"include_live": False,
"show": "unwatched",
"scope": "my",
"library_source": "organic",
}
def _index_columns(db) -> list[str]:
# Key columns first, then the INCLUDE ones, in the index's own order.
return list(
db.execute(
text(
"SELECT a.attname FROM pg_index i "
"JOIN pg_class c ON c.oid = i.indexrelid "
"CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, n) "
"JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum "
"WHERE c.relname = :name ORDER BY k.n"
),
{"name": INDEX},
).scalars()
)
def _video_columns(stmt) -> set[str]:
# The columns of `videos` the statement as SENT reads: the compiled SQL, not the expression tree,
# which still carries the projection a with_only_columns replaced.
sql = str(stmt.compile(dialect=postgresql.dialect()))
return set(re.findall(r"\bvideos\.(\w+)", sql))
def _assert_covered(db, read: set[str]) -> None:
held = _index_columns(db)
assert held, f"{INDEX} is missing"
# It leads with channel_id, so it also serves every per-channel lookup the old index did.
assert held[0] == "channel_id"
assert read <= set(held), f"read but not in {INDEX}: {sorted(read - set(held))}"
def test_the_channel_sums_read_only_columns_the_index_holds(db):
_assert_covered(db, _video_columns(channel_aggregates(["UC_x"])))
def test_the_feed_sidebar_counts_read_only_columns_the_index_holds(db, make_user):
# feed_summary groups the default filter by channel, feed_years by publish date.
base, _rank = filtered_query(db, make_user(), **FEED_DEFAULTS)
_assert_covered(
db, _video_columns(base.with_only_columns(Video.channel_id, Video.published_at))
)
+133
View File
@@ -0,0 +1,133 @@
"""Relevance ranks only the newest RANK_WINDOW matches (E31), DB-lane.
A broad word ("a") matched most of the prod library and ts_rank read every match's tsvector: 6.8 s a page.
The ranking now covers the newest RANK_WINDOW matches and the rest follow newest first. The window's start
rides the cursor, so a video ingested mid-scroll cannot push a row behind the cursor and repeat it.
"""
import base64
import json
from datetime import datetime, timezone
import pytest
from app import feedquery
from app.models import Channel, Subscription, User, Video
def _video(vid: str, day: int, title: str) -> Video:
return Video(
id=vid,
channel_id="UCw",
published_at=datetime(2024, 1, day, tzinfo=timezone.utc),
title=title,
is_short=False,
live_status="none",
via_search=False,
via_explore=False,
)
def _seed(db) -> User:
db.add(Channel(id="UCw", title="Window"))
user = User(email="window@example.com", email_verified=True)
db.add(user)
db.flush()
db.add(Subscription(user_id=user.id, channel_id="UCw", priority=0, hidden=False))
# m1 is the oldest and the strongest match, m4 the strongest of the newest three; the rest tie on one
# title "foo" each (ts_rank counts occurrences, it does not normalise by length).
db.add_all(
[
_video("m1", 1, "foo foo foo foo foo"),
_video("m2", 2, "foo and some other words here"),
_video("m3", 3, "foo plus some filler words"),
_video("m4", 4, "foo foo foo"),
_video("m5", 5, "foo with a longer title"),
_video("m6", 6, "foo with yet another title"),
]
)
db.commit()
return user
def _ids(client, cursor=None, limit=50):
url = f"/api/feed?q=foo&sort=relevance&limit={limit}"
if cursor:
url += f"&cursor={cursor}"
resp = client.get(url)
assert resp.status_code == 200, resp.text
body = resp.json()
return [it["id"] for it in body["items"]], body["next_cursor"]
def test_a_specific_word_ranks_every_match(db, as_user):
client = as_user(_seed(db))
ids, _ = _ids(client)
assert ids[:2] == ["m1", "m4"], ids
def test_a_broad_word_ranks_the_newest_window_and_the_rest_follow_newest_first(
db, as_user, monkeypatch
):
monkeypatch.setattr(feedquery, "RANK_WINDOW", 3)
client = as_user(_seed(db))
ids, _ = _ids(client)
assert ids[0] == "m4", ids # the strongest of the newest three
assert sorted(ids[:3]) == ["m4", "m5", "m6"], ids
assert ids[3:] == ["m3", "m2", "m1"], ids # past the window: newest first, m1 not ranked
def test_paging_keeps_the_window_page_one_ranked_against(db, as_user, monkeypatch):
monkeypatch.setattr(feedquery, "RANK_WINDOW", 3)
client = as_user(_seed(db))
full, _ = _ids(client)
first, cursor = _ids(client, limit=2)
assert first == full[:2]
# A newer match lands mid-scroll. Ranked against a recomputed window, m4 (shown on page one) would
# fall out of it to rank 0, sort behind the cursor and come back on page two.
db.add(_video("m7", 7, "foo arriving while the reader scrolls"))
db.commit()
walked = list(first)
while cursor:
page, cursor = _ids(client, cursor, limit=2)
walked.extend(page)
assert len(walked) == len(set(walked)), walked
assert set(full) <= set(walked), walked
@pytest.mark.parametrize(
("window", "limit"),
# 3: the page is read as its two halves (the window ranked, the rest newest first), and pages of 2
# and 3 cross the window's edge mid-page and exactly at it. 2000: one ranked query.
[(3, 2), (3, 3), (2000, 2)],
)
def test_cursor_walk_matches_the_unpaged_order(db, as_user, monkeypatch, window, limit):
monkeypatch.setattr(feedquery, "RANK_WINDOW", window)
client = as_user(_seed(db))
full, _ = _ids(client)
if window == 3:
assert full == ["m4", "m6", "m5", "m3", "m2", "m1"]
walked, cursor = _ids(client, limit=limit)
while cursor:
page, cursor = _ids(client, cursor, limit=limit)
walked.extend(page)
assert walked == full
@pytest.mark.parametrize(
"payload",
[
{"k": [5, None], "id": "m1", "w": "2024-01-01T00:00:00+00:00"}, # null published_at
{"k": [5, "2024-01-03T00:00:00+00:00"], "id": "m1", "w": "2024-01-01T00:00:00"}, # naive w
{"k": [5, "2024-01-03T00:00:00"], "id": "m1", "w": "2024-01-01T00:00:00+00:00"}, # naive k
],
)
def test_a_hand_made_relevance_cursor_is_refused_not_a_crash(db, as_user, payload):
# The page halves compare the cursor's timestamp with the window's in Python: a null or a naive
# one raised there, past the route's 400 — a 500 (E31 review 2).
client = as_user(_seed(db))
cursor = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
resp = client.get(f"/api/feed?q=foo&sort=relevance&limit=2&cursor={cursor}")
assert resp.status_code == 400, resp.text
+100
View File
@@ -0,0 +1,100 @@
"""Every feed sort on a videos column rides an index, and an ICU upgrade rebuilds the title ones (E31 S4e).
Without an index in the exact ORDER BY, each feed page read the whole videos heap and kept a top-N
(prod: 350 ms a page for views/duration, 550–780 ms for title). Migration 0096 adds one per direction
and per reader language. The planner is told it may not sort or scan the table, so a plan that still
sorts it whole means no index matches the ORDER BY `feedquery.order_by` builds: a key, direction, NULLS
rule or collation changed on one side only. An Incremental Sort is fine: the index gives the key's order
and only its ties are sorted by id (`newest`/`oldest` ride the published_at indexes that way). DB lane.
"""
import re
import pytest
from sqlalchemy import select, text
from sqlalchemy.dialects import postgresql
from app import collation
from app.db import engine
from app.feedquery import order_by, sort_keys
from app.models import Video
VIDEO_SORTS = [
"newest",
"oldest",
"views",
"views_asc",
"duration_desc",
"duration_asc",
"title",
"title_desc",
]
def _plan(db, sort: str, lang: str) -> str:
stmt = select(Video.id).order_by(*order_by(sort_keys(sort, 0, None, lang))).limit(60)
sql = str(stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}))
db.execute(text("SET LOCAL enable_seqscan = off"))
db.execute(text("SET LOCAL enable_sort = off"))
db.execute(text("SET LOCAL enable_bitmapscan = off"))
return "\n".join(db.execute(text(f"EXPLAIN {sql}")).scalars())
@pytest.mark.parametrize("lang", sorted(collation.ICU))
@pytest.mark.parametrize("sort", VIDEO_SORTS)
def test_the_sort_walks_an_index(db, sort, lang):
plan = _plan(db, sort, lang)
db.rollback()
full_sort = re.search(r"(?<!Incremental )Sort \(", plan)
assert not full_sort, f"{sort} ({lang}) sorts the table:\n{plan}"
assert "Index Scan" in plan or "Index Only Scan" in plan, plan
def _drifted(db) -> list[str]:
return list(
db.execute(
text(
"SELECT collname FROM pg_collation WHERE collname = ANY(:names) "
"AND collversion IS DISTINCT FROM pg_collation_actual_version(oid)"
),
{"names": list(collation.ICU.values())},
).scalars()
)
def _files(db) -> dict[str, int]:
return dict(
db.execute(
text("SELECT relname, relfilenode FROM pg_class WHERE relname LIKE 'ix_videos_title_%'")
).all()
)
def test_an_icu_upgrade_rebuilds_the_title_indexes(db):
# What an image update with a newer ICU looks like from inside: the recorded version no longer
# matches the library's (the test database runs as the bootstrap superuser, as the app's does).
db.execute(text("UPDATE pg_collation SET collversion = '0.0' WHERE collname = 'hu-x-icu'"))
db.commit()
try:
assert _drifted(db) == ["hu-x-icu"]
before = _files(db)
# A concurrent rebuild waits for every open transaction, this session's too.
db.rollback()
rebuilt = collation.heal_drift(engine)
assert sorted(rebuilt) == ["ix_videos_title_hu", "ix_videos_title_hu_desc"]
after = _files(db)
# Rebuilt = written to new files; the English pair, on an unchanged ICU, is left alone.
assert {n for n in before if before[n] != after[n]} == set(rebuilt)
assert _drifted(db) == []
finally:
db.execute(
text(
"UPDATE pg_collation SET collversion = pg_collation_actual_version(oid) "
"WHERE collname = 'hu-x-icu'"
)
)
db.commit()
def test_no_drift_rebuilds_nothing(db):
assert collation.heal_drift(engine) == []
+53
View File
@@ -0,0 +1,53 @@
"""E31 S1: the welcome message is rendered once, when the account is created, in the language of that day.
An account that switched language afterwards kept reading it in the old one (the owner's: English under a
Hungarian UI). It is served in the reader's language now; any other system message stays as stored.
Needs the DB."""
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import Message, User
from app.routes.messages import WELCOME
def _client(db, user, monkeypatch, override_dep) -> TestClient:
monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user)
return TestClient(app)
def _user_with_english_welcome(db) -> User:
"""An account whose welcome was written in English, then switched to Hungarian."""
user = User(email="welcome-hu@example.com", preferences={"language": "hu"})
db.add(user)
db.flush()
db.add(Message(kind="system", sender_id=None, recipient_id=user.id, body=WELCOME["en"]))
db.commit()
return user
def test_the_welcome_is_read_in_the_readers_language(db, monkeypatch, override_dep):
user = _user_with_english_welcome(db)
client = _client(db, user, monkeypatch, override_dep)
thread = client.get("/api/messages/conversations/0").json()["items"]
assert [m["body"] for m in thread] == [WELCOME["hu"]]
convs = client.get("/api/messages/conversations").json()["items"]
assert [c["last_message"]["body"] for c in convs if c["partner"]["id"] == 0] == [WELCOME["hu"]]
def test_another_system_message_is_served_as_stored(db, monkeypatch, override_dep):
user = _user_with_english_welcome(db)
db.add(
Message(kind="system", sender_id=None, recipient_id=user.id, body="Maintenance tonight.")
)
db.commit()
client = _client(db, user, monkeypatch, override_dep)
bodies = [m["body"] for m in client.get("/api/messages/conversations/0").json()["items"]]
assert bodies == [WELCOME["hu"], "Maintenance tonight."]
@@ -0,0 +1,53 @@
"""A concurrent index build that failed part-way, then re-run (E31 S5 review), DB-lane.
CREATE INDEX CONCURRENTLY that fails leaves an INVALID index under its name, and alembic_version is not
stamped, so the next start runs the migration again. `IF NOT EXISTS` matched the name and kept the
leftover, which the planner never uses; 0095 then dropped the old channel_id index, leaving `videos`
with no usable one. A re-run now rebuilds the leftover and drops the old index only once the new one is
valid.
"""
from alembic import command
from alembic.config import Config
from sqlalchemy import text
from app.db import engine
def _index(name: str) -> tuple[bool, str] | None:
with engine.connect() as conn:
row = conn.execute(
text(
"SELECT i.indisvalid, pg_get_indexdef(i.indexrelid) FROM pg_index i "
"JOIN pg_class c ON c.oid = i.indexrelid WHERE c.relname = :name"
),
{"name": name},
).first()
return None if row is None else (bool(row[0]), row[1])
def _leave_invalid(name: str) -> None:
"""What a build that failed part-way leaves behind: the name taken, the index INVALID."""
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
conn.execute(text(f"CREATE INDEX {name} ON videos (id)"))
conn.execute(
text(f"UPDATE pg_index SET indisvalid = false WHERE indexrelid = '{name}'::regclass")
)
def test_a_rerun_rebuilds_an_invalid_leftover_before_dropping_the_old_index(db):
db.rollback() # CONCURRENTLY waits for every open transaction, this session's too
cfg = Config("alembic.ini")
command.downgrade(cfg, "0094_plex_genres_hu_everywhere")
try:
_leave_invalid("ix_videos_channel_pub_cover")
_leave_invalid("ix_videos_views_desc")
finally:
command.upgrade(cfg, "head")
cover = _index("ix_videos_channel_pub_cover")
assert cover is not None and cover[0], cover
assert "INCLUDE (duration_seconds" in cover[1]
assert _index("ix_videos_channel_pub_desc") is None
views = _index("ix_videos_views_desc")
assert views is not None and views[0] and "view_count DESC NULLS LAST" in views[1], views
@@ -214,3 +214,28 @@ def test_hidden_movie_absent_from_grid_and_facets(db, as_user):
facets = client.get("/api/plex/facets?scope=movie&show=all")
crs = {c["value"]: c["count"] for c in facets.json()["content_ratings"]}
assert crs.get("R") == 1, crs
def test_a_watch_state_scope_rolls_the_episodes_up_once(db, as_user):
"""The per-user episode rollup behind a show's watch state is a pass over every episode; each show
facet group asked it again (E31 S4e: half of a filtered facets call). It is asked once now."""
from sqlalchemy import event
from app.db import engine
seed = _seed(db)
client = as_user(seed["user"])
rollups: list[str] = []
def count(conn, cursor, statement, *_):
if "GROUP BY plex_items.show_id" in statement:
rollups.append(statement)
event.listen(engine, "before_cursor_execute", count)
try:
for show in ("watched", "in_progress", "unwatched"):
rollups.clear()
assert client.get(f"/api/plex/facets?scope=both&show={show}").status_code == 200
assert len(rollups) == 1, show
finally:
event.remove(engine, "before_cursor_execute", count)
+77
View File
@@ -0,0 +1,77 @@
"""A card asks the Plex image proxy for a small cut; a detail page keeps the full one (E31 S4e).
Cards fetched the 400×600 poster for a ~144px picture and the 1280×720 backdrop for a ~212px shelf
picture. The `_sm` variants are cut by Plex at a card's size and cached apart from the full ones; an
unknown variant still gets the full poster. DB lane (the proxy opens its own short session).
"""
from fastapi.testclient import TestClient
import app.state as app_state
from app.main import app
from app.models import PlexItem, PlexLibrary
from app.plex import imgcache
from app.routes.plex import playback
class _FakePlex:
calls: list[tuple[str, int | None, int | None]] = []
def __init__(self, db) -> None:
pass
def __enter__(self):
return self
def __exit__(self, *exc) -> None:
return None
def image_bytes(self, key, width=None, height=None):
_FakePlex.calls.append((key, width, height))
return b"\xff\xd8jpeg", "image/jpeg"
def _setup(db, make_user, monkeypatch, tmp_path) -> TestClient:
user = make_user()
lib = PlexLibrary(plex_key="90", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(
PlexItem(
rating_key="pm1",
library_id=lib.id,
kind="movie",
title="A Movie",
thumb_key="/library/metadata/pm1/thumb/1",
art_key="/library/metadata/pm1/art/1",
)
)
db.commit()
monkeypatch.setattr(app_state, "_configured_cache", True)
monkeypatch.setattr(playback, "resolved_user_id", lambda request: (user.id, True))
monkeypatch.setattr(playback, "PlexClient", _FakePlex)
monkeypatch.setattr(imgcache, "CACHE_DIR", tmp_path)
_FakePlex.calls = []
return TestClient(app)
def test_each_variant_is_cut_at_its_own_size(db, make_user, monkeypatch, tmp_path):
client = _setup(db, make_user, monkeypatch, tmp_path)
for variant in ("thumb", "thumb_sm", "art", "art_sm"):
assert client.get(f"/api/plex/image/pm1?variant={variant}").status_code == 200
assert _FakePlex.calls == [
("/library/metadata/pm1/thumb/1", 400, 600),
("/library/metadata/pm1/thumb/1", 240, 360),
("/library/metadata/pm1/art/1", 1280, 720),
("/library/metadata/pm1/art/1", 480, 270),
]
# Each cut is cached under its own name: asking again reaches Plex no more.
for variant in ("thumb", "thumb_sm", "art", "art_sm"):
client.get(f"/api/plex/image/pm1?variant={variant}")
assert len(_FakePlex.calls) == 4
def test_an_unknown_variant_gets_the_full_poster(db, make_user, monkeypatch, tmp_path):
client = _setup(db, make_user, monkeypatch, tmp_path)
assert client.get("/api/plex/image/pm1?variant=huge").status_code == 200
assert _FakePlex.calls == [("/library/metadata/pm1/thumb/1", 400, 600)]
+28
View File
@@ -86,3 +86,31 @@ def test_a_job_runs_audit_row_carries_its_result_as_data(db):
{"status": "skipped", "summary": "sync paused", "result": {"skipped": "sync paused"}},
{"status": "error", "summary": "boom"},
]
def test_a_failure_the_job_names_rides_along_as_data(db, make_user):
"""E31: a failed RSS poll reached the Hungarian audit log as the server's English sentence. A
failure the job names (`progress.JobFailed`) is stored as data beside that sentence — on the
dashboard, the manual-run notice and the audit row — so the client says it in the reader's
language. Any other failure keeps only its own text (the test above)."""
from app import progress
from app.models import AuditLog
def outage(_db):
raise progress.JobFailed("rss_all_failed", "RSS poll failed", total=2, cause="transport")
admin = make_user("failure@example.com", role="admin")
token = scheduler._manual_actor.set(admin.id)
try:
scheduler._job("rss_poll", outage)
finally:
scheduler._manual_actor.reset(token)
named = {"failure": "rss_all_failed", "total": 2, "cause": "transport"}
rss = _snapshot_job("rss_poll")
assert rss["last_error"] == "RSS poll failed"
assert rss["result"] == named
notice = db.execute(select(Notification).where(Notification.user_id == admin.id)).scalar_one()
assert notice.data["result"] == named
row = db.execute(select(AuditLog).order_by(AuditLog.id.desc())).scalars().first()
assert row is not None and row.after["result"] == named
+9 -1
View File
@@ -6,6 +6,7 @@ import logging
import httpx
import pytest
from app import progress
from app.sync import runner
from app.sync.playlists import FAILED, _Breaker
from app.sync.subscriptions import should_prune
@@ -224,8 +225,15 @@ class TestRssPollResult:
def dead(cid):
raise rss.RssError(f"RSS fetch for {cid} returned HTTP 404", "http_404")
with pytest.raises(RuntimeError, match="HTTP 404"):
with pytest.raises(RuntimeError, match="HTTP 404") as err:
self._run(monkeypatch, fetch=dead, apply_=lambda db, ch, e: 1, channels=("UC_dead",))
# E31: and as data, for the reader's language — whose feed, and the reason code that tells a
# dead feed (`http_404`) from a dead egress (`transport`).
assert isinstance(err.value, progress.JobFailed)
assert (err.value.reason, err.value.params) == (
"rss_all_failed",
{"total": 1, "channel": "UC_dead", "cause": "http_404", "read": 0},
)
def test_a_read_only_database_is_a_total_outage_too(self, monkeypatch):
# Every feed reads fine, nothing can be stored: just as un-polled, just as red.
@@ -0,0 +1,39 @@
"""The admin Configuration page reads every override in one query (E31 S4d).
`sysconfig.describe()` did a `db.get` per key to tell whether an override was set, and `get()` did one
more for its value: ~55 keys, and two round trips for a key with no row, since the session cannot cache
an absence. That was ~40 ms of the page's ~55 on prod. It now reads the table once. DB lane.
"""
from sqlalchemy import event
from app import sysconfig
def _statements(db, fn) -> int:
seen: list[str] = []
engine = db.get_bind()
listener = lambda *a, **k: seen.append(a[2]) # noqa: E731 — (conn, cursor, statement, …)
event.listen(engine, "before_cursor_execute", listener)
try:
fn()
finally:
event.remove(engine, "before_cursor_execute", listener)
return len(seen)
def test_describe_reads_the_overrides_once(db):
sysconfig.set_value(db, "quota_daily_budget", 5000)
db.commit()
assert _statements(db, lambda: sysconfig.describe(db)) == 1
def test_describe_shows_the_stored_value_and_the_default(db):
sysconfig.set_value(db, "quota_daily_budget", 5000)
db.commit()
items = {i["key"]: i for g in sysconfig.describe(db)["groups"].values() for i in g}
assert items["quota_daily_budget"]["is_set"] is True
assert items["quota_daily_budget"]["value"] == 5000
other = next(i for k, i in items.items() if k != "quota_daily_budget" and not i["secret"])
assert other["is_set"] is False
assert other["value"] == other["default"]
+28 -7
View File
@@ -1,8 +1,10 @@
"""The sysconfig registry: its order is the admin page's tab order, and a closed-set knob is a CHOICE.
"""The sysconfig registry: a group's keys stay together, every group has its section, and a closed-set
knob is a CHOICE.
The admin Configuration page used to keep its own hand-written group order, which had already drifted
(`explore` was missing, so its tab sorted last). The page now follows SPECS, so SPECS has to read in
tab order: one run of keys per group.
(`explore` was missing, so its tab sorted last). The page now follows the registry: SECTIONS orders the
groups under their headings (E31), and a group missing there would fall off the page — so each group
of SPECS sits in exactly one section.
A closed-set admin knob is a CHOICE, not free text (E22 S2 follow-up).
@@ -22,7 +24,7 @@ from app.config import Settings, choices_of
CHOICE_KEYS = {s.key for s in sysconfig.SPECS if s.type == "choice"}
def test_each_group_is_one_run_of_keys_so_the_registry_reads_in_tab_order():
def test_each_group_is_one_run_of_keys():
runs = [
s.group
for i, s in enumerate(sysconfig.SPECS)
@@ -32,6 +34,13 @@ def test_each_group_is_one_run_of_keys_so_the_registry_reads_in_tab_order():
assert runs[:2] == ["access", "email"]
def test_every_group_sits_in_exactly_one_section():
listed = [g for _, members in sysconfig.SECTIONS for g in members]
assert len(listed) == len(set(listed)), listed
assert set(listed) == {s.group for s in sysconfig.SPECS}
assert [sid for sid, _ in sysconfig.SECTIONS] == ["access", "youtube", "media", "system"]
def test_every_closed_set_knob_is_a_choice_whose_options_come_from_config():
assert CHOICE_KEYS == {
"search_source",
@@ -58,19 +67,23 @@ def test_the_env_accepts_any_letter_case_and_rejects_an_unknown_value():
class _Row:
def __init__(self, value):
self.value, self.encrypted = value, False
def __init__(self, key, value):
self.key, self.value, self.encrypted = key, value, False
class _FakeDb:
"""Just enough of a Session for sysconfig's reads: one stored override row per key."""
def __init__(self, **rows):
self.rows = {k: _Row(v) for k, v in rows.items()}
self.rows = {k: _Row(k, v) for k, v in rows.items()}
def get(self, _model, key):
return self.rows.get(key)
def scalars(self, _stmt):
# describe() reads every override at once (E31 S4d).
return list(self.rows.values())
def test_a_stored_value_reads_back_canonical_and_a_stale_one_falls_back_to_the_default():
assert sysconfig.get_str(_FakeDb(plex_transcode_hw="OFF"), "plex_transcode_hw") == "off"
@@ -85,6 +98,14 @@ def test_describe_hands_the_picker_its_options():
assert items["quota_daily_budget"]["choices"] == []
def test_describe_lists_every_group_under_its_section_in_order():
d = sysconfig.describe(_FakeDb())
assert [s["id"] for s in d["sections"]] == ["access", "youtube", "media", "system"]
assert d["sections"][0]["groups"] == ["access", "google", "email"]
listed = [g for s in d["sections"] for g in s["groups"]]
assert sorted(listed) == sorted(d["groups"])
def test_set_value_stores_the_canonical_spelling_and_refuses_anything_else(db):
sysconfig.set_value(db, "search_source", "API")
assert sysconfig.get_str(db, "search_source") == "api"
+18 -2
View File
@@ -1,8 +1,8 @@
"""iso(): the single date/datetime→JSON serializer the routes share (R6 S1a)."""
"""iso(): the single date/datetime→JSON serializer the routes share (R6 S1a); to_tsquery_str()."""
from datetime import date, datetime, timezone
from app.utils import iso, normalize_description
from app.utils import iso, normalize_description, to_tsquery_str
def test_none_maps_to_none():
@@ -56,3 +56,19 @@ def test_normalize_description_single_line_unchanged():
def test_normalize_description_folds_crlf_line_endings():
# splitlines() folds CR/CRLF to \n — a documented, benign line-ending normalization.
assert normalize_description("a\r\nb\r\n\r\nc") == "a\nb\nc"
def test_tsquery_prefix_matches_a_last_word_of_three_or_more():
assert to_tsquery_str("ti amo magyar") == "ti & amo & magyar:*"
assert to_tsquery_str("min") == "min:*"
def test_tsquery_matches_a_short_last_word_whole():
# A 1-2 letter prefix made ts_rank score most of the library (5-8 s on prod, E31 S4e).
assert to_tsquery_str("m") == "m"
assert to_tsquery_str("minecraft 4k") == "minecraft & 4k"
assert to_tsquery_str("ti amo m") == "ti & amo & m"
def test_tsquery_without_a_word_is_none():
assert to_tsquery_str("?! --") is None
+18 -4
View File
@@ -39,8 +39,8 @@ runs this check before it is called done (E28 S3g):
4. **Seen.** A screenshot of every changed state, thin edges at 1:1; the tablet widths (1024 × 768, a
narrow split screen) when the layout can reflow. Phones are the E29 epic's, not this check's.
5. **Judged as a whole** (E30 S6). For each screenshot, one written line on its composition: where the empty
space is, what hugs one side, what the eye lands on first. Then the screen at 200% browser zoom and at
the largest text size. Both are held to [Composition and the reader](#composition-and-the-reader--decided-e30-s6).
space is, what hugs one side, what the eye lands on first. Then the screen at 125% and 133% browser
zoom and at the largest text size. All are held to [Composition and the reader](#composition-and-the-reader--decided-e30-s6).
A rule that holds is not the same as a screen that works: the person page passed every rule above with
half its panel empty.
@@ -465,8 +465,9 @@ of a full-width panel and nothing on the right.
(TMDB, YouTube) is fetched ahead of time by a job, not on the click, so a page never waits on it.
Late content keeps its place (B8). [Nielsen #1, RAIL]
- **Zoom and text size are first-class.** The owner reads with the browser zoomed and a large text size.
Every screen holds at 200% browser zoom on a desktop window and at the largest Settings text size at
1024 wide: nothing clipped or overlapping, no sideways page scroll, every control reachable and at
Every screen holds at 125% and 133% browser zoom on a 1080p desktop window (what the owner uses: the
layout of a 1536 × 729 and a 1444 × 685 viewport) and at the largest Settings text size at 1024 wide:
nothing clipped or overlapping, no sideways page scroll, every control reachable and at
least its rung, text wrapping rather than cut. Measured, not assumed, on every UI change. [WCAG 1.4.4,
1.4.10, 1.4.12]
@@ -688,3 +689,16 @@ of a full-width panel and nothing on the right.
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
+15 -9
View File
@@ -106,28 +106,34 @@ test.describe("channel view", () => {
await expectContentType(page, "includeLive", false);
});
// Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays
// put while only the video grid scrolls.
test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => {
// Platform S4b + E31 S4c: the channel chrome sits outside the scroller. Scrolling into the videos
// folds the banner and identity once (the name moves into the tab row); from then on the folded
// chrome stays put while only the video grid scrolls.
test("the channel chrome folds once, then stays fixed while the grid scrolls", async ({
page,
}) => {
await openApp(page);
await page.getByTestId("video-card-channel").first().click();
const count = page.getByTestId("item-count");
const title = page.getByTestId("channel-title");
await expect(count).toBeVisible();
await expect(page.getByTestId("video-card").first()).toBeVisible();
const titleTop = await title.evaluate((el) => el.getBoundingClientRect().top);
const countTop = await count.evaluate((el) => el.getBoundingClientRect().top);
await expect(page.getByTestId("channel-folded")).toHaveCount(0);
// Keep nudging until the grid is tall enough to actually scroll (it may still be filling in).
await expect
.poll(async () => {
await page.getByRole("main").evaluate((el) => el.scrollBy(0, 1500));
await page.getByRole("main").evaluate((el) => el.scrollBy(0, 600));
return page.getByRole("main").evaluate((el) => el.scrollTop);
})
.toBeGreaterThan(400);
const folded = page.getByTestId("channel-folded");
await expect(folded).toBeVisible();
await expect(folded.getByTestId("channel-title")).toBeVisible();
const titleTop = await folded.evaluate((el) => el.getBoundingClientRect().top);
const countTop = await count.evaluate((el) => el.getBoundingClientRect().top);
// The chrome didn't move — banner title and toolbar are in exactly the same place.
expect(await title.evaluate((el) => el.getBoundingClientRect().top)).toBe(titleTop);
await page.getByRole("main").evaluate((el) => el.scrollBy(0, 400));
expect(await folded.evaluate((el) => el.getBoundingClientRect().top)).toBe(titleTop);
expect(await count.evaluate((el) => el.getBoundingClientRect().top)).toBe(countTop);
});
});
+9
View File
@@ -547,6 +547,15 @@ export default tseslint.config(
// lib/*.contract.test.ts).
"no-restricted-syntax": [
"error",
// A next-page fetch called bare CANCELS the one in flight and sends the same cursor again;
// our requests ignore the abort, so the server ran both (E31 S4e: every feed page 2–3×).
// lib/nextPage.ts NEXT_PAGE makes the second call join the first.
{
selector:
"CallExpression[arguments.length=0]:matches([callee.name='fetchNextPage'], [callee.property.name='fetchNextPage'])",
message:
"fetchNextPage() cancels and re-sends a page already loading: call fetchNextPage(NEXT_PAGE) (lib/nextPage.ts).",
},
// `enabled:` sees only the native attribute; a FocusSafeButton that holds the focus is
// disabled by aria-disabled, so a bare `enabled:hover:` lit it up on hover as if it worked.
{
+2 -2
View File
@@ -16,7 +16,7 @@ import {
type PanelLayout,
} from "./lib/panelLayout";
import { notify, removeByMetaKind } from "./lib/notifications";
import { LS, setAccountRaw } from "./lib/storage";
import { LS, setTabRaw } from "./lib/storage";
import { usePref } from "./lib/usePref";
import { useVerifyEntry } from "./lib/useVerifyEntry";
import { useNavigation, useNavigationActions } from "./components/navigationContext";
@@ -249,7 +249,7 @@ export default function App() {
if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") });
void refetchMe();
setAccountRaw(LS.settingsTab, "account");
setTabRaw(LS.settingsTab, "account");
setPage("settings");
} else {
const key = result === "conflict" || result === "mismatch" ? result : "error";
+4 -2
View File
@@ -13,6 +13,7 @@ import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api
import { copyText } from "../lib/clipboard";
import { formatDateTimeSec } from "../lib/format";
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
import { nextPageOnClick } from "../lib/nextPage";
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
import {
DEFAULT_LOGS_STATE,
@@ -156,7 +157,8 @@ export default function AdminLogs() {
? "countsFailed"
: null;
const retryFailed = () => {
if (logs.isFetchNextPageError) void logs.fetchNextPage();
if (logs.isFetchNextPageError)
void logs.fetchNextPage(nextPageOnClick(logs.isFetchingNextPage));
else if (logs.isError) void logs.refetch();
if (facets.isError) void facets.refetch();
};
@@ -190,7 +192,7 @@ export default function AdminLogs() {
const loadOlder = () => {
setLive(false);
void logs.fetchNextPage();
void logs.fetchNextPage(nextPageOnClick(logs.isFetchingNextPage));
};
const copy = async () => {
@@ -116,13 +116,63 @@ describe("a user row", () => {
});
});
/** The open menu's rows (a closed popover's are hidden from the accessibility tree in jsdom). */
const menuRows = () => screen.queryAllByRole("menuitem", { hidden: true });
/** The row's actions live in its "…" menu (E31): open it, then pick the row. */
async function chooseInRowMenu(label: string) {
fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" }));
const item = await waitFor(() => {
const found = menuRows().find((m) => m.textContent?.startsWith(label));
if (!found) throw new Error(`no menu row "${label}"`);
return found;
});
fireEvent.click(item);
}
// E31 (DESIGN § Action placement): the role, the suspension and the delete moved off the row into
// ONE menu — the row's "…", which a right-click on the row opens too. A locked one stays listed with
// its reason.
describe("a user row's menu", () => {
it("holds the role, the suspension and the delete; the row shows no action button", async () => {
mount();
const open = await screen.findByTitle("users.account.open");
const card = open.parentElement!;
const buttons = [...card.querySelectorAll("button")].filter((b) => b !== open);
expect(buttons.map((b) => b.getAttribute("aria-label"))).toEqual(["users.rowMenu"]);
fireEvent.contextMenu(card, { clientX: 5, clientY: 5 });
await waitFor(() => expect(menuRows().length).toBe(3));
expect(menuRows().map((i) => i.textContent)).toEqual([
"users.roles.makeAdmin",
"users.suspend.action",
"users.delete.action",
]);
});
it("lists your own row's actions as locked, with the reason", async () => {
vi.spyOn(api, "adminUsers").mockResolvedValue([{ ...row, id: 1, email: "admin@x" }] as never);
render(
<QueryClientProvider client={new QueryClient()}>
<AdminUsers />
</QueryClientProvider>,
);
fireEvent.click(await screen.findByRole("button", { name: "users.rowMenu" }));
await waitFor(() => expect(menuRows().length).toBe(3));
const [role, suspend, del] = menuRows();
expect(role!.hasAttribute("disabled")).toBe(true);
expect(role!.textContent).toBe("users.roles.makeAdminusers.roles.selfLocked");
expect(suspend!.textContent).toBe("users.suspend.actionusers.suspend.selfLocked");
expect(del!.textContent).toBe("users.delete.actionusers.delete.locked");
});
});
describe("a user action that fails", () => {
it("says a user who is gone, and refetches the list", async () => {
const users = mount();
vi.spyOn(globalThis, "fetch").mockResolvedValue(
answer(404, { code: "not_found", reason: "user" }),
);
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
await chooseInRowMenu("users.roles.makeAdmin");
await waitFor(() =>
expect(notify).toHaveBeenCalledWith({
level: "error",
@@ -136,7 +186,7 @@ describe("a user action that fails", () => {
it("is not toasted on top of the error dialog the request already opened", async () => {
mount();
vi.spyOn(globalThis, "fetch").mockResolvedValue(answer(409, "Cannot demote the last admin"));
fireEvent.click(await screen.findByText("users.roles.makeAdmin"));
await chooseInRowMenu("users.roles.makeAdmin");
const { reportError } = await import("../lib/errorDialog");
await waitFor(() => expect(reportError).toHaveBeenCalled());
expect(notify.mock.calls.filter(([n]) => n.level === "error")).toEqual([]);
+65 -79
View File
@@ -41,11 +41,15 @@ import AccountPanel from "./AccountPanel";
import { useConfirm } from "./confirmContext";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { useAccountPersistedState } from "../lib/storage";
import { useTabPersistedState } from "../lib/storage";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
import { Pressable } from "./ui/Pressable";
import { useContextMenu } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import type { ActionMenuItem } from "./ui/ActionMenu";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
@@ -65,7 +69,7 @@ const ROW_OPEN =
export default function AdminUsers() {
const me = useMe();
const { t } = useTranslation();
const [tab, setTab] = useAccountPersistedState(ADMIN_USERS_TAB_KEY, "roles");
const [tab, setTab] = useTabPersistedState(ADMIN_USERS_TAB_KEY, "roles");
const panel = useTabPanel();
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
@@ -206,10 +210,58 @@ function UsersRoles({ me }: { me: Me }) {
if (ok) del.mutate({ id: u.id, email: u.email });
};
// The row's actions are ONE menu (DESIGN § Action placement, E31): the role, the suspension and
// the delete — rare and two of them destructive — open from the row's "…", a right-click on the row,
// a long-press or Shift+F10. The row keeps what the admin reads: the name, the role and state
// badges, the last sign-in. A locked action (the demo, yourself) stays listed with its reason.
const rowMenu = useContextMenu<AdminUserRow>({ keyOf: (u) => u.id });
const menuItems = (u: AdminUserRow): ActionMenuItem[] => {
const self = u.id === me.id;
const pending = pendingIds.has(u.id);
const locked = (demoNote: string, selfNote: string) =>
u.is_demo ? demoNote : self ? selfNote : undefined;
const roleNote = locked(t("users.roles.demoLocked"), t("users.roles.selfLocked"));
const suspendNote = locked(t("users.suspend.demoLocked"), t("users.suspend.selfLocked"));
const deleteNote = u.is_demo || self ? t("users.delete.locked") : undefined;
return [
{
id: "role",
label: u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin"),
icon: <Shield />,
disabled: pending || !!roleNote,
note: roleNote,
onSelect: () => onToggle(u),
},
{
id: "suspend",
label: u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action"),
icon: u.is_suspended ? <RotateCcw /> : <Ban />,
disabled: pending || !!suspendNote,
note: suspendNote,
onSelect: () => onSuspend(u),
},
{
id: "delete",
label: t("users.delete.action"),
icon: <Trash2 />,
danger: true,
separatorBefore: true,
disabled: pending || !!deleteNote,
note: deleteNote,
onSelect: () => onDelete(u),
},
];
};
const rows = q.data ?? [];
return (
<Section card title={t("users.roles.title")}>
<ContextMenu
menu={rowMenu}
ariaLabel={t("users.rowMenu")}
items={rowMenu.target ? menuItems(rowMenu.target) : []}
/>
{panelFor && <AccountPanel user={panelFor} onClose={() => setPanelFor(null)} />}
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{q.isError && !q.data ? (
@@ -232,6 +284,7 @@ function UsersRoles({ me }: { me: Me }) {
// stretched layer. The hover used to be the name button's own and ended at the first badge.
<div
key={u.id}
{...rowMenu.triggerProps(u)}
className={`${SURFACE.card} ${ROW_LAYER} flex items-center gap-2.5 p-2.5`}
>
<Pressable
@@ -256,83 +309,9 @@ function UsersRoles({ me }: { me: Me }) {
{u.is_active && !u.email_verified && !u.has_google && (
<Badge tone="warn">{t("users.roles.unverified")}</Badge>
)}
<Tooltip
always={u.is_demo || self}
hint={
u.is_demo
? t("users.roles.demoLocked")
: self
? t("users.roles.selfLocked")
: u.role === "admin"
? t("users.roles.makeUser")
: t("users.roles.makeAdmin")
}
>
<Button
variant="glass"
size="xs"
className="relative shrink-0"
onClick={() => onToggle(u)}
// Locked (demo / self) is unavailable WITH a reason, the tooltip's: aria-disabled keeps it
// focusable so a keyboard user reaches the reason. A pending write is transient.
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
icon={<Shield className="w-3.5 h-3.5" />}
>
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
</Button>
</Tooltip>
<Tooltip
always={u.is_demo || self}
hint={
u.is_demo
? t("users.suspend.demoLocked")
: self
? t("users.suspend.selfLocked")
: u.is_suspended
? t("users.suspend.undoAction")
: t("users.suspend.action")
}
>
<IconButton
noTitle
label={
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
}
size="sm"
variant={u.is_suspended ? "outline-warn" : "outline"}
className="relative"
onClick={() => onSuspend(u)}
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
>
{u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)}
</IconButton>
</Tooltip>
<Tooltip
always={u.is_demo || self}
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
>
<IconButton
noTitle
label={t("users.delete.action")}
size="sm"
variant="outline-danger"
className="relative"
onClick={() => onDelete(u)}
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
{/* How long ago this account last signed in. LAST in the row on purpose: the badges and
the role button before it vary in width row to row, so only the row's own right
edge lines the values up into one column. The exact moment is the hint here and a
{/* How long ago this account last signed in. At the row's right end on purpose (only the
fixed-width "…" after it): the badges before it vary in width row to row, so only
the right edge lines the values up into one column. The exact moment is the hint here and a
line in the account panel; below `sm` the row has no room, and the panel still says it. */}
<Tooltip
hint={
@@ -347,6 +326,13 @@ function UsersRoles({ me }: { me: Me }) {
{u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"}
</span>
</Tooltip>
<MoreButton
label={t("users.rowMenu")}
size="md"
expanded={rowMenu.open && rowMenu.target?.id === u.id}
menuProps={rowMenu.buttonProps(u)}
className="relative"
/>
</div>
);
})}
@@ -0,0 +1,48 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { ChevronRight } from "lucide-react";
import type { AuditRow } from "../lib/api";
import { Button } from "./ui/Button";
import { RelativeTime } from "./ui/RelativeTime";
/** A folded run of one job's successful runs (lib/auditGroups, E31): the row shows the newest, this
* opens the earlier ones, each with its moment and its result (`detailOf`, the row's own wording). */
export default function AuditEarlierRuns({
runs,
detailOf,
}: {
runs: AuditRow[];
detailOf: (run: AuditRow) => string | null;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const earlier = runs.slice(1);
return (
<div className="mt-1">
<Button
variant="link-muted"
aria-expanded={open}
onClick={() => setOpen(!open)}
icon={<ChevronRight className={clsx("transition-transform", open && "rotate-90")} />}
>
{t("audit.earlierRuns", { count: earlier.length })}
</Button>
{open && (
<ul className="mt-1 space-y-0.5 text-2xs text-muted">
{earlier.map((run) => (
<li key={run.id} className="flex gap-2">
<RelativeTime
at={run.created_at}
seconds
empty="—"
className="shrink-0 tabular-nums"
/>
<span className="min-w-0 wrap-break-word">{detailOf(run)}</span>
</li>
))}
</ul>
)}
</div>
);
}
+3 -1
View File
@@ -14,6 +14,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import { auditColumns } from "./auditColumns";
import { notifyActionError } from "../lib/actionErrors";
import { compareText } from "../lib/collate";
import { foldJobRuns } from "../lib/auditGroups";
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
@@ -25,7 +26,8 @@ export default function AuditLog() {
const confirm = useConfirm();
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
const data = q.data;
const rows = useMemo(() => data?.items ?? [], [data]);
// Back-to-back successful runs of one job fold into one row (lib/auditGroups, E31).
const rows = useMemo(() => foldJobRuns(data?.items ?? []), [data]);
const actionOptions = useMemo(() => {
// By what the reader sees — the named action, in their alphabet — not by the code.
@@ -242,6 +242,7 @@ export default function ChannelDiscovery({
onSortChange={setSort}
emptyText={t("channels.discovery.empty")}
keyboardNav
narrowedBy={q}
/>
) : (
<ChannelLayoutGrid
@@ -0,0 +1,132 @@
// @vitest-environment jsdom
//
// E31 (the owner's pick): the channel page's chrome is fixed, and its banner + identity block kept ~40% of a
// 133% screen before the first video for as long as the page was open. Once the grid scrolls they fold
// away and the name rides in the tab row; back at the top they return. The Back button is one element in
// both states, so a focused Back keeps its focus through the fold.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, screen, act, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("./ChannelAbout", () => ({ default: () => null }));
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
import ChannelPage from "./ChannelPage";
import { CONTENT_ID } from "./ui/landmarks";
import { api, type ChannelDetail, type Me } from "../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
document.getElementById(CONTENT_ID)?.remove();
vi.restoreAllMocks();
});
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
const BANNER = 'img[src^="https://yt3."]';
// The page's one scroller (PageScroller's <main>), with a scroll position jsdom does not lay out.
function scroller() {
const main = document.createElement("main");
main.id = CONTENT_ID;
let top = 0;
Object.defineProperty(main, "scrollTop", { get: () => top, configurable: true });
Object.defineProperty(main, "scrollHeight", { value: 6000, configurable: true });
Object.defineProperty(main, "clientHeight", { value: 600, configurable: true });
document.body.appendChild(main);
return (to: number) =>
act(() => {
top = to;
main.dispatchEvent(new Event("scroll"));
});
}
// Renders the page and waits for the detail to land (the banner shows).
async function renderPage() {
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "StarTalk",
banner_url: "https://yt3.googleusercontent.com/banner",
subscribed: true,
blocked: false,
hidden: false,
deep_requested: false,
backfill_done: true,
stored_videos: 10,
tag_ids: [],
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const view = render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ChannelPage
channelId={CH}
initialName="StarTalk"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
await waitFor(() => expect(view.container.querySelector(BANNER)).not.toBeNull());
return view;
}
describe("ChannelPage — the header folds while the grid scrolls", () => {
it("drops the banner and puts the name in the tab row once scrolled, and brings them back at the top", async () => {
const scrollTo = scroller();
const { container } = await renderPage();
const banner = () => container.querySelector(BANNER);
expect(banner()).not.toBeNull();
expect(screen.queryByTestId("channel-folded")).toBeNull();
scrollTo(400);
expect(banner()).toBeNull();
expect(screen.getByTestId("channel-folded").textContent).toContain("StarTalk");
scrollTo(10);
expect(screen.queryByTestId("channel-folded")).not.toBeNull();
scrollTo(0);
expect(banner()).not.toBeNull();
expect(screen.queryByTestId("channel-folded")).toBeNull();
});
it("keeps a focused Back focused through the fold", async () => {
const scrollTo = scroller();
await renderPage();
const back = screen.getByTestId("channel-back");
back.focus();
scrollTo(400);
expect(screen.getByTestId("channel-folded")).toBeTruthy();
expect(document.activeElement).toBe(back);
});
});
+276 -216
View File
@@ -24,11 +24,15 @@ import {
import Avatar from "./ui/Avatar";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { useScrollFold } from "./ui/scrollFold";
import Feed from "./Feed";
import ChannelAboutContent from "./ChannelAbout";
import { PageToolbar } from "./ui/PageShell";
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
import { useFeedFilters } from "./feedFiltersContext";
import { SHOW_IDS } from "./feedFiltersInit";
import { usePref } from "../lib/usePref";
import { LS } from "../lib/storage";
import { useConfirm } from "./confirmContext";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -86,6 +90,10 @@ export default function ChannelPage({
// where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so
// the toolbar lands under the tabs inside the fixed band rather than in the scroller.
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null);
// The banner and the identity block fold away once the grid scrolls (E31, the owner's pick): the chrome
// is fixed, so unfolded it kept ~40% of a 133% screen for good. Folded, the name rides in the tab row.
const foldable = useRef<HTMLDivElement>(null);
const folded = useScrollFold(foldable);
// The banner comes from Google's image CDN, which refuses any request carrying a Referer from our
// origin — every time, not only in a burst (measured 2026-09-26) — so the <img> sends none
// (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss,
@@ -260,7 +268,13 @@ export default function ChannelPage({
// Channel-scoped feed: the whole catalog filtered to this channel (scope=all + source=all so
// the explored, per-user-private videos show — the backend gates them to this explorer).
const [filters, setFilters] = useState<FeedFilters>(() => ({
// The watch state is the one filter a channel page remembers (the owner, E31): a fresh page shows
// what is still unwatched, and a change sticks — across channels, reloads and devices.
const [show, setShow] = usePref<string>("channelShow", "unwatched", {
localKey: LS.channelShow,
parse: (v) => (typeof v === "string" && SHOW_IDS.includes(v) ? v : "unwatched"),
});
const [ownFilters, setOwnFilters] = useState<FeedFilters>(() => ({
tags: [],
tagModes: DEFAULT_TAG_MODES,
q: "",
@@ -271,9 +285,14 @@ export default function ChannelPage({
includeNormal: globalFilters.includeNormal,
includeShorts: globalFilters.includeShorts,
includeLive: globalFilters.includeLive,
show: "all",
show: "unwatched",
channelIds: [channelId],
}));
const filters: FeedFilters = { ...ownFilters, show };
const setFilters = (next: FeedFilters) => {
if (next.show !== show) setShow(next.show);
setOwnFilters(next);
};
const name = ch?.title ?? initialName ?? channelId;
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
@@ -306,251 +325,292 @@ export default function ChannelPage({
: null,
joined ? t("channel.joined", { date: joined }) : null,
].filter(Boolean) as string[];
// The page's name and its state, shown in the identity block or, folded, in the tab row.
const heading = (
<PageHeading
data-testid="channel-title"
className={`${folded ? "text-base" : "text-xl"} font-semibold truncate`}
>
{name}
</PageHeading>
);
const stateBadge = ch?.blocked ? (
<span className="shrink-0 text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
{t("channel.blockedBadge")}
</span>
) : (
ch?.explored &&
!ch?.subscribed && (
<span className="shrink-0 text-xs px-2 py-0.5 rounded-full bg-warn/15 text-warn">
{t("channel.exploringBadge")}
</span>
)
);
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
// banner/identity/tabs/toolbar stay put and only the video grid scrolls.
// tabs/toolbar stay put and only the video grid scrolls; the banner/identity fold away meanwhile.
const bannerSrc = ch?.banner_url
? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`
: null;
return (
<>
<PageToolbar>
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3">
{bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? (
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
// low default size, so request a crisp wide version (=w1707) and object-cover the
// desktop "safe area" — the centre 2560×423 (~6:1) band.
<div
className="w-full overflow-hidden rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
>
<img
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
// browser's negative cache (googleusercontent accepts an extra query param).
key={bannerAttempt}
src={bannerSrc}
referrerPolicy="no-referrer"
alt=""
className={`w-full h-full object-cover object-center ${
bannerLoaded === bannerSrc ? "" : "opacity-0"
}`}
onLoad={() => setBannerLoaded(bannerSrc)}
onError={() => {
if (bannerAttempt <= MAX_BANNER_RETRIES) {
window.setTimeout(
() => setBannerAttempt((a) => a + 1),
500 * (bannerAttempt + 1),
);
}
}}
/>
</div>
) : (
// No banner (or a dead one): a neutral bar the SAME height as a real banner, so the
// overlapping avatar + the Back button have the same room and don't collide (a short bar
// let the -mt-8 avatar ride up into the Back button).
<div
className="w-full rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
/>
)}
<Button
variant="media"
data-testid="channel-back"
icon={<ArrowLeft />}
onClick={closeChannel}
className="absolute top-5 left-7 sm:left-9 z-base backdrop-blur-xs"
>
{t("channel.back")}
</Button>
</div>
<div className="px-4 sm:px-6">
{/* Identity + actions. relative+z-base so the avatar that overlaps up into the banner
paints ABOVE it (the banner's positioned container would otherwise cover it). */}
<div className="relative z-base flex items-end gap-4 -mt-8">
<Avatar
src={ch?.thumbnail_url ?? null}
fallback={name}
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
/>
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
<PageHeading data-testid="channel-title" className="text-xl font-semibold truncate">
{name}
</PageHeading>
{ch?.blocked ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
{t("channel.blockedBadge")}
</span>
<div className="relative">
{!folded && (
<div ref={foldable}>
{/* Banner — OPTION C: inset, rounded card (not full-bleed) */}
<div className="px-4 sm:px-6 pt-3">
{bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? (
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
// low default size, so request a crisp wide version (=w1707) and object-cover the
// desktop "safe area" — the centre 2560×423 (~6:1) band.
<div
className="w-full overflow-hidden rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
>
<img
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
// browser's negative cache (googleusercontent accepts an extra query param).
key={bannerAttempt}
src={bannerSrc}
referrerPolicy="no-referrer"
alt=""
className={`w-full h-full object-cover object-center ${
bannerLoaded === bannerSrc ? "" : "opacity-0"
}`}
onLoad={() => setBannerLoaded(bannerSrc)}
onError={() => {
if (bannerAttempt <= MAX_BANNER_RETRIES) {
window.setTimeout(
() => setBannerAttempt((a) => a + 1),
500 * (bannerAttempt + 1),
);
}
}}
/>
</div>
) : (
ch?.explored &&
!ch?.subscribed && (
<span className="text-xs px-2 py-0.5 rounded-full bg-warn/15 text-warn">
{t("channel.exploringBadge")}
</span>
)
// No banner (or a dead one): a neutral bar the SAME height as a real banner, so the
// overlapping avatar + the Back button have the same room and don't collide (a short bar
// let the -mt-8 avatar ride up into the Back button).
<div
className="w-full rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
/>
)}
</div>
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
{metaParts.map((part, i) => (
<span key={i}>
{i > 0 && <span className="mr-1.5">·</span>}
{part}
</span>
))}
</div>
</div>
</div>
{exploring && (
<div className="flex items-center gap-2 text-xs text-muted mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("channel.loadingVideos")}
<div className="px-4 sm:px-6">
{/* Identity. relative+z-base so the avatar that overlaps up into the banner paints
ABOVE it (the banner's positioned container would otherwise cover it). */}
<div className="relative z-base flex items-end gap-4 -mt-8">
<Avatar
src={ch?.thumbnail_url ?? null}
fallback={name}
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
/>
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
{heading}
{stateBadge}
</div>
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
{metaParts.map((part, i) => (
<span key={i}>
{i > 0 && <span className="mr-1.5">·</span>}
{part}
</span>
))}
</div>
</div>
</div>
</div>
</div>
)}
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
<div className="px-4 sm:px-6">
{exploring && (
<div className="flex items-center gap-2 text-xs text-muted mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t("channel.loadingVideos")}
</div>
)}
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
corner: there are only ever two tabs, so there's room, and it keeps the things you click
within a few pixels of each other instead of across the page. */}
<div className="flex items-center gap-2 mt-3 border-b border-border pb-2">
<Tabs
idBase={panel.idBase}
className="flex gap-1"
active={tab}
onChange={(id) => setTab(id as "videos" | "about")}
tabs={[
{ id: "videos", label: t("channel.tabVideos") },
{ id: "about", label: t("channel.tabAbout") },
]}
/>
<div className="ml-2 flex items-center gap-2">
<IconLink
href={ytUrl}
target="_blank"
rel="noreferrer"
label={t("channels.row.openOnYouTube")}
size="md"
variant="outline"
{/* Wraps rather than overflows: at a tablet width (1024) the actions + tags ran past the edge and
the whole page scrolled sideways (E31 S1). The tag lane takes what is left and folds into "+N". */}
<div
className={`flex flex-wrap items-center gap-2 border-b border-border pb-2 ${folded ? "pt-3" : "mt-3"}`}
>
{/* One Back button in both states, so a focused Back keeps its focus when the header folds:
over the banner's corner (absolute to the chrome) while unfolded, first in this row folded. */}
<Button
variant={folded ? "outline" : "media"}
data-testid="channel-back"
icon={<ArrowLeft />}
onClick={closeChannel}
className={folded ? "" : "absolute top-5 left-7 sm:left-9 z-base backdrop-blur-xs"}
>
<ExternalLink />
</IconLink>
{ch?.subscribed && (
<IconButton
label={t("channel.showInFeed")}
size="md"
variant="outline"
onClick={onShowInFeed}
{t("channel.back")}
</Button>
{folded && (
<div
className="flex min-w-0 max-w-[40%] items-center gap-2"
data-testid="channel-folded"
>
<SlidersHorizontal className="w-4 h-4" />
</IconButton>
)}
{ch?.subscribed && !me.is_demo && (
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
<span className="text-xs text-muted">{t("channels.cols.priority")}</span>
<PriorityStepper
priority={ch.priority}
onPriority={(d) => bumpPriority(channelId, d)}
onSet={(v) => setPriority(channelId, v)}
size="md"
hint={t("channels.row.priorityHint")}
<Avatar
src={ch?.thumbnail_url ?? null}
fallback={name}
className="w-8 h-8 rounded-full shrink-0"
/>
{heading}
{stateBadge}
</div>
)}
{/* Only the recent subset is stored → offer to pull the channel's full back-catalogue.
<Tabs
idBase={panel.idBase}
className="flex gap-1"
active={tab}
onChange={(id) => setTab(id as "videos" | "about")}
tabs={[
{ id: "videos", label: t("channel.tabVideos") },
{ id: "about", label: t("channel.tabAbout") },
]}
/>
{/* Unfolded, the actions share the tabs' line and the tag lane gives way ("+N"). Folded, Back
and the name take that room, so at a tablet width the actions wrap onto a line of their
own rather than push the page sideways (min-w-0 let them overflow: 1072 px at 1024). */}
<div className={`ml-2 flex flex-1 items-center gap-2 ${folded ? "" : "min-w-0"}`}>
<IconLink
href={ytUrl}
target="_blank"
rel="noreferrer"
label={t("channels.row.openOnYouTube")}
size="md"
variant="outline"
>
<ExternalLink />
</IconLink>
{ch?.subscribed && (
<IconButton
label={t("channel.showInFeed")}
size="md"
variant="outline"
onClick={onShowInFeed}
>
<SlidersHorizontal className="w-4 h-4" />
</IconButton>
)}
{ch?.subscribed && !me.is_demo && (
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
<span className="text-xs text-muted">{t("channels.cols.priority")}</span>
<PriorityStepper
priority={ch.priority}
onPriority={(d) => bumpPriority(channelId, d)}
onSet={(v) => setPriority(channelId, v)}
size="md"
hint={t("channels.row.priorityHint")}
/>
</div>
)}
{/* Only the recent subset is stored → offer to pull the channel's full back-catalogue.
Once queued (deep_requested) it shows as loading; hidden once the backfill is done.
While loading, the label carries the live stored/total count (the detail poll refreshes
it) so a large channel's paced tail reads as visible progress rather than a stuck
spinner. */}
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
<Button
variant="outline"
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
disabled={ch.deep_requested || deepBackfill.isPending}
loading={ch.deep_requested}
icon={<History className="w-4 h-4" />}
title={
ch.deep_requested
? t("channel.fullHistoryLoadingHint")
: t("channel.fullHistory")
}
>
<span className="hidden sm:inline whitespace-nowrap">
{!ch.deep_requested
? t("channel.fullHistory")
: ch.video_count != null && ch.stored_videos < ch.video_count
? t("channel.fullHistoryProgress", {
stored: ch.stored_videos,
total: ch.video_count,
})
: t("channel.fullHistoryLoading")}
</span>
</Button>
)}
{!me.is_demo && (
<IconButton
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
// light up on hover, as if a click would do something.
size="md"
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
onClick={() => block.mutate()}
loading={block.isPending}
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
>
<Ban />
</IconButton>
)}
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
<Button
variant="outline"
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
disabled={ch.deep_requested || deepBackfill.isPending}
loading={ch.deep_requested}
icon={<History className="w-4 h-4" />}
title={
ch.deep_requested
? t("channel.fullHistoryLoadingHint")
: t("channel.fullHistory")
}
>
<span className="hidden sm:inline whitespace-nowrap">
{!ch.deep_requested
? t("channel.fullHistory")
: ch.video_count != null && ch.stored_videos < ch.video_count
? t("channel.fullHistoryProgress", {
stored: ch.stored_videos,
total: ch.video_count,
})
: t("channel.fullHistoryLoading")}
</span>
</Button>
)}
{!me.is_demo && (
<IconButton
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
// light up on hover, as if a click would do something.
size="md"
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
onClick={() => block.mutate()}
loading={block.isPending}
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
>
<Ban />
</IconButton>
)}
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
from the feed. EyeOff = currently hidden (click to unhide). */}
{ch?.subscribed && !me.is_demo && (
<IconButton
size="md"
variant={ch.hidden ? "outline-accent" : "outline"}
onClick={() => toggleHidden.mutate()}
loading={toggleHidden.isPending}
label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
>
{ch.hidden ? <EyeOff /> : <Eye />}
</IconButton>
)}
{!me.is_demo &&
!ch?.blocked &&
(ch?.subscribed ? (
<Button
variant="outline"
onClick={onUnsubscribe}
loading={unsubscribe.isPending}
title={t("channel.unsubscribe")}
icon={<Check className="w-4 h-4" />}
{ch?.subscribed && !me.is_demo && (
<IconButton
size="md"
variant={ch.hidden ? "outline-accent" : "outline"}
onClick={() => toggleHidden.mutate()}
loading={toggleHidden.isPending}
label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
>
{t("channel.subscribedState")}
</Button>
) : (
<Button
variant="outline"
onClick={() => subscribe.mutate()}
loading={subscribe.isPending}
icon={<Plus className="w-4 h-4" />}
>
{t("channel.subscribe")}
</Button>
))}
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
{ch.hidden ? <EyeOff /> : <Eye />}
</IconButton>
)}
{!me.is_demo &&
!ch?.blocked &&
(ch?.subscribed ? (
<Button
variant="outline"
onClick={onUnsubscribe}
loading={unsubscribe.isPending}
title={t("channel.unsubscribe")}
icon={<Check className="w-4 h-4" />}
>
{t("channel.subscribedState")}
</Button>
) : (
<Button
variant="outline"
onClick={() => subscribe.mutate()}
loading={subscribe.isPending}
icon={<Plus className="w-4 h-4" />}
>
{t("channel.subscribe")}
</Button>
))}
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
otherwise), and personal tags are for your subscriptions — so only offer it on a
channel you actually follow. */}
{ch?.subscribed && !me.is_demo && (
<ChannelTagsControl
userTags={userTags}
attachedIds={ch.tag_ids}
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(channelId, name)}
// The header row has free width — show tags inline instead of collapsing to "+N".
bounded={false}
/>
)}
{ch?.subscribed && !me.is_demo && (
<div className="min-w-0 flex-1">
<ChannelTagsControl
userTags={userTags}
attachedIds={ch.tag_ids}
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(channelId, name)}
// The header row's leftover width: tags inline, "+N" only when they do not fit.
bounded={false}
/>
</div>
)}
</div>
</div>
</div>
</div>
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
//
// E31 (the owner): a freshly opened channel page shows what is still unwatched, and a watch state the
// viewer picks there sticks — a reload (a fresh mount, seeded from the stored preference) keeps it.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { FeedFilters } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
const feed = vi.hoisted(() => ({
last: null as null | { filters: FeedFilters; setFilters: (f: FeedFilters) => void },
}));
vi.mock("./Feed", () => ({
default: (p: { filters: FeedFilters; setFilters: (f: FeedFilters) => void }) => {
feed.last = p;
return null;
},
}));
vi.mock("./ChannelAbout", () => ({ default: () => null }));
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
import ChannelPage from "./ChannelPage";
import { api, type ChannelDetail, type Me } from "../lib/api";
import { qk } from "../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
feed.last = null;
});
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
function mount(preferences: Record<string, unknown> = {}) {
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "StarTalk",
subscribed: true,
stored_videos: 10,
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const save = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, preferences } as unknown as Me);
render(
<QueryClientProvider client={qc}>
<ChannelPage
channelId={CH}
initialName="StarTalk"
me={{ id: 1, can_write: true, can_read: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
return save;
}
describe("ChannelPage — the watch state", () => {
it("shows only the unwatched videos on a freshly opened page", () => {
mount();
expect(feed.last?.filters.show).toBe("unwatched");
});
it("stores a picked watch state", () => {
const save = mount();
act(() => feed.last!.setFilters({ ...feed.last!.filters, show: "all" }));
expect(feed.last?.filters.show).toBe("all");
expect(save).toHaveBeenCalledWith({ channelShow: "all" });
});
it("keeps the stored watch state after a reload", () => {
mount({ channelShow: "watched" });
expect(feed.last?.filters.show).toBe("watched");
});
it("falls back to unwatched for a stored value it does not know", () => {
mount({ channelShow: "bogus" });
expect(feed.last?.filters.show).toBe("unwatched");
});
});
+1
View File
@@ -754,6 +754,7 @@ export default function Channels() {
keyboardNav
reveal={follow}
surfacedFilters={SURFACED_FILTERS}
narrowedBy={q}
/>
) : (
<>
@@ -72,6 +72,7 @@ vi.mock("../lib/api", async (importOriginal) => {
choice("log_level", LEVELS, "INFO"),
],
},
sections: [{ id: "s", groups: ["g"] }],
secrets_manageable: true,
}),
setConfig: (key: string, value: unknown) => setConfig(key, value),
@@ -46,6 +46,7 @@ vi.mock("../lib/api", async (importOriginal) => {
groups: {
g: [text("site_name"), { ...text("signups_open"), type: "bool", value: "false" }],
},
sections: [{ id: "s", groups: ["g"] }],
secrets_manageable: true,
}),
},
@@ -0,0 +1,92 @@
// @vitest-environment jsdom
//
// E31 (the owner's pick): Configuration's sixteen group tabs wrapped to three rows at 133% zoom, so they
// are a side list under four headings, as Settings has. The registry owns the order: the groups follow
// its sections, not the order the groups object happens to arrive in.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ConfigItem } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
const text = (key: string, group: string): ConfigItem => ({
key,
type: "str",
group,
secret: false,
min: null,
max: null,
is_set: false,
allow_empty: true,
choices: [],
value: "",
default: "",
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
adminConfig: () =>
Promise.resolve({
groups: { b: [text("kb", "b")], c: [text("kc", "c")], a: [text("ka", "a")] },
sections: [
{ id: "one", groups: ["a", "b"] },
{ id: "two", groups: ["c"] },
],
secrets_manageable: true,
}),
},
};
});
vi.mock("../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
import ConfigPanel from "./ConfigPanel";
afterEach(() => {
cleanup();
localStorage.clear();
});
const renderPanel = () =>
render(
<QueryClientProvider client={new QueryClient()}>
<ConfigPanel />
</QueryClientProvider>,
);
describe("Configuration sections (E31)", () => {
it("lists the groups in the registry's sections, each heading once above its run", async () => {
renderPanel();
const list = await screen.findByRole("tablist");
expect(list.getAttribute("aria-orientation")).toBe("vertical");
// The list's own lines, top to bottom: headings and tabs.
const lines = Array.from(list.children).map((el) =>
el.getAttribute("role") === "tab" ? `tab:${el.textContent}` : `head:${el.textContent}`,
);
expect(lines).toEqual([
"head:config.sections.one",
"tab:config.groups.a",
"tab:config.groups.b",
"head:config.sections.two",
"tab:config.groups.c",
]);
});
it("opens on the first group and switches to the one picked", async () => {
renderPanel();
expect(await screen.findByText("config.fields.ka.label")).toBeTruthy();
fireEvent.click(screen.getByRole("tab", { name: "config.groups.c" }));
expect(screen.getByText("config.fields.kc.label")).toBeTruthy();
expect(screen.queryByText("config.fields.ka.label")).toBeNull();
});
});
+128 -123
View File
@@ -11,12 +11,11 @@ import { useMe } from "../lib/useMe";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { Switch } from "./ui/form";
import SegmentedToggle from "./ui/SegmentedToggle";
import FilterSelect from "./ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS, useAccountPersistedState } from "../lib/storage";
import { LS, useTabPersistedState } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors";
import { Button } from "./ui/Button";
import { CheckRow } from "./ui/NativeControls";
@@ -57,7 +56,7 @@ export default function ConfigPanel() {
const [reseedToken, setReseedToken] = useState(0);
// One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded.
const [tab, setTab] = useAccountPersistedState(LS.configTab);
const [tab, setTab] = useTabPersistedState(LS.configTab);
// A stable signature of the config SHAPE — changes only when keys are added/removed, not when an
// unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial
@@ -176,146 +175,152 @@ export default function ConfigPanel() {
return <LoadingState label={t("config.loading")} />;
}
const groupKeys = Object.keys(data.groups);
// The groups in the registry's sections, in order (E31: sixteen tabs wrapped to three rows at 133%
// zoom, so they are a side list under four headings, as Settings has).
const groupKeys = data.sections.flatMap((s) => s.groups);
// Clamp the persisted tab to a real group (the registry can change between sessions). The
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
// never silently lost behind the global Save bar.
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
const dirtyByGroup = new Set(dirtyKeys.map((k) => byKey[k]?.group).filter(Boolean));
const groupTabs = groupKeys.map((g) => ({
id: g,
label: `${t(`config.groups.${g}`, g)}${dirtyByGroup.has(g) ? " •" : ""}`,
}));
const groupTabs = data.sections.flatMap((s) =>
s.groups.map((g) => ({
id: g,
group: t(`config.sections.${s.id}`, s.id),
label: `${t(`config.groups.${g}`, g)}${dirtyByGroup.has(g) ? " •" : ""}`,
testId: `config-tab-${g}`,
})),
);
return (
<>
{/* The tab bar is the page's band (B4); the intro scrolls with the fields — in the band it
took three lines of a short window for text read once. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<div className="px-4 pb-24 pt-3 max-w-4xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
{/* The groups as a side list under their headings, the fields beside it: Settings' own
layout, one pattern for both settings pages (E31). */}
<div className={`${SURFACE.panel} mb-4 flex flex-col overflow-hidden sm:flex-row`}>
<Tabs
idBase={panel.idBase}
orientation="vertical"
className="flex gap-1 overflow-x-auto border-b border-border/60 p-2 sm:w-52 sm:shrink-0 sm:flex-col sm:border-b-0 sm:border-r"
tabs={groupTabs}
active={activeGroup ?? ""}
onChange={setTab}
/>
</div>
</PageToolbar>
<div className="px-4 pb-24 pt-1 max-w-3xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
{activeGroup && (
<div {...panel.panelProps(activeGroup ?? "")} className={`${SURFACE.panel} p-4 mb-4`}>
<div className="divide-y divide-border/60">
{(data.groups[activeGroup] ?? []).map((item) => (
<Field
key={item.key}
item={item}
value={draft[item.key] ?? ""}
secretsManageable={data.secrets_manageable}
pendingSecretReset={!!secretReset[item.key]}
onChange={(v) => setDraft((d) => ({ ...d, [item.key]: v }))}
onResetField={() => {
if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] }));
else setDraft((d) => ({ ...d, [item.key]: "" }));
}}
/>
))}
</div>
{activeGroup && (
<div {...panel.panelProps(activeGroup ?? "")} className="min-w-0 flex-1 p-4">
<div className="divide-y divide-border/60">
{(data.groups[activeGroup] ?? []).map((item) => (
<Field
key={item.key}
item={item}
value={draft[item.key] ?? ""}
secretsManageable={data.secrets_manageable}
pendingSecretReset={!!secretReset[item.key]}
onChange={(v) => setDraft((d) => ({ ...d, [item.key]: v }))}
onResetField={() => {
if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] }));
else setDraft((d) => ({ ...d, [item.key]: "" }));
}}
/>
))}
</div>
{activeGroup === "email" && (
<div className="mt-3 pt-3 border-t border-border/60 space-y-3">
{/* The one place an operator can see that mail is off. Sending is fail-soft, so a
{activeGroup === "email" && (
<div className="mt-3 pt-3 border-t border-border/60 space-y-3">
{/* The one place an operator can see that mail is off. Sending is fail-soft, so a
half-filled config costs verification links, resets and access-request notices
with no error anywhere — say so next to the fields that fix it. */}
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
<Tooltip hint={t("config.testEmailHint")}>
<Button
variant="glass"
icon={<Send />}
loading={testEmail.isPending}
aria-disabled={dirty}
onClick={() => testEmail.mutate()}
>
{t("config.testEmail")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted">{t("config.testDirty")}</p>}
</div>
)}
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
<Tooltip hint={t("config.testEmailHint")}>
<Button
variant="glass"
icon={<Send />}
loading={testEmail.isPending}
aria-disabled={dirty}
onClick={() => testEmail.mutate()}
>
{t("config.testEmail")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted">{t("config.testDirty")}</p>}
</div>
)}
{activeGroup === "issues" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.issuesTestHint")}>
<Button
variant="glass"
icon={<Plug />}
loading={testIssues.isPending}
aria-disabled={dirty}
onClick={() => testIssues.mutate()}
>
{t("config.issuesTest")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
</div>
)}
{activeGroup === "issues" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.issuesTestHint")}>
<Button
variant="glass"
icon={<Plug />}
loading={testIssues.isPending}
aria-disabled={dirty}
onClick={() => testIssues.mutate()}
>
{t("config.issuesTest")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
</div>
)}
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
<Button
variant="glass"
icon={<Plug />}
loading={testPlex.isPending}
aria-disabled={dirty}
onClick={() => testPlex.mutate()}
>
{t("config.plexTest")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
{plexResult && (
<div className="mt-3">
<p className="text-xs text-muted mb-2">
{t("config.plexConnected", {
name: plexResult.server_name,
version: plexResult.version ?? "",
})}
</p>
{plexResult.media_check?.checked &&
(plexResult.media_check.ok ? (
<p className="text-2xs text-success mb-2">{t("config.plexMediaOk")}</p>
) : (
<p className="text-2xs text-danger mb-2">
{t("config.plexMediaMissing", {
path: plexResult.media_check.local_path ?? "",
})}
</p>
))}
<p className="text-2xs text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
<div className="flex flex-col gap-1.5">
{plexResult.sections.map((s) => (
<CheckRow
key={s.key}
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
onChange={() => togglePlexLib(s.key)}
className="text-sm"
>
<span>{s.title}</span>
<span className="text-2xs text-muted">
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
{s.count != null ? ` · ${s.count}` : ""}
</span>
</CheckRow>
))}
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
<Button
variant="glass"
icon={<Plug />}
loading={testPlex.isPending}
aria-disabled={dirty}
onClick={() => testPlex.mutate()}
>
{t("config.plexTest")}
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
{plexResult && (
<div className="mt-3">
<p className="text-xs text-muted mb-2">
{t("config.plexConnected", {
name: plexResult.server_name,
version: plexResult.version ?? "",
})}
</p>
{plexResult.media_check?.checked &&
(plexResult.media_check.ok ? (
<p className="text-2xs text-success mb-2">{t("config.plexMediaOk")}</p>
) : (
<p className="text-2xs text-danger mb-2">
{t("config.plexMediaMissing", {
path: plexResult.media_check.local_path ?? "",
})}
</p>
))}
<p className="text-2xs text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
<div className="flex flex-col gap-1.5">
{plexResult.sections.map((s) => (
<CheckRow
key={s.key}
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
onChange={() => togglePlexLib(s.key)}
className="text-sm"
>
<span>{s.title}</span>
<span className="text-2xs text-muted">
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
{s.count != null ? ` · ${s.count}` : ""}
</span>
</CheckRow>
))}
</div>
<p className="text-2xs text-muted mt-2">{t("config.plexPickHint")}</p>
</div>
<p className="text-2xs text-muted mt-2">{t("config.plexPickHint")}</p>
</div>
)}
</div>
)}
</div>
)}
)}
</div>
)}
</div>
)}
</div>
<DraftSaveBar
variant="floating"
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
//
// The count pill while a new search's count loads. keepPreviousData holds the last filter's count, and a
// broad word's count takes seconds ("a": ~3 s on the prod-sized library, E31): the pill showed the old
// number as if it were the new search's. It now dims and is aria-busy until the new count lands.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
vi.mock("./VirtualFeed", () => ({ default: () => null }));
vi.mock("./SelectedViewChip", () => ({ default: () => null }));
vi.mock("./ActiveFilterChips", () => ({ default: () => null }));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api";
import { qk } from "../lib/queryKeys";
import type { ReactElement } from "react";
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
// The pill lives in the toolbar, which portals into the shell's band: give it one.
const slot = document.body.appendChild(document.createElement("div"));
const feed = (q: string, qc: QueryClient): ReactElement => (
<QueryClientProvider client={qc}>
<PageToolbarSlot.Provider value={slot}>
<Feed
filters={{ ...FILTERS, q }}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch=""
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</PageToolbarSlot.Provider>
</QueryClientProvider>
);
describe("Feed — the count while a new search's count loads", () => {
it("dims the previous count and marks it busy until the new one lands", async () => {
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
let settle: (v: unknown) => void = () => {};
vi.spyOn(api, "feedSummary").mockImplementation(((f: FeedFilters) =>
f.q === "kitchen"
? Promise.resolve({ count: 1937, facets: {} })
: new Promise((r) => (settle = r))) as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
const pill = () => screen.getByTestId("item-count");
const view = render(feed("kitchen", qc));
await waitFor(() => expect(pill().getAttribute("title")).toBe("1,937"));
expect(pill().getAttribute("aria-busy")).toBeNull();
view.rerender(feed("a", qc));
await waitFor(() => expect(pill().getAttribute("aria-busy")).toBe("true"));
expect(pill().getAttribute("title")).toBe("1,937"); // still the old figure, now marked as such
settle({ count: 178991, facets: {} });
await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991"));
expect(pill().getAttribute("aria-busy")).toBeNull();
});
it("says the count failed instead of showing the empty box of a first load", async () => {
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
vi.spyOn(api, "feedSummary").mockImplementation(((f: FeedFilters) =>
f.q === "kitchen"
? Promise.resolve({ count: 1937, facets: {} })
: Promise.reject(new Error("boom"))) as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
const pill = () => screen.getByTestId("item-count");
const view = render(feed("kitchen", qc));
await waitFor(() => expect(pill().getAttribute("title")).toBe("1,937"));
view.rerender(feed("a", qc));
await waitFor(() => expect(pill().getAttribute("title")).toBe("common.countFailed"));
expect(pill().getAttribute("aria-busy")).toBeNull();
});
});
@@ -0,0 +1,114 @@
// @vitest-environment jsdom
//
// A next page that fails. Retry is off and the read-ahead re-runs whenever a fetch settles, so a failed
// page was requested again once a round-trip, forever, while the end of the list was in view, and
// nothing said it had failed (E31 review 2). The read-ahead now stops at a failed page, and a row says
// so and retries on a click. VirtualFeed is replaced by what Feed hands it: the read-ahead's gate and a
// button standing in for the read-ahead itself.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
vi.mock("./VirtualFeed", () => ({
default: ({
hasNextPage,
fetchNextPage,
}: {
hasNextPage: boolean;
fetchNextPage: (o: { cancelRefetch: boolean }) => unknown;
}) => (
<div>
<span data-testid="has-next">{String(hasNextPage)}</span>
<button onClick={() => void fetchNextPage({ cancelRefetch: false })}>read ahead</button>
</div>
),
}));
vi.mock("./SelectedViewChip", () => ({ default: () => null }));
vi.mock("./ActiveFilterChips", () => ({ default: () => null }));
import Feed from "./Feed";
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api";
import { qk } from "../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const FILTERS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
describe("Feed — a next page that fails", () => {
it("stops the read-ahead, says so, and loads the page on a retry", async () => {
const page = (n: number, next: string | null) => ({
items: [
{ id: `v${n}`, title: `v${n}`, channel_id: "UCx", published_at: "2026-01-01T00:00:00Z" },
],
next_cursor: next,
});
let failNext = true;
const feed = vi
.spyOn(api, "feed")
.mockImplementation(((_f: FeedFilters, cursor: string | null) =>
cursor === null
? Promise.resolve(page(1, "c2"))
: failNext
? Promise.reject(new Error("boom"))
: Promise.resolve(page(2, null))) as never);
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 2, facets: {} } as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
render(
<QueryClientProvider client={qc}>
<Feed
filters={FILTERS}
setFilters={() => {}}
view="cards"
setView={() => {}}
canRead
ytSearch=""
onYtSearch={() => {}}
onExitYtSearch={() => {}}
/>
</QueryClientProvider>,
);
await waitFor(() => expect(screen.getByTestId("has-next").textContent).toBe("true"));
fireEvent.click(screen.getByText("read ahead"));
await waitFor(() => expect(screen.getByText("common.loadMoreFailed")).toBeTruthy());
expect(screen.getByTestId("has-next").textContent).toBe("false"); // the read-ahead stands down
const calls = feed.mock.calls.length;
failNext = false;
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await waitFor(() => expect(feed.mock.calls.length).toBe(calls + 1));
await waitFor(() => expect(screen.queryByText("common.loadMoreFailed")).toBeNull());
});
});
+27 -8
View File
@@ -7,6 +7,7 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { nextPageOnClick } from "../lib/nextPage";
import {
ArrowLeft,
Columns2,
@@ -37,6 +38,7 @@ import {
} from "../lib/feedSort";
import { type SortOption, type SortState } from "../lib/columnSort";
import { usePref } from "../lib/usePref";
import { SHOW_IDS } from "./feedFiltersInit";
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
@@ -64,7 +66,6 @@ const PAGE = 60;
// The "Show" view filter, content-type filter and ordering live in the feed toolbar
// (above the cards), not the filter sidebar.
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
const CONTENT = [
{ key: "includeNormal", label: "sidebar.content.normal" },
{ key: "includeShorts", label: "sidebar.content.shorts" },
@@ -549,14 +550,16 @@ export default function Feed({
onResetState={onResetState}
onOpen={openVideo}
hasNextPage={false}
isFetchingNextPage={false}
isFetching={false}
fetchNextPage={() => {}}
/>
{ytQuery.hasNextPage && (
<div className="text-center pt-4">
<Button
variant="outline"
onClick={() => ytQuery.fetchNextPage()}
onClick={() =>
void ytQuery.fetchNextPage(nextPageOnClick(ytQuery.isFetchingNextPage))
}
loading={ytQuery.isFetchingNextPage}
>
{ytQuery.isFetchingNextPage
@@ -709,7 +712,11 @@ export default function Feed({
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
never shoves the controls beside it (see ItemCount). */}
<ItemCount count={summaryQuery.data?.count} />
<ItemCount
count={summaryQuery.data?.count}
stale={summaryQuery.isPlaceholderData}
failed={summaryQuery.isError && !summaryQuery.data}
/>
<FilterFoldButton
fold={fold}
label={t("sidebar.filters")}
@@ -825,8 +832,8 @@ export default function Feed({
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
the toolbar rides with the tabs. Channel pages get no chips: their Feed starts from its OWN
baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you
applied" when it's just the page being itself. */}
baseline (channel-scoped, whole-catalog, the watch state the channel page remembers), so
each would read as a "filter you applied" when it's just the page being itself. */}
<PageToolbar>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
{toolbar}
@@ -890,8 +897,11 @@ export default function Feed({
onToggleSave={onToggleSave}
onResetState={onResetState}
onOpen={openVideo}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
// A failed next page stops the read-ahead: retry is off, and re-running on every settle would
// re-request it once a round-trip for as long as the end is in view (E31 review 2). The row
// below says so and retries on a click.
hasNextPage={!!hasNextPage && !query.isFetchNextPageError}
isFetching={query.isFetching}
fetchNextPage={fetchNextPage}
/>
)}
@@ -909,6 +919,15 @@ export default function Feed({
{isFetchingNextPage && (
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
)}
{query.isFetchNextPageError && !isFetchingNextPage && (
<StateMessage
tone="error"
compact
onRetry={() => void fetchNextPage(nextPageOnClick(isFetchingNextPage))}
>
{t("common.loadMoreFailed")}
</StateMessage>
)}
</div>
);
}
+3 -8
View File
@@ -59,13 +59,14 @@ import { SURFACE } from "./ui/surfaces";
import clsx from "clsx";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
import { HOVER_YIELD } from "./ui/reveal";
import { MediaResumeButtons } from "./MediaResumeButtons";
import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard";
import {
MEDIA_BAR,
MEDIA_BADGE,
MEDIA_HOVER_LAYER,
MEDIA_RESUME_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaPictureBox,
@@ -595,13 +596,7 @@ function Hero({
{/* A started hero continues on a click, and offers the start as a card does (C5): the pair
is a sibling over the picture, never inside its button. */}
{started && (
<div
className={clsx(
HOVER_REVEAL,
"pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto",
card.aspect === "poster" && "flex-col",
)}
>
<div className={clsx(MEDIA_RESUME_LAYER, card.aspect === "poster" && "flex-col")}>
<MediaResumeButtons onContinue={() => onPlay(card)} onRestart={() => onPlay(card, 0)} />
</div>
)}
+26 -2
View File
@@ -30,11 +30,13 @@ import {
NavRail,
NavRailAction,
NavRailDivider,
NavRailGroup,
NavRailItem,
NavRailList,
RAIL_ROW,
} from "./ui/NavRail";
import { usePrefs } from "./prefsContext";
import { pageHref } from "../lib/nav";
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
@@ -185,6 +187,7 @@ export default function NavSidebar({
showLabel={railLabels}
active={page === p}
badge={badge}
href={pageHref(p)}
onClick={() => setPage(p)}
/>
);
@@ -197,8 +200,28 @@ export default function NavSidebar({
<NavRail label={t("nav.primary")} className="absolute top-0 bottom-0 left-0 z-rail my-3">
<NavRailList ref={listFade.ref} style={listFade.style}>
{userItems.map(renderItem)}
{systemItems.length > 0 && <NavRailDivider />}
{systemItems.map(renderItem)}
{systemItems.length > 0 && (
<>
<NavRailDivider />
{/* The admin pages, one row opening a panel of them (E31): five rows of their own sat
below the fold at 133% zoom, behind a fade alone. */}
<NavRailGroup
testId="nav-admin"
icon={Shield}
label={t("nav.admin")}
shortLabel={t("nav.rail.admin")}
showLabel={railLabels}
pages={systemItems.map(({ page: p, icon, label }) => ({
id: p,
icon,
label,
href: pageHref(p),
active: page === p,
onSelect: () => setPage(p),
}))}
/>
</>
)}
</NavRailList>
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
@@ -214,6 +237,7 @@ export default function NavSidebar({
shortLabel={t(moduleRailKey.settings)}
showLabel={railLabels}
active={page === "settings"}
href={pageHref("settings")}
onClick={() => setPage("settings")}
/>
@@ -10,7 +10,7 @@
// then browser Back — is still checked in a real browser: jsdom reproduces history ordering only as
// bookkeeping.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react";
import { renderHook, render as rtlRender, act, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
@@ -28,6 +28,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext";
import { useBackToClose } from "../lib/history";
import { PageHeading } from "./ui/Headings";
import { qk } from "../lib/queryKeys";
@@ -88,6 +89,41 @@ describe("NavigationProvider · the tab title", () => {
expect(document.title).not.toBe("Siftlode");
expect(document.title.endsWith(" · Siftlode")).toBe(true);
});
// E31 S1: the Feed's tab read a bare "Siftlode" — the one page that named no place.
it("names the Feed like every other page", async () => {
window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed");
render();
await settle();
expect(document.title).toBe("header.account.feed · Siftlode");
});
// E31 S1: a channel opened from a link was "Channels · Siftlode", the same channel opened in the
// app was named; a Plex title, person or collection only "Plex". The object is the view's heading.
it("puts the open view's own heading before the place, and drops it when the view closes", async () => {
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
function View({ name }: { name: string | null }) {
return name === null ? null : <PageHeading>{name}</PageHeading>;
}
const view = rtlRender(<View name="" />, { wrapper });
await settle();
expect(document.title).toBe("plex.navLabel · Siftlode"); // no title yet: the place alone
view.rerender(<View name="Nicolas Cage" />);
await settle();
expect(document.title).toBe("Nicolas Cage · plex.navLabel · Siftlode");
view.rerender(<View name={null} />);
await settle();
expect(document.title).toBe("plex.navLabel · Siftlode");
});
it("takes a heading's tabTitle over its text", async () => {
window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex");
rtlRender(<PageHeading tabTitle="Voyager · Season 1">Season 1</PageHeading>, { wrapper });
await settle();
expect(document.title).toBe("Voyager · Season 1 · plex.navLabel · Siftlode");
});
});
describe("NavigationProvider · setPage's same-page guard", () => {
+9 -12
View File
@@ -4,6 +4,7 @@ import { useMe } from "../lib/useMe";
import { urlWithParams, type Page } from "../lib/urlState";
import { LOGS_KEYS } from "../lib/logsUrlState";
import { pageTitleKey } from "../lib/pageMeta";
import { useDocumentObjectTitle } from "../lib/documentTitle";
import { setAccountRaw } from "../lib/storage";
import { setNavigator } from "../lib/nav";
import {
@@ -164,21 +165,17 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
if (page !== "feed") setYtSearch(null);
}
// Reflect the current module in the browser tab title. Feed = brand only; an open channel page
// shows the channel name. Signed out, the page on screen is the public landing whatever `page`
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
// "<object> · <module> · Siftlode". The object is the open view's own heading (a channel, a Plex
// title, a playlist — `PageHeading` reports it), so a channel opened from a link is named the same
// as one opened in the app. Signed out, the page on screen is the public landing whatever `page`
// says — the tab read "What should I watch?" over the sign-in form (prod UAT 2026-09-19).
const signedIn = useMe() != null;
const objectTitle = useDocumentObjectTitle();
useEffect(() => {
const brand = "Siftlode";
const label = !signedIn
? null
: channelView
? channelView.name || t("header.channelManager")
: page === "feed"
? null
: t(pageTitleKey(page));
document.title = label ? `${label} · ${brand}` : brand;
}, [signedIn, page, channelView, i18n.language, t]);
const parts = signedIn ? [objectTitle, t(pageTitleKey(page))] : [];
document.title = [...parts.filter(Boolean), "Siftlode"].join(" · ");
}, [signedIn, page, objectTitle, i18n.language, t]);
// The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history
// entry: every later page change writes its own entry through setPage.
@@ -9,7 +9,7 @@
// test that leaves `window.YT` unset). Each item the modal plays constructs a new YT.Player, and the
// fake records what it was asked for — that is how these tests see where playback starts.
import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, cleanup, act, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
@@ -443,3 +443,149 @@ describe("PlayerModal — the side arrows", () => {
expect(arrow.parentElement!.className).toMatch(/sm:px-14/);
});
});
// E31 S1: at 133% zoom (a 685px window) a full-width card ran 20px past the bottom and cut its channel
// row. The card's width is capped from the window height less what sits under the video, measured.
describe("PlayerModal — fits the window's height", () => {
it("caps the card's width by the chrome measured under the video", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.classList.contains("player-stage") ? 470 : 0;
});
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.querySelector(":scope > .player-stage") ? 630 : 0;
});
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
expect(row.style.maxWidth).toBe("min(63rem, calc((100dvh - 3rem - 160px) * 16 / 9 + 7rem))");
});
});
// E31 (the owner's pick "A"): on a wide window the video was capped at ~1008 px of a 1920 screen, with
// empty space all round. From 1280 px the title, channel, actions, description and queue sit in a side
// panel beside the video, and the video takes the window's height.
describe("PlayerModal — the side panel on a wide window", () => {
const wideWindow = (matches: boolean) =>
vi.spyOn(window, "matchMedia").mockImplementation(
(q: string) =>
({
matches: matches && q === "(min-width: 1280px)",
media: q,
addEventListener: () => {},
removeEventListener: () => {},
}) as unknown as MediaQueryList,
);
beforeEach(() => {
window.matchMedia ??= (() => ({})) as unknown as typeof window.matchMedia;
});
it("puts the details beside the video, the description open, and sizes the card for both", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockResolvedValue({
description: "A long description",
} as Awaited<ReturnType<typeof api.videoDetail>>);
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector<HTMLElement>(".player-stage")!;
const card = stage.parentElement!;
const info = screen.getByTestId("player-info");
// The card is a row: the video, then the panel holding the title and the channel.
expect(card.className).toMatch(/(^|\s)flex(\s|$)/);
expect(card.contains(info) && !stage.contains(info)).toBe(true);
expect(info.contains(screen.getByRole("heading", { name: "Video A" }))).toBe(true);
expect(stage.compareDocumentPosition(info) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// The description is shown outright; there is no ⓘ to open it.
expect(await screen.findByText("A long description")).toBeTruthy();
expect(screen.queryByRole("button", { name: "player.description" })).toBeNull();
// The row's width: the video at the window's height, plus the panel and the side strips.
expect(card.parentElement!.style.maxWidth).toBe(
"calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)",
);
});
it("lets the wheel scroll the panel while it can, and turn the volume everywhere else", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockResolvedValue({
description: "A long description",
} as Awaited<ReturnType<typeof api.videoDetail>>);
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const text = await screen.findByText("A long description");
const panel = screen.getByTestId("player-info");
let top = 0;
Object.defineProperty(panel, "scrollTop", { get: () => top, configurable: true });
Object.defineProperty(panel, "scrollHeight", { value: 900, configurable: true });
Object.defineProperty(panel, "clientHeight", { value: 500, configurable: true });
const spin = (el: Element, deltaY: number) => {
const e = new WheelEvent("wheel", { deltaY, bubbles: true, cancelable: true });
el.dispatchEvent(e);
return e.defaultPrevented; // prevented = the wheel went to the volume
};
expect(spin(text, 100)).toBe(false); // room below: the panel scrolls
expect(spin(text, -100)).toBe(true); // at its top: nothing to scroll up, so the volume
top = 400;
expect(spin(text, 100)).toBe(true); // at its end: the volume
expect(spin(text, -100)).toBe(false);
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
});
// E31 S5: a failed load read "No description." in the always-open panel — an error drawn as empty.
it("says the description failed to load, with a retry, instead of 'No description'", async () => {
wideWindow(true);
const detail = vi
.spyOn(api, "videoDetail")
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValue({ description: "A long description" } as Awaited<
ReturnType<typeof api.videoDetail>
>);
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const panel = screen.getByTestId("player-description");
expect(await within(panel).findByText("player.descriptionFailed")).toBeTruthy();
expect(within(panel).queryByText("player.noDescription")).toBeNull();
fireEvent.click(within(panel).getByRole("button", { name: "common.retry" }));
expect(await within(panel).findByText("A long description")).toBeTruthy();
expect(detail).toHaveBeenCalledTimes(2);
});
// E31 review 2: the popover below 1280 px said it the same way only by a copy nobody tested.
it("says it in the description popover below 1280 px too", async () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockRejectedValue(new Error("boom"));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
fireEvent.click(screen.getByRole("button", { name: "player.description" }));
expect(await screen.findByText("player.descriptionFailed")).toBeTruthy();
expect(screen.queryByText("player.noDescription")).toBeNull();
});
it("keeps the details under the video below 1280 px", () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const card = document.querySelector<HTMLElement>(".player-stage")!.parentElement!;
expect(card.className).not.toMatch(/(^|\s)flex(\s|$)/);
expect(screen.getByRole("button", { name: "player.description" })).toBeTruthy();
expect(screen.queryByTestId("player-description")).toBeNull();
});
});
+340 -223
View File
@@ -1,4 +1,13 @@
import { useCallback, useEffect, useEffectEvent, useId, useRef, useState } from "react";
import {
useCallback,
useEffect,
useEffectEvent,
useId,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./navigationContext";
@@ -53,6 +62,7 @@ import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { useBackToClose } from "../lib/history";
import ChannelNameLink from "./ChannelNameLink";
import Toaster from "./ui/Toaster";
import { StateMessage } from "./ui/QueryState";
import { useOverlayOrder } from "./ui/overlayOrder";
import { afterRemoval } from "../lib/playerQueue";
import {
@@ -68,6 +78,7 @@ import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { IconLink, LinkButton } from "./ui/LinkButton";
import { SectionLabel } from "./ui/Headings";
import { useMediaQuery } from "../lib/useMediaQuery";
// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
@@ -90,6 +101,20 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
return false;
}
// From this window width the player's details sit in a side panel beside the video (E31). The panel is
// a rem width, so it grows with the text size; the video takes what is left.
const WIDE_PLAYER_PX = 1280;
const SIDE_PANEL = "20rem";
// The meta line's items each lead with a "·" that parts them from what stands before: the channel name
// on its line. In the wide panel the meta has a line of its own, where the first "·" would lead nothing.
const metaSep = (
<span aria-hidden="true" data-meta-sep="">
·{" "}
</span>
);
const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
export default function PlayerModal({
video,
startAt,
@@ -181,6 +206,29 @@ export default function PlayerModal({
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
// dep lists too.
const focusModal = useCallback(() => cardRef.current?.focus(), []);
// The card fits the window, video AND what sits under it (the queue strip, title, channel row): the
// video is 16:9 at the card's width, so on a short window (133% zoom on a 1080p screen: 685px) a
// full-width card ran past the bottom and cut its channel row (E31 S1). `below` is that chrome's
// height, measured (it wraps with the title and the text size), and caps the card's width below.
const [below, setBelow] = useState(0);
// A wide window (E31, the owner's pick "A"): the title, channel, actions, description and the queue
// move into a side panel beside the video, so the video takes the window's whole height — at
// 1920×911 it was capped at ~1008 px wide with empty space all round. Narrower, they stay under it.
const wide = useMediaQuery(`(min-width: ${WIDE_PLAYER_PX}px)`, false);
useLayoutEffect(() => {
const card = cardRef.current;
const stage = stageRef.current;
if (!card || !stage) return;
const measure = () => {
// A maximised stage is fixed to the viewport, out of the card's flow: keep the last value.
if (getComputedStyle(stage).position === "fixed") return;
setBelow(card.scrollHeight - stage.offsetHeight);
};
measure();
const ro = new ResizeObserver(measure);
for (const child of card.children) ro.observe(child);
return () => ro.disconnect();
}, []);
// The dialog contract every other `aria-modal` shell shares (ui/dialogFocus): the focus goes back
// to whatever opened the player, and the page lock is the one counted lock. It opens focused on the
// CARD, not its first button, so Space/F/arrows reach the shortcuts. Until E11 this was the one
@@ -423,7 +471,8 @@ export default function PlayerModal({
queryFn: () => api.videoDetail(currentVideoId),
// On opening the description, and eagerly when navigated, so we can link the
// linked video's channel. Both share the one cached result.
enabled: showDesc || navigated,
// On a wide window the side panel shows the description outright (a stored video's is a DB read).
enabled: showDesc || navigated || wide,
staleTime: 5 * 60_000,
});
@@ -558,6 +607,30 @@ export default function PlayerModal({
qc,
});
// The description, said the same way in its two places (the popover under 1280 px, the panel
// beside the video above it): loading, failed with a retry, the text, or none. One ladder, so the
// two cannot drift (E31 review 2: the failed state was tested in the panel only). `wrap` is each
// place's own box for the text.
const description = (wrap: (body: ReactNode) => ReactNode) =>
detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.isError && !detail.data ? (
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
{t("player.descriptionFailed")}
</StateMessage>
) : detail.data?.description ? (
wrap(
renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
}),
)
) : (
<div className="text-sm text-muted">{t("player.noDescription")}</div>
);
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
// must not render inside the page scroller: once that scroller is a stacking context (its
// edge-fade mask makes it one) this z-overlay is scoped inside it, and the nav rail (z-rail) and the
@@ -579,7 +652,17 @@ export default function PlayerModal({
it and a portrait tablet lost it). stopPropagation so a click steps instead of closing.
Hidden on narrow screens where there's no room beside the card. */}
<div
className={`relative flex w-full max-h-full ${hasQueue ? "max-w-[63rem] sm:px-14" : "max-w-4xl"}`}
className={`relative flex w-full max-h-full ${hasQueue ? "sm:px-14" : ""}`}
// The design width (63rem with the side strips, else 56rem), narrowed so the 16:9 video plus the
// chrome under it fits the window's height less the backdrop's padding (`p-6` = 3rem).
// Wide: no design cap — the video is as wide as the window's height allows, plus the side panel.
style={{
maxWidth: wide
? `calc((100dvh - 3rem - ${below}px) * 16 / 9 + ${SIDE_PANEL}${hasQueue ? " + 7rem" : ""})`
: `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${
hasQueue ? " + 7rem" : ""
}))`,
}}
>
{hasQueue && (
<Pressable
@@ -598,14 +681,16 @@ export default function PlayerModal({
<div
ref={cardRef}
tabIndex={-1}
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full overflow-y-auto outline-hidden`}
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full outline-hidden ${
wide ? "flex overflow-hidden" : "overflow-y-auto"
}`}
onClick={(e) => e.stopPropagation()}
>
<div
ref={stageRef}
className={`player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden ${
maximized ? "player-stage--max" : ""
}`}
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
} ${maximized ? "player-stage--max" : ""}`}
>
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
through our overlay. */}
@@ -764,234 +849,266 @@ export default function PlayerModal({
)}
</div>
{hasQueue && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<PlayerTransportControls
variant="modal"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
</div>
)}
<div className="p-4 sm:p-5">
{/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */}
<div className="flex items-start gap-3">
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
</h2>
<IconButton
ref={descPop.refs.setReference}
{...descPop.getReferenceProps()}
variant="ghost"
aria-expanded={showDesc}
label={t("player.description")}
>
<Info />
</IconButton>
{navigated && (
<Button
variant="ghost"
size="sm"
onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")}
icon={<ArrowLeft />}
>
{t("player.back")}
</Button>
{/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long
description never makes the card taller than the video. */}
<div
className={wide ? "relative shrink-0 border-l border-border" : undefined}
style={wide ? { width: SIDE_PANEL } : undefined}
>
<div
className={wide ? "absolute inset-0 overflow-y-auto" : undefined}
data-testid="player-info"
// The wheel scrolls this panel while it can (lib/playerModalHooks), the volume elsewhere.
data-wheel-scrolls={wide ? "" : undefined}
>
{hasQueue && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<PlayerTransportControls
variant="modal"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
</div>
)}
<PopoverSurface
popover={descPop}
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
>
<SectionLabel level={3} className="mb-2">
{t("player.description")}
</SectionLabel>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
<div
ref={descFade.ref}
style={descFade.style}
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
>
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div>
) : (
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</PopoverSurface>
<Button
variant="ghost"
size="sm"
data-testid="player-close"
onClick={onClose}
title={t("player.closeEsc")}
>
{t("player.close")}
<X />
</Button>
</div>
{/* Channel + meta on one line, with the watched toggle pushed to the right.
<div className="p-4 sm:p-5">
{/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */}
<div className="flex items-start gap-3">
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
</h2>
{!wide && (
<IconButton
ref={descPop.refs.setReference}
{...descPop.getReferenceProps()}
variant="ghost"
aria-expanded={showDesc}
label={t("player.description")}
>
<Info />
</IconButton>
)}
{navigated && (
<Button
variant="ghost"
size="sm"
onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")}
icon={<ArrowLeft />}
>
{t("player.back")}
</Button>
)}
<PopoverSurface
popover={descPop}
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
>
<SectionLabel level={3} className="mb-2">
{t("player.description")}
</SectionLabel>
{description((body) => (
<div
ref={descFade.ref}
style={descFade.style}
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
>
{body}
</div>
))}
</PopoverSurface>
<Button
variant="ghost"
size="sm"
data-testid="player-close"
onClick={onClose}
title={t("player.closeEsc")}
>
{t("player.close")}
<X />
</Button>
</div>
{/* Channel + meta on one line, with the watched toggle pushed to the right.
When navigated to a linked video we only have its author (from the player),
so we show that as plain text and hide feed-video-specific bits. */}
<div className="flex items-center gap-3 mt-3">
{!navigated && (
<Avatar
src={active.channel_thumbnail}
fallback={active.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0"
/>
)}
{navigated ? (
detail.data?.channel_id ? (
<a
href={channelYouTubeUrl(detail.data.channel_id)}
target="_blank"
rel="noreferrer"
className="font-medium hover:text-accent shrink-0"
>
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
</a>
) : (
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
)
) : (
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
<ChannelNameLink
channelId={active.channel_id}
title={active.channel_title}
onOpenInApp={() => {
// openChannel closes this player first and pushes only once its
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
// and clicking the name merely closes the player
// (lib/history.closeOverlaysThenNavigate).
openChannel(active.channel_id, active.channel_title ?? undefined);
}}
className="font-medium hover:text-accent truncate text-left"
/>
<IconLink
href={active.channel_url}
target="_blank"
rel="noreferrer"
label={t("channels.row.openOnYouTube")}
className="shrink-0"
>
<ExternalLink />
</IconLink>
</div>
)}
{!navigated ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{active.view_count != null && (
<span>
·{" "}
{t("player.views", {
count: active.view_count,
formattedCount: formatCompact(active.view_count, lang),
})}
</span>
<div className={`flex items-center gap-3 mt-3 ${wide ? "flex-wrap" : ""}`}>
{!navigated && (
<Avatar
src={active.channel_thumbnail}
fallback={active.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0"
/>
)}
<span>
· {relativeTime(active.published_at, lang)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
</span>
{active.duration_seconds != null && (
<span>· {formatDuration(active.duration_seconds)}</span>
{navigated ? (
detail.data?.channel_id ? (
<a
href={channelYouTubeUrl(detail.data.channel_id)}
target="_blank"
rel="noreferrer"
className="font-medium hover:text-accent shrink-0"
>
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
</a>
) : (
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
)
) : (
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
<ChannelNameLink
channelId={active.channel_id}
title={active.channel_title}
onOpenInApp={() => {
// openChannel closes this player first and pushes only once its
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
// and clicking the name merely closes the player
// (lib/history.closeOverlaysThenNavigate).
openChannel(active.channel_id, active.channel_title ?? undefined);
}}
className="font-medium hover:text-accent truncate text-left"
/>
<IconLink
href={active.channel_url}
target="_blank"
rel="noreferrer"
label={t("channels.row.openOnYouTube")}
className="shrink-0"
>
<ExternalLink />
</IconLink>
</div>
)}
{active.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("player.stream")}
</span>
{!navigated ? (
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
>
{active.view_count != null && (
<span>
{metaSep}
{t("player.views", {
count: active.view_count,
formattedCount: formatCompact(active.view_count, lang),
})}
</span>
)}
<span>
{metaSep}
{relativeTime(active.published_at, lang)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
</span>
{active.duration_seconds != null && (
<span>
{metaSep}
{formatDuration(active.duration_seconds)}
</span>
)}
{active.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("player.stream")}
</span>
)}
</div>
) : (
// Linked video's stats come from the (already-fetched) video detail.
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
>
{detail.data?.view_count != null && (
<span>
{metaSep}
{t("player.views", {
count: detail.data.view_count,
formattedCount: formatCompact(detail.data.view_count, lang),
})}
</span>
)}
{detail.data?.published_at && (
<span>
{metaSep}
{relativeTime(detail.data.published_at, lang)} ·{" "}
{fullDate(detail.data.published_at)}
</span>
)}
{detail.data?.duration_seconds != null && (
<span>
{metaSep}
{formatDuration(detail.data.duration_seconds)}
</span>
)}
</div>
)}
</div>
) : (
// Linked video's stats come from the (already-fetched) video detail.
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{detail.data?.view_count != null && (
<span>
·{" "}
{t("player.views", {
count: detail.data.view_count,
formattedCount: formatCompact(detail.data.view_count, lang),
})}
</span>
)}
{detail.data?.published_at && (
<span>
· {relativeTime(detail.data.published_at, lang)} ·{" "}
{fullDate(detail.data.published_at)}
</span>
)}
{detail.data?.duration_seconds != null && (
<span>· {formatDuration(detail.data.duration_seconds)}</span>
)}
</div>
)}
{/* Reliable, discoverable path into our big view straight from the windowed player —
{/* Reliable, discoverable path into our big view straight from the windowed player —
the docked bar's controls (incl. our own true-fullscreen ⛶) are off-screen here, and
the only fullscreen button previously reachable in the windowed video was YouTube's
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
the docked bar offers OUR true fullscreen. */}
{!navigated && (
<IconButton
variant="outline"
size="md"
onClick={toggleFullscreen}
label={t("player.fullscreen")}
title={`${t("player.fullscreen")} · F`}
className="ml-auto"
>
<Maximize />
</IconButton>
)}
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
variant="outline"
size="md"
/>
)}
{!navigated && onRemoveFromList && (
<IconButton
variant="outline-danger"
size="md"
onClick={() => void removeFromList()}
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
label={t("playlists.removeItemOf", { title: active.title })}
title={t("playlists.removeItem")}
>
<Trash2 />
</IconButton>
)}
{!navigated && (
<Button
variant="outline"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? t("player.watched") : t("player.markWatched")}
</Button>
)}
{!navigated && (
<IconButton
variant="outline"
size="md"
onClick={toggleFullscreen}
label={t("player.fullscreen")}
title={`${t("player.fullscreen")} · F`}
// Narrow: the actions end the channel line. Wide: their own line in the panel.
className={wide ? undefined : "ml-auto"}
>
<Maximize />
</IconButton>
)}
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
variant="outline"
size="md"
/>
)}
{!navigated && onRemoveFromList && (
<IconButton
variant="outline-danger"
size="md"
onClick={() => void removeFromList()}
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
label={t("playlists.removeItemOf", { title: active.title })}
title={t("playlists.removeItem")}
>
<Trash2 />
</IconButton>
)}
{!navigated && (
<Button
variant="outline"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? t("player.watched") : t("player.markWatched")}
</Button>
)}
</div>
{wide && (
<section className="mt-5" data-testid="player-description">
<SectionLabel level={3} className="mb-2">
{t("player.description")}
</SectionLabel>
{description((body) => (
<div className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
{body}
</div>
))}
</section>
)}
</div>
</div>
</div>
</div>
+4 -1
View File
@@ -63,6 +63,7 @@ import { CheckRow } from "./ui/NativeControls";
import { useReturnFocus } from "./ui/useReturnFocus";
import { MoreButton } from "./ui/MoreButton";
import { HiddenFilterCue } from "./ui/HiddenFilterCue";
import { PageHeading } from "./ui/Headings";
import { useCopyItems } from "./copyItems";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
@@ -875,7 +876,9 @@ export default function Playlists() {
aria-label={t("playlists.rename")}
/>
) : (
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
<PageHeading className="text-lg font-semibold truncate">
{plName(detail)}
</PageHeading>
)}
<div className="ml-auto flex gap-1.5 items-center shrink-0">
{linked && detail.dirty && (
+17 -3
View File
@@ -9,6 +9,7 @@ import {
} from "@tanstack/react-query";
import { User, X } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { mayReadAhead, NEXT_PAGE, nextPageOnClick } from "../lib/nextPage";
import { healPlexField } from "../lib/plexSearch";
import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort";
import type { SortState } from "../lib/columnSort";
@@ -316,18 +317,22 @@ export default function PlexBrowse() {
// mount/unmount, so the observer always watches the live node; a plain ref would keep observing the
// detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages).
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ;
const { isFetching, isFetchingNextPage, isFetchNextPageError, fetchNextPage } = browseQ;
// A failed next page stops the read-ahead (the row at the bottom retries): the observer is rebuilt
// on every settle and would re-request it once a round-trip while the sentinel is in view.
const hasNextPage = browseQ.hasNextPage && !isFetchNextPageError;
useEffect(() => {
if (!sentinelEl) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
if (entries[0]?.isIntersecting && mayReadAhead(hasNextPage, isFetching))
void fetchNextPage(NEXT_PAGE);
},
{ rootMargin: "600px" },
);
io.observe(sentinelEl);
return () => io.disconnect();
}, [sentinelEl, hasNextPage, isFetchingNextPage, fetchNextPage]);
}, [sentinelEl, hasNextPage, isFetching, fetchNextPage]);
function onCard(card: PlexCard) {
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
@@ -751,6 +756,15 @@ export default function PlexBrowse() {
{isFetchingNextPage && (
<p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
)}
{isFetchNextPageError && !isFetchingNextPage && (
<StateMessage
tone="error"
compact
onRetry={() => void fetchNextPage(nextPageOnClick(isFetchingNextPage))}
>
{t("common.loadMoreFailed")}
</StateMessage>
)}
</div>
);
}
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { vocab } from "../lib/vocab";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react";
import { qk } from "../lib/queryKeys";
@@ -262,7 +263,7 @@ export default function PlexCollectionView({
const meta = [
d?.year_min != null &&
(d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`),
d?.content_rating,
d?.content_rating && vocab(t, "certificate", d.content_rating),
d && t("plex.browse.items", { count: d.child_count ?? items.length }),
formatRuntime(d?.total_runtime_s, lang),
].filter(Boolean) as string[];
@@ -285,6 +286,9 @@ export default function PlexCollectionView({
posterDisabled={!canPlay || queueBusy}
showPlayOverlay={canPlay}
onPosterClick={() => canPlay && !queueBusy && playAll(false)}
// Sized to the text beside it (E31, the owner's pick): a title, one meta line and a short
// description sat beside a show-sized poster, two thirds of the hero empty under them.
posterSizeClass="w-20 sm:w-24"
customizeMenu={null}
>
{renaming ? (
+97 -25
View File
@@ -15,13 +15,19 @@ vi.mock("react-i18next", async (importOriginal) => {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
// A certificate code ("R") is no catalog word: it falls back to itself, as in the real catalog.
t: (key: string, opts?: Record<string, unknown>) =>
opts?.name ? `${key}:${opts.name}` : key,
opts?.name
? `${key}:${opts.name}`
: key.startsWith("vocab.certificate.") && typeof opts?.defaultValue === "string"
? opts.defaultValue
: key,
}),
};
});
import PlexInfo from "./PlexInfo";
import { CREW_SHOWN } from "./plexDetailAside";
import type { PlexItemDetail } from "../lib/api";
afterEach(cleanup);
@@ -169,8 +175,11 @@ describe("PlexInfo enriched detail (page)", () => {
// E28 S3h, the owner's layout B: a main column (collection strips → crew → cast) beside a sidebar of
// facts (details, keywords); a column with nothing in it is not rendered and the other takes the width.
describe("PlexInfo layout B (page)", () => {
const TWO_COLUMNS = "lg:grid-cols-[minmax(0,1fr)_20rem]";
// E31 (the owner's pick "A"): the hero's text stopped at ~55% of the panel. The facts and the key crew are
// its second column now (a grid that splits in a wide hero), the keywords sit under the text, and under
// the hero only the collection strips and the cast remain, stacked.
describe("PlexInfo — the hero's second column (page)", () => {
const TWO_COLUMNS = "@5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]";
// The strips are Carousels, which watch their size.
beforeAll(() => {
class Stub {
@@ -181,7 +190,8 @@ describe("PlexInfo layout B (page)", () => {
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
const heading = (name: string) => screen.getByRole("heading", { name });
const column = (el: HTMLElement) => el.closest("div.flex-col") as HTMLElement;
const follows = (a: HTMLElement, b: HTMLElement) =>
!!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
const full: PlexItemDetail = {
...detail,
cast: [{ name: "An Actor", role: "Hero" }],
@@ -195,38 +205,100 @@ describe("PlexInfo layout B (page)", () => {
],
};
it("stacks the collection, the crew and the cast in the main column, the facts beside it", () => {
it("puts the facts over the crew beside the text, the keywords under it, the rest below the hero", () => {
wrap(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
const aside = screen.getByTestId("detail-aside");
expect(aside.contains(heading("plex.info.facts.title"))).toBe(true);
expect(aside.contains(heading("plex.info.crew"))).toBe(true);
expect(follows(heading("plex.info.facts.title"), heading("plex.info.crew"))).toBe(true);
// The grid's two children: the poster + text, and the column.
const grid = aside.parentElement!;
expect(grid.className).toContain(TWO_COLUMNS);
const main = grid.children[0]!;
const side = grid.children[1];
expect(side).toBe(aside);
expect(main.contains(screen.getByText("A summary."))).toBe(true);
expect(main.contains(screen.getByTestId("detail-keywords"))).toBe(true);
// Below the hero (the panel under the title): the collection, then the cast — and neither in it.
const hero = grid.parentElement!;
expect(hero.contains(heading("Test Movie"))).toBe(true);
const coll = heading("The Collection");
const crew = heading("plex.info.crew");
const cast = heading("plex.info.cast");
const facts = heading("plex.info.facts.title");
const follows = (a: HTMLElement, b: HTMLElement) =>
!!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
expect(follows(coll, crew) && follows(crew, cast)).toBe(true);
const main = column(coll);
expect(main.contains(crew) && main.contains(cast)).toBe(true);
const side = column(facts);
expect(side).not.toBe(main);
expect(side).toBe(column(heading("plex.info.keywords")));
// the two columns are the two children of the one grid
expect(main.parentElement!.className).toContain(TWO_COLUMNS);
expect([...main.parentElement!.children]).toEqual([main, side]);
expect(hero.contains(coll) || hero.contains(cast)).toBe(false);
expect(follows(hero, coll) && follows(coll, cast)).toBe(true);
});
it("with nothing for the main column, the sidebar's sections take the full width", () => {
it("without facts or crew there is no second column; the keywords still sit under the text", () => {
const bare: PlexItemDetail = {
...detail,
year: null,
duration_seconds: null,
content_rating: null,
studio: null,
tmdb: {
...detail.tmdb!,
budget: null,
revenue: null,
studios: [],
production_countries: [],
crew: [],
},
};
wrap(<PlexInfo onPlayItem={noop} detail={bare} variant="page" />);
expect(screen.queryByTestId("detail-aside")).toBeNull();
expect(screen.queryByRole("heading", { name: "plex.info.facts.title" })).toBeNull();
const kw = screen.getByTestId("detail-keywords");
expect(document.querySelector(`[class*="${TWO_COLUMNS}"]`)).toBeNull();
expect(follows(heading("Test Movie"), kw)).toBe(true);
});
// E31: the hero's grid row is as tall as its taller column — the crew, beside the text — and the poster,
// a flex child, stretched to it: a 2:3 picture drawn ~175×590 and cropped. It keeps its own ratio.
it("keeps the poster at its own height, not stretched to the row", () => {
wrap(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
const poster = document.querySelector<HTMLImageElement>(`img[src="${detail.thumb}"]`)!;
expect(poster.className).toContain("aspect-2/3");
expect(poster.className.split(/\s+/)).toContain("self-start");
});
// E31 (the owner's call): twelve names ran the column ~170px past the text. The first CREW_SHOWN show,
// the rest behind "Show N more"; a list barely longer shows whole.
it("shows the first crew names and opens the rest on demand", () => {
const crew = Array.from({ length: 12 }, (_, i) => ({ name: `Person ${i}`, job: "Producer" }));
wrap(
<PlexInfo
onPlayItem={noop}
detail={{ ...detail, tmdb: { ...detail.tmdb!, crew: [] } }}
detail={{ ...full, tmdb: { ...full.tmdb!, crew } }}
variant="page"
/>,
);
expect(screen.queryByRole("heading", { name: "plex.info.crew" })).toBeNull();
const side = column(heading("plex.info.facts.title"));
expect(side).toBe(column(heading("plex.info.keywords")));
expect(side.parentElement!.className).not.toContain(TWO_COLUMNS);
expect([...side.parentElement!.children]).toEqual([side]);
const aside = screen.getByTestId("detail-aside");
expect(aside.textContent).toContain(`Person ${CREW_SHOWN - 1}`);
expect(aside.textContent).not.toContain(`Person ${CREW_SHOWN}`);
const more = screen.getByRole("button", { name: "plex.info.crewMore" });
expect(more.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(more);
expect(aside.textContent).toContain("Person 11");
const less = screen.getByRole("button", { name: "plex.info.crewLess" });
expect(less.getAttribute("aria-expanded")).toBe("true");
fireEvent.click(less);
expect(aside.textContent).not.toContain(`Person ${CREW_SHOWN}`);
});
it("shows a crew only a little longer than that whole, with no toggle", () => {
const crew = Array.from({ length: CREW_SHOWN + 2 }, (_, i) => ({
name: `Person ${i}`,
job: "Producer",
}));
wrap(
<PlexInfo
onPlayItem={noop}
detail={{ ...full, tmdb: { ...full.tmdb!, crew } }}
variant="page"
/>,
);
expect(screen.getByTestId("detail-aside").textContent).toContain(`Person ${CREW_SHOWN + 1}`);
expect(screen.queryByRole("button", { name: "plex.info.crewMore" })).toBeNull();
});
});
+186 -390
View File
@@ -1,14 +1,20 @@
import { useState, type ReactNode } from "react";
import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatCompact, formatDecimal, formatMoney, formatRuntime } from "../lib/format";
import { formatCompact, formatDecimal, formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail";
import { usePref } from "../lib/usePref";
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
import {
DETAIL_POSTER,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailAside,
useDetailPrefs,
} from "../lib/plexDetail";
import { Carousel } from "./ui/Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
@@ -21,13 +27,12 @@ import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge";
import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings";
import clsx from "clsx";
import { PlexPosterCard } from "./plexCards";
import { PlexMenu } from "./PlexMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { usePlexCardActions } from "./usePlexCardActions";
import { toggleWatchState } from "../lib/plexWatch";
import { regionName, vocab } from "../lib/vocab";
import { vocab } from "../lib/vocab";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -143,174 +148,6 @@ function RatingsRow({
);
}
// A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries,
// pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something.
// What filters the library is an edged chip, a plain fact stays flat text (E28 S3h, the S3d1 rule).
function FactsPanel({
detail,
onFilter,
}: {
detail: PlexItemDetail;
onFilter?: (patch: Partial<PlexFilters>) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const tmdb = detail.tmdb;
const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : [];
const rows: { key: string; label: string; node: ReactNode }[] = [];
// The same values filter here as in the header above (year, certificate, studios).
const year = detail.year;
if (year != null)
rows.push({
key: "year",
label: t("plex.info.facts.release"),
node: (
<Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
>
{year}
</Filterable>
),
});
if (detail.duration_seconds)
rows.push({
key: "runtime",
label: t("plex.info.facts.runtime"),
node: formatRuntime(detail.duration_seconds, lang),
});
const cert = detail.content_rating;
if (cert)
rows.push({
key: "cert",
label: t("plex.info.facts.cert"),
node: (
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
{cert}
</Filterable>
),
});
if (tmdb?.budget)
rows.push({
key: "budget",
label: t("plex.info.facts.budget"),
node: formatMoney(tmdb.budget, lang),
});
if (tmdb?.revenue)
rows.push({
key: "revenue",
label: t("plex.info.facts.revenue"),
node: formatMoney(tmdb.revenue, lang),
});
if (studios.length)
rows.push({
key: "studios",
label: t("plex.info.facts.studios"),
node: (
<span className="flex flex-wrap gap-1.5">
{studios.map((s) => (
<Filterable
key={s}
tag
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
>
{s}
</Filterable>
))}
</span>
),
});
if (tmdb?.production_countries?.length)
rows.push({
key: "countries",
label: t("plex.info.facts.countries"),
node: tmdb.production_countries
.map((c) => regionName(t, c.code, i18n.language, c.name))
.join(", "),
});
if (rows.length === 0) return null;
return (
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
<dl className="grid grid-cols-[auto_1fr] items-baseline gap-x-4 gap-y-2 text-sm">
{rows.map((r) => (
<div key={r.key} className="contents">
<dt className="text-muted">{r.label}</dt>
<dd className="min-w-0 font-medium">{r.node}</dd>
</div>
))}
</dl>
</div>
);
}
// TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword (via applyFilter).
function KeywordsPanel({
keywords,
onFilter,
}: {
keywords: string[];
onFilter?: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
if (keywords.length === 0) return null;
return (
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
<div className="flex flex-wrap gap-1.5">
{keywords.map((k) => (
<Filterable
key={k}
tag
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
>
{k}
</Filterable>
))}
</div>
</div>
);
}
// Full crew (E5 S5) — the TMDB key-crew (Director / Writer / Producer / …), each clickable to open
// their person page. Complements the hero's Director line (which comes from Plex directly).
function CrewPanel({
crew,
onOpenPerson,
}: {
crew: { name: string; job: string }[];
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
if (crew.length === 0) return null;
return (
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
{/* In the main column (layout B): as many name columns as its width holds. */}
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-x-3 gap-y-1.5">
{crew.map((c, i) => (
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? (
<Pressable
selectable
onClick={() => onOpenPerson(c.name)}
title={t("plex.info.viewPerson", { name: c.name })}
className="max-w-full truncate text-sm leading-6 font-medium hover:text-accent pointer-coarse:leading-(--touch-target)"
>
{c.name}
</Pressable>
) : (
<div className="truncate text-sm font-medium">{c.name}</div>
)}
<div className="truncate text-2xs text-muted">{vocab(t, "crewJob", c.job)}</div>
</div>
))}
</div>
</div>
);
}
// The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when
// known. Class + colour are derived through the SAME shared helper the browse-card corner dot uses,
// so the two surfaces can't drift. `overlay` tints the label for the dark player background.
@@ -372,10 +209,8 @@ export default function PlexInfo({
// is PlexInfo-specific state, persisted through the same savePref.
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
useDetailPrefs();
// The three TMDB-enriched side panels are each independently hideable (default ON), like the cast row.
const [showFacts, setShowFacts] = usePref<boolean>("plexInfoFacts", true);
const [showKeywords, setShowKeywords] = usePref<boolean>("plexInfoKeywords", true);
const [showCrew, setShowCrew] = usePref<boolean>("plexInfoCrew", true);
// The TMDB facts / keywords / crew: the hero's second column, each section a per-user toggle.
const aside = useDetailAside(detail);
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
{}) as { plexHiddenStripSources?: string[] };
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
@@ -462,20 +297,7 @@ export default function PlexInfo({
const sourceLabel = (src: string) =>
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
// Whether each enriched side panel has anything to show — drives both its render and its toggle row
// (no toggle for a panel that would be empty anyway).
const tmdb = detail.tmdb;
const hasFacts =
detail.year != null ||
!!detail.duration_seconds ||
!!detail.content_rating ||
!!tmdb?.budget ||
!!tmdb?.revenue ||
!!tmdb?.studios?.length ||
!!detail.studio ||
!!tmdb?.production_countries?.length;
const hasKeywords = (tmdb?.keywords?.length ?? 0) > 0;
const hasCrew = (tmdb?.crew?.length ?? 0) > 0;
const asideShown = !overlay && aside.column;
const infoTitle =
detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title;
@@ -483,7 +305,7 @@ export default function PlexInfo({
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
<div className={overlay ? "p-5" : "flex flex-col gap-3 p-3 sm:p-4"}>
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
<div className={overlay ? "" : `${SURFACE.panel} p-4 sm:p-6`}>
<div className={overlay ? "" : `${SURFACE.panel} @container p-4 sm:p-6`}>
{/* Header: close (overlay) / customize */}
<div className="mb-4 flex items-start gap-3">
<div className="min-w-0 flex-1">
@@ -510,27 +332,7 @@ export default function PlexInfo({
hasCast={detail.cast.length > 0}
extra={
<>
{hasFacts && (
<PrefToggle
label={t("plex.info.prefFacts")}
on={showFacts}
onClick={() => setShowFacts(!showFacts)}
/>
)}
{hasKeywords && (
<PrefToggle
label={t("plex.info.prefKeywords")}
on={showKeywords}
onClick={() => setShowKeywords(!showKeywords)}
/>
)}
{hasCrew && (
<PrefToggle
label={t("plex.info.prefCrew")}
on={showCrew}
onClick={() => setShowCrew(!showCrew)}
/>
)}
{!overlay && <DetailAsideToggles aside={aside} />}
{presentSources.map((src) => (
<PrefToggle
key={src}
@@ -554,171 +356,192 @@ export default function PlexInfo({
)}
</div>
<div className="flex gap-4 sm:gap-6">
{/* Poster */}
<img
src={detail.thumb}
alt=""
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-2/3`}
/>
{/* The poster + text, and beside them (page variant, a wide hero) the facts / keywords / crew. */}
<div
className={
asideShown ? "grid gap-6 @5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]" : undefined
}
>
<div className="flex gap-4 sm:gap-6">
{/* Poster */}
<img
src={detail.thumb}
alt=""
className={`shrink-0 self-start rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : DETAIL_POSTER} aspect-2/3`}
/>
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{detail.year != null && (
<Filterable
tag
onDark={overlay}
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
onClick={
onFilter
? () => onFilter({ yearMin: detail.year, yearMax: detail.year })
: undefined
}
>
{detail.year}
</Filterable>
)}
{detail.content_rating && (
<Filterable
tag
onDark={overlay}
onClick={
onFilter
? () => onFilter({ contentRatings: [detail.content_rating!] })
: undefined
}
>
{detail.content_rating}
</Filterable>
)}
{detail.duration_seconds ? (
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
) : null}
{detail.studio && (
<Filterable
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
>
{detail.studio}
</Filterable>
)}
<PlaybackChip detail={detail} overlay={overlay} />
</div>
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
<div className="mt-2">
<RatingsRow
imdbRating={detail.imdb_rating}
imdbUrl={detail.imdb_url}
audience={detail.rating}
tmdbRating={detail.tmdb?.rating}
tmdbVotes={detail.tmdb?.vote_count}
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
onDark={overlay}
/>
</div>
{detail.tagline && (
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
{detail.tagline}
</p>
)}
{detail.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{detail.genres.map((g) => (
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{detail.year != null && (
<Filterable
key={g}
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
title={
onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined
}
onClick={
onFilter
? () => onFilter({ yearMin: detail.year, yearMax: detail.year })
: undefined
}
>
{g}
{detail.year}
</Filterable>
))}
</div>
)}
{detail.directors.length > 0 && (
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
<span>{t("plex.info.director")}:</span>
{detail.directors.map((d) => (
)}
{detail.content_rating && (
<Filterable
key={d}
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
onClick={
onFilter
? () => onFilter({ contentRatings: [detail.content_rating!] })
: undefined
}
>
{d}
{vocab(t, "certificate", detail.content_rating)}
</Filterable>
))}
</p>
)}
{detail.summary && (
<p
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
>
{detail.summary}
</p>
)}
{/* Page actions: Play/Resume + watch controls */}
{!overlay && (
<div className="mt-4 flex flex-wrap items-center gap-2">
{onPlay && (
<Button onClick={onPlay} icon={<Play className="fill-current" />}>
{inProgress ? t("plex.resume") : t("plex.play")}
</Button>
)}
{detail.status === "watched" ? (
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
{t("plex.info.markUnwatched")}
</Button>
) : (
<Button variant="glass" onClick={() => setState("watched")} icon={<Check />}>
{t("plex.info.markWatched")}
</Button>
)}
{inProgress && (
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
{t("plex.info.clearResume")}
</Button>
)}
{detail.kind === "movie" && (
<Button
variant="glass"
onClick={() => setPlaylistOpen(true)}
icon={<ListPlus />}
{detail.duration_seconds ? (
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
) : null}
{detail.studio && (
<Filterable
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
>
{t("plex.playlistAdd.manage")}
</Button>
)}
{isAdmin && detail.kind === "movie" && library && (
<Button
variant="glass"
onClick={() => setEditorOpen(true)}
icon={<FolderPlus />}
>
{t("plex.collEditor.manage")}
</Button>
{detail.studio}
</Filterable>
)}
<PlaybackChip detail={detail} overlay={overlay} />
</div>
)}
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
<div className="mt-2">
<RatingsRow
imdbRating={detail.imdb_rating}
imdbUrl={detail.imdb_url}
audience={detail.rating}
tmdbRating={detail.tmdb?.rating}
tmdbVotes={detail.tmdb?.vote_count}
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
onDark={overlay}
/>
</div>
{detail.tagline && (
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
{detail.tagline}
</p>
)}
{detail.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{detail.genres.map((g) => (
<Filterable
key={g}
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
>
{g}
</Filterable>
))}
</div>
)}
{detail.directors.length > 0 && (
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
<span>{t("plex.info.director")}:</span>
{detail.directors.map((d) => (
<Filterable
key={d}
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
>
{d}
</Filterable>
))}
</p>
)}
{detail.summary && (
<p
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
>
{detail.summary}
</p>
)}
{/* Page actions: Play/Resume + watch controls */}
{!overlay && (
<div className="mt-4 flex flex-wrap items-center gap-2">
{onPlay && (
<Button onClick={onPlay} icon={<Play className="fill-current" />}>
{inProgress ? t("plex.resume") : t("plex.play")}
</Button>
)}
{detail.status === "watched" ? (
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
{t("plex.info.markUnwatched")}
</Button>
) : (
<Button variant="glass" onClick={() => setState("watched")} icon={<Check />}>
{t("plex.info.markWatched")}
</Button>
)}
{inProgress && (
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
{t("plex.info.clearResume")}
</Button>
)}
{detail.kind === "movie" && (
<Button
variant="glass"
onClick={() => setPlaylistOpen(true)}
icon={<ListPlus />}
>
{t("plex.playlistAdd.manage")}
</Button>
)}
{isAdmin && detail.kind === "movie" && library && (
<Button
variant="glass"
onClick={() => setEditorOpen(true)}
icon={<FolderPlus />}
>
{t("plex.collEditor.manage")}
</Button>
)}
</div>
)}
{!overlay && aside.shown.keywords && (
<DetailKeywords
keywords={detail.tmdb?.keywords ?? []}
onFilter={onFilter}
className="mt-5"
/>
)}
</div>
</div>
{asideShown && (
<DetailAside
facts={detail}
shown={aside.shown}
onFilter={onFilter}
onOpenPerson={onOpenPerson}
/>
)}
</div>
</div>
{/* End hero panel. */}
{/* Below the hero (E28 S3h, the owner's layout B, 2026-09-26): a main column — the collection
strips first (the owner's most used), then the crew, then the cast — beside a narrow
sidebar of facts (details, keywords), the TMDB/IMDb shape. Every section is per-user
toggleable, so the layout is built from what is ON: a column with nothing in it is not
rendered and the other takes the full width; inside a column the sections stack, so a
hidden one leaves no hole. Narrow windows stack the sidebar under the main column. The
overlay (over the running player) stays lean: its strips and its cast, stacked. */}
{/* Below the hero: the collection strips first (the owner's most used), then the cast, each
per-user toggleable, stacked so a hidden one leaves no hole. The facts / keywords / crew that
sat beside them (E28 S3h) moved up into the hero's second column (E31). The overlay (over
the running player) is the same stack, lean. */}
{(() => {
const collections = (detail.collections ?? []).filter(
(col) => !hiddenSources.includes(col.source),
@@ -845,38 +668,11 @@ export default function PlexInfo({
{castRow}
</>
);
const crewBox = showCrew && hasCrew && (
<CrewPanel crew={tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />
);
const factsBox = showFacts && hasFacts && (
<FactsPanel detail={detail} onFilter={onFilter} />
);
const keywordsBox = showKeywords && hasKeywords && (
<KeywordsPanel keywords={tmdb?.keywords ?? []} onFilter={onFilter} />
);
const hasMain = strips.length > 0 || !!crewBox || !!castRow;
const hasSide = !!factsBox || !!keywordsBox;
if (!hasMain && !hasSide) return null;
if (strips.length === 0 && !castRow) return null;
return (
<div
className={clsx(
"grid items-start gap-3",
hasMain && hasSide && "lg:grid-cols-[minmax(0,1fr)_20rem]",
)}
>
{hasMain && (
<div className="flex min-w-0 flex-col gap-3">
{strips}
{crewBox}
{castRow}
</div>
)}
{hasSide && (
<div className="flex min-w-0 flex-col gap-3">
{factsBox}
{keywordsBox}
</div>
)}
<div className="flex min-w-0 flex-col gap-3">
{strips}
{castRow}
</div>
);
})()}
@@ -5,6 +5,7 @@ import { qk } from "../lib/queryKeys";
import { api, type PlexCard } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { LoadingState } from "./ui/QueryState";
import { cardImage } from "../lib/plexImage";
// 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
@@ -49,7 +50,7 @@ export default function PlexLibraryPicker({
{results.map((c) => (
<li key={c.id} className="flex items-center gap-3">
<img
src={c.thumb}
{...cardImage(c.thumb)}
alt=""
className="h-12 w-8 shrink-0 rounded object-cover"
loading="lazy"
+9 -3
View File
@@ -55,6 +55,7 @@ import { SURFACE } from "./ui/surfaces";
import { PageHeading } from "./ui/Headings";
import { MEDIA_BADGE } from "./ui/mediaCardStyles";
import { seasonTitle } from "../lib/plexDetail";
import { cardImage } from "../lib/plexImage";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
@@ -157,7 +158,7 @@ function Poster({ src, className }: { src: string; className: string }) {
<div
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${className}`}
>
<img src={src} alt="" loading="lazy" className="h-full w-full object-cover" />
<img {...cardImage(src)} alt="" loading="lazy" className="h-full w-full object-cover" />
</div>
);
}
@@ -200,7 +201,12 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
<div
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
>
<img src={ep.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
<img
{...cardImage(ep.thumb)}
alt=""
loading="lazy"
className="h-full w-full object-cover"
/>
{ep.status === "watched" && <span className={`${MEDIA_BADGE} right-0.5 top-0.5`}>✓</span>}
{inProgress && <div className="absolute inset-x-0 bottom-0 h-0.5 bg-accent" />}
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/thumb:opacity-100">
@@ -319,7 +325,7 @@ function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) {
{block.items.slice(0, 3).map((ep, i) => (
<img
key={ep.id}
src={ep.thumb}
{...cardImage(ep.thumb)}
alt=""
loading="lazy"
className="aspect-2/3 w-8 rounded-sm border-2 border-surface object-cover"
+2 -1
View File
@@ -12,6 +12,7 @@ import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/pl
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { ROVING_HIGHLIGHT } from "./ui/AnchoredPopover";
import { cardImage } from "../lib/plexImage";
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
@@ -160,7 +161,7 @@ export default function PlexSearchBox() {
className={`flex w-full items-center gap-3 rounded-lg p-1.5 transition ${ROVING_HIGHLIGHT}`}
>
<img
src={c.thumb}
{...cardImage(c.thumb)}
alt=""
loading="lazy"
className="h-12 w-8 shrink-0 rounded-md object-cover bg-border/40"
+5 -1
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles";
import { useTranslation } from "react-i18next";
import { vocab } from "../lib/vocab";
import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
@@ -247,7 +248,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
body: (
<Combobox
label={t("plex.filter.contentRating")}
options={facets.content_ratings}
options={facets.content_ratings.map((o) => ({
...o,
label: vocab(t, "certificate", o.value),
}))}
selected={filters.contentRatings}
onChange={(next) => patch({ contentRatings: next })}
/>
+2 -1
View File
@@ -3,6 +3,7 @@ import { hueOf, hueTileGradient } from "../lib/hueTile";
import { navItemAttrs } from "../lib/listNav";
import { MEDIA_IMG } from "./ui/mediaCardStyles";
import { Pressable } from "./ui/Pressable";
import { cardImage } from "../lib/plexImage";
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
@@ -50,7 +51,7 @@ export default function PosterTile({
posters[i] ? (
<img
key={i}
src={`/api/plex/image/${posters[i]}`}
{...cardImage(`/api/plex/image/${posters[i]}`)}
alt=""
loading="lazy"
className={MEDIA_IMG}
+5 -2
View File
@@ -35,7 +35,7 @@ import { useContextMenu } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { jobResultText } from "../lib/jobResult";
import { jobFailureText, jobResultText } from "../lib/jobResult";
const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
@@ -257,7 +257,10 @@ function JobRow({
? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang) })
: t("scheduler.neverRun")}
{job.last_error ? (
<span className="text-danger"> · {job.last_error}</span>
<span className="text-danger">
{" · "}
{jobFailureText(t, job.result, lang) ?? job.last_error}
</span>
) : job.result ? (
<span> · {jobResultText(t, job.result, lang)}</span>
) : job.last_result ? (
+2 -2
View File
@@ -10,7 +10,7 @@ import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api";
import { displayName, roleKey } from "../lib/api/account";
import { notifyActionError } from "../lib/actionErrors";
import { LS, useAccountPersistedState } from "../lib/storage";
import { LS, useAccountPersistedState, useTabPersistedState } from "../lib/storage";
import Avatar from "./ui/Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
@@ -68,7 +68,7 @@ export default function SettingsPanel() {
const prefs = usePrefs();
// "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button).
const { openWizard } = useWizard();
const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance");
const [tabRaw, setTab] = useTabPersistedState(LS.settingsTab, "appearance");
const panel = useTabPanel();
// Clamp at render so a stale/removed tab id falls back to Appearance.
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
+5 -6
View File
@@ -14,9 +14,10 @@ import FilterRail from "./FilterRail";
import { type PanelItem } from "./ui/PanelGroups";
import { type MediaSourceKind } from "../lib/mediaSource";
import ChannelPicker from "./ChannelPicker";
import { facetOption } from "./facetOption";
import DateFilterSelect from "./DateFilterSelect";
import YearFilter from "./YearFilter";
import Combobox, { type ComboboxOption } from "./ui/Combobox";
import Combobox from "./ui/Combobox";
import MatchToggle from "./ui/MatchToggle";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useChannelsActions } from "./channelsContext";
@@ -72,6 +73,8 @@ export default function Sidebar({
});
const facetsReady = !!summaryQuery.data;
const facetCounts = summaryQuery.data?.facets ?? {};
// Ordering falls back to the tag's whole-library count until the facets land; the number shown does
// not (facetOption).
const chipCount = (tag: Tag): number =>
facetsReady ? (facetCounts[String(tag.id)] ?? 0) : tag.channel_count;
const visibleChips = (list: Tag[]): Tag[] => {
@@ -93,11 +96,7 @@ export default function Sidebar({
// The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns
// only its own slice: its selection replaces the ids that belong to its list, leaving the others.
const tagOption = (tg: Tag): ComboboxOption => ({
value: String(tg.id),
label: tg.name,
count: chipCount(tg),
});
const tagOption = (tg: Tag) => facetOption(tg, summaryQuery.data?.facets);
const selectedIn = (list: Tag[]): string[] =>
filters.tags.filter((id) => list.some((tg) => tg.id === id)).map(String);
function setTagSelection(list: Tag[], next: string[]) {
+65 -49
View File
@@ -8,14 +8,16 @@ import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/fo
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { useWizard } from "./wizardContext";
import { LS, useAccountPersistedState } from "../lib/storage";
import { LS, useTabPersistedState } from "../lib/storage";
import Tooltip from "./ui/Tooltip";
import { LoadingState, StateMessage } from "./ui/QueryState";
import FilterChipGroup from "./ui/FilterChipGroup";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { PageToolbar } from "./ui/PageShell";
import { Section, SettingRow } from "./ui/form";
import { Section } from "./ui/form";
import { SectionLabel } from "./ui/Headings";
import { StatBars, StatTile, StatTiles } from "./ui/StatTiles";
import { useMe } from "../lib/useMe";
import { notifyActionError } from "../lib/actionErrors";
import { Button } from "./ui/Button";
@@ -32,7 +34,7 @@ export default function Stats() {
const me = useMe();
const { t } = useTranslation();
const isAdmin = me.role === "admin";
const [tabRaw, setTab] = useAccountPersistedState(LS.statsTab, "overview");
const [tabRaw, setTab] = useTabPersistedState(LS.statsTab, "overview");
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
const panel = useTabPanel();
@@ -108,34 +110,40 @@ function Overview({ me }: { me: Me }) {
<>
<Section title={t("stats.sync.myStatus")}>
{s ? (
<div className="space-y-1.5 text-sm">
<SettingRow label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
{s.channels_total}
</SettingRow>
<SettingRow
<StatTiles>
<StatTile
label={t("stats.sync.channels")}
hint={t("stats.sync.channelsHint")}
value={formatNumber(s.channels_total, lang)}
/>
<StatTile
label={t("stats.sync.recentSynced")}
hint={t("stats.sync.recentSyncedHint")}
>
{`${s.channels_recent_synced}/${s.channels_total}`}
</SettingRow>
<SettingRow label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
</SettingRow>
value={`${formatNumber(s.channels_recent_synced, lang)}/${formatNumber(s.channels_total, lang)}`}
/>
<StatTile
label={t("stats.sync.fullHistory")}
hint={t("stats.sync.fullHistoryHint")}
value={`${formatNumber(s.channels_deep_done, lang)}/${formatNumber(s.channels_deep_requested, lang)}`}
/>
{s.deep_pending_count > 0 && (
<SettingRow
<StatTile
label={t("stats.sync.fullHistoryEta")}
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
>
{formatEta(t, s.deep_eta_seconds)}
</SettingRow>
value={formatEta(t, s.deep_eta_seconds)}
/>
)}
<SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
{formatNumber(s.my_videos, lang)}
</SettingRow>
<SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
{formatNumber(s.quota_remaining_today, lang)}
</SettingRow>
</div>
<StatTile
label={t("stats.sync.myVideos")}
hint={t("stats.sync.myVideosHint")}
value={formatNumber(s.my_videos, lang)}
/>
<StatTile
label={t("stats.sync.quotaLeft")}
hint={t("stats.sync.quotaLeftHint")}
value={formatNumber(s.quota_remaining_today, lang)}
/>
</StatTiles>
) : status.isError ? (
<StateMessage tone="error" onRetry={() => status.refetch()}>
{t("common.loadError")}
@@ -148,31 +156,39 @@ function Overview({ me }: { me: Me }) {
<Section title={t("stats.sync.apiUsage")}>
{usage.data ? (
<>
<div className="space-y-1.5 text-sm">
<SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
{formatNumber(usage.data.today, lang)}
</SettingRow>
<SettingRow label={t("stats.sync.last7d")}>
{formatNumber(usage.data.last_7d, lang)}
</SettingRow>
<SettingRow label={t("stats.sync.last30d")}>
{formatNumber(usage.data.last_30d, lang)}
</SettingRow>
<SettingRow label={t("stats.sync.allTime")}>
{formatNumber(usage.data.all_time, lang)}
</SettingRow>
</div>
<StatTiles>
<StatTile
label={t("stats.sync.today")}
hint={t("stats.sync.todayHint")}
value={formatNumber(usage.data.today, lang)}
/>
<StatTile
label={t("stats.sync.last7d")}
value={formatNumber(usage.data.last_7d, lang)}
/>
<StatTile
label={t("stats.sync.last30d")}
value={formatNumber(usage.data.last_30d, lang)}
/>
<StatTile
label={t("stats.sync.allTime")}
value={formatNumber(usage.data.all_time, lang)}
/>
</StatTiles>
{Object.keys(usage.data.by_action).length > 0 && (
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
<div className="uppercase tracking-wide">{t("stats.sync.byAction")}</div>
{Object.entries(usage.data.by_action)
.sort((a, b) => b[1] - a[1])
.map(([action, units]) => (
<div key={action} className="flex justify-between gap-2">
<span>{quotaActionLabel(t, action)}</span>
<span className="tabular-nums">{formatNumber(units, lang)}</span>
</div>
))}
<div className="mt-4">
<SectionLabel level={3} className="mb-2">
{t("stats.sync.byAction")}
</SectionLabel>
<StatBars
label={t("stats.sync.byAction")}
format={(n) => formatNumber(n, lang)}
rows={Object.entries(usage.data.by_action).map(([action, units]) => ({
id: action,
label: quotaActionLabel(t, action),
value: units,
}))}
/>
</div>
)}
</>
+3 -1
View File
@@ -5,6 +5,7 @@ import { Play, X } from "lucide-react";
import { api } from "../lib/api";
import { plexLang } from "../i18n";
import { qk } from "../lib/queryKeys";
import { cardImage } from "../lib/plexImage";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
@@ -72,8 +73,9 @@ export function UpNextCard({
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
{next && (
<img
src={api.plexImageUrl(nextId, "thumb")}
{...cardImage(api.plexImageUrl(nextId, "thumb"))}
alt=""
loading="lazy"
className="h-full w-full object-cover"
draggable={false}
/>
+2 -1
View File
@@ -35,6 +35,7 @@ import {
MEDIA_BADGE,
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_PICTURE_CONTAINER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaTitleClass,
@@ -239,7 +240,7 @@ function Thumb({
// min-h-0: the caller's aspect box must hold its ratio before the image arrives — an aspect-ratio
// box otherwise grows to its content, and an off-screen, not-yet-loaded thumbnail measured 338px
// tall, which stretched a whole Home shelf to it (ui/mediaCardStyles `mediaPictureBox`).
<div className={clsx("relative min-h-0", className)}>
<div className={clsx("relative min-h-0", MEDIA_PICTURE_CONTAINER, className)}>
{/* Named by the title: its only text is the duration badge, so a screen reader heard the first
Tab stop of every card as "44:10" (E28 S3d4). */}
<a
+5 -4
View File
@@ -1,5 +1,6 @@
import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import type { NEXT_PAGE } from "../lib/nextPage";
import VideoCard from "./VideoCard";
import { VideoMenu } from "./VideoMenu";
import { useContextMenu } from "./ui/useContextMenu";
@@ -28,8 +29,8 @@ export interface VirtualFeedProps {
onResetState: (id: string) => void;
onOpen: (v: Video, startAt?: number | null) => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
isFetching: boolean;
fetchNextPage: (options: typeof NEXT_PAGE) => unknown;
}
// The feed's videos on the shared VirtualGrid engine — this wrapper only maps a FeedView to the
@@ -43,7 +44,7 @@ export default function VirtualFeed({
onResetState,
onOpen,
hasNextPage,
isFetchingNextPage,
isFetching,
fetchNextPage,
}: VirtualFeedProps) {
const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view];
@@ -63,7 +64,7 @@ export default function VirtualFeed({
maxCols={maxCols}
rowEst={ROW_EST[view]}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isFetching={isFetching}
fetchNextPage={fetchNextPage}
keyboardNav
renderItem={(v, itemWidth, index) => (
@@ -57,7 +57,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
vi.mock("./ui/Tabs", () => ({ default: () => null }));
vi.mock("../lib/storage", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/storage")>()),
useAccountPersistedState: () => ["access", () => {}],
useTabPersistedState: () => ["access", () => {}],
}));
const listMock = vi.fn();
@@ -48,7 +48,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
vi.mock("./ui/Tabs", () => ({ default: () => null }));
vi.mock("../lib/storage", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/storage")>()),
useAccountPersistedState: () => ["access", () => {}],
useTabPersistedState: () => ["access", () => {}],
}));
const pendingMock = vi.fn();
@@ -4,12 +4,17 @@
// "log_level = DEBUG", "Background sync paused") reads in the reader's language, rebuilt from the row's
// data through the REAL catalog. A row whose summary is data (an e-mail, a URL) keeps it.
import { afterEach, describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { cleanup, fireEvent, render } from "@testing-library/react";
import i18n, { setLanguage } from "../i18n";
import type { AuditRow } from "../lib/api";
import { auditColumns } from "./auditColumns";
afterEach(() => setLanguage("en"));
// Unmount BEFORE the language goes back: a mounted reader re-renders on the switch, and under a loaded
// run that render landed after the test environment was gone ("window is not defined", E31 S5).
afterEach(() => {
cleanup();
setLanguage("en");
});
const row = (over: Partial<AuditRow>): AuditRow => ({
id: 1,
@@ -152,3 +157,49 @@ describe("an audit row's details", () => {
);
});
});
// E31: a folded run (lib/auditGroups) shows its newest run and opens to the earlier ones; a failed run
// is the line to see in a page of routine ones.
describe("a folded run of one job", () => {
const ok = (id: number, enriched: number) =>
row({
id,
target_type: "job",
target_id: "enrich",
after: { status: "ok", result: { enriched } },
});
it("names how many earlier runs it holds, and opens to each", () => {
setLanguage("hu");
const t = i18n.getFixedT("hu");
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
const runs = [ok(3, 50), ok(2, 40), ok(1, 30)];
const { getByRole, container } = render(<>{col.render!({ ...runs[0]!, runs })}</>);
const toggle = getByRole("button", { name: "2 korábbi futás" });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelector("li")).toBeNull();
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect([...container.querySelectorAll("li")].map((li) => li.textContent)).toEqual([
"—dúsítva: 40",
"—dúsítva: 30",
]);
});
it("has no toggle for a lone run", () => {
const t = i18n.getFixedT("hu");
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
const { container } = render(<>{col.render!(ok(1, 5))}</>);
expect(container.querySelector("button")).toBeNull();
});
it("paints a failed run's line in the danger colour", () => {
const t = i18n.getFixedT("hu");
const col = auditColumns(t, "hu", []).find((c) => c.key === "details")!;
const failed = row({ target_type: "job", target_id: "enrich", after: { status: "error" } });
const { container } = render(<>{col.render!(failed)}</>);
expect(container.querySelector(".wrap-break-word")!.className).toContain("text-danger");
const { container: fine } = render(<>{col.render!(ok(2, 5))}</>);
expect(fine.querySelector(".wrap-break-word")!.className).not.toContain("text-danger");
});
});
+15 -2
View File
@@ -1,5 +1,8 @@
import type { TFunction } from "i18next";
import clsx from "clsx";
import type { AuditRow } from "../lib/api";
import type { AuditListRow } from "../lib/auditGroups";
import AuditEarlierRuns from "./AuditEarlierRuns";
import { formatNumber } from "../lib/format";
import type { JobResult } from "../lib/api";
import { jobResultText, jobRunText } from "../lib/jobResult";
@@ -105,7 +108,7 @@ export function auditColumns(
t: TFunction,
lang: string,
actionOptions: { value: string; label: string }[],
): Column<AuditRow>[] {
): Column<AuditListRow>[] {
return [
{
key: "when",
@@ -147,13 +150,23 @@ export function auditColumns(
const local = localDetails(t, r, lang);
const summary = local ? local.head : r.summary;
const change = local ? local.sub : changeText(r);
// A failed run is the row to see in a page of routine ones (E31): its line in the danger colour.
const failed = r.action === "job_run" && r.after?.status === "error";
return (
// wrap-break-word (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
// sizes to its longest row, so one long summary/change stretched the whole table past the
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
<div className="min-w-0">
{summary && <div className="wrap-break-word">{summary}</div>}
{summary && (
<div className={clsx("wrap-break-word", failed && "text-danger")}>{summary}</div>
)}
{change && <div className="text-2xs text-muted wrap-break-word">{change}</div>}
{r.runs && r.runs.length > 1 && (
<AuditEarlierRuns
runs={r.runs}
detailOf={(run) => localDetails(t, run, lang)?.sub ?? run.summary}
/>
)}
</div>
);
},
@@ -6,13 +6,16 @@
// i18n (the key-echo mock does not re-render on a switch, which is the very thing under test).
import { describe, it, expect, afterEach, vi } from "vitest";
import type { ReactNode } from "react";
import { renderHook, act, waitFor } from "@testing-library/react";
import { renderHook, act, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { setLanguage } from "../i18n";
import { api } from "../lib/api";
import { useComfortPin } from "./comfortPin";
// Unmount BEFORE the language goes back: a mounted reader re-renders on the switch, and under a loaded
// run that render landed after the test environment was gone ("window is not defined", E31 S5).
afterEach(() => {
cleanup();
vi.restoreAllMocks();
setLanguage("en");
});
@@ -237,6 +237,27 @@ describe("DownloadRow · a link with no details yet", () => {
expect(container.textContent).not.toContain("https://");
});
// E31 S5 (measured at 21px on the Downloads page): every link in the row stands on a 24px line on
// a mouse (DESIGN B1), as the link line and the failure link already did.
it("gives the title link and the channel link a 24px line", () => {
const { container } = render(
<DownloadRow
job={{
...bare,
display_uploader: "Someone",
display_uploader_url: "https://example.com/c",
}}
/>,
);
const title = [...container.querySelectorAll("a")].find((a) => a.href === bare.source_url)!;
const channel = container.querySelector<HTMLAnchorElement>("a[href='https://example.com/c']")!;
// inline-block: an inline link's box is the font's height (21px) whatever its line height.
for (const a of [title, channel])
expect(a.className.split(/\s+/)).toEqual(
expect.arrayContaining(["inline-block", "leading-6"]),
);
});
it("keeps the named row as it was: title text, and the link on its own line", () => {
const named = { ...bare, display_name: "A clip" };
const { container } = render(<DownloadRow job={named} />);
+2 -2
View File
@@ -211,7 +211,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
target="_blank"
rel="noopener noreferrer"
title={url}
className="text-muted hover:text-accent hover:underline"
className="inline-block max-w-full truncate align-top leading-6 text-muted hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
{...nativeMenu}
>
{name}
@@ -274,7 +274,7 @@ export function DownloadRow({
target="_blank"
rel="noopener noreferrer"
title={`${t("downloads.source.open")} — ${job.source_url}`}
className="hover:text-accent hover:underline"
className="inline-block max-w-full truncate align-top leading-6 hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
{...nativeMenu}
>
{jobTitle(job)}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import type { Tag } from "../lib/api";
import { facetOption } from "./facetOption";
const music = { id: 7, name: "Music", category: "topic", channel_count: 238 } as Tag;
describe("a tag as a filter row", () => {
it("counts the current filter's channels", () => {
expect(facetOption(music, { "7": 3 }).count).toBe(3);
expect(facetOption(music, {}).count).toBe(0);
});
// E31 review 2, live: with the summary failed, the rail read "Music 238" beside a search that matched
// 3 Music channels — the whole library's count printed as the filter's.
it("shows no count until the facets land, rather than the whole library's", () => {
expect(facetOption(music, undefined).count).toBeUndefined();
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { Tag } from "../lib/api";
import type { ComboboxOption } from "./ui/Combobox";
// A tag as a filter row: its count is the CURRENT filter's (the summary's facets), or none at all while
// the facets load or after they failed. The rail printed the tag's whole-library count in that gap, as
// if it were the filter's: "Music 238" beside a search that matched 3 of its channels (E31 review 2).
export const facetOption = (
tag: Tag,
facets: Record<string, number> | undefined,
): ComboboxOption => ({
value: String(tag.id),
label: tag.name,
count: facets ? (facets[String(tag.id)] ?? 0) : undefined,
});
@@ -18,6 +18,9 @@ export const DEFAULT_FILTERS: FeedFilters = {
show: "unwatched",
};
/** The watch-state choices, in the order the filter offers them. */
export const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
export const FILTERS_KEY = LS.filters;
// This account's own persisted filters (per-account keys — never another account's). No account
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import videoCard from "./VideoCard.tsx?raw";
import {
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_PICTURE_CONTAINER,
MEDIA_RESUME_LAYER,
mediaPictureBox,
} from "./ui/mediaCardStyles";
// E31 S5, measured: at text size 1.3 a 242px feed thumbnail put the 63px Play 13px into the 37px hover
// row (the Play and the row grow with the root font, the picture's height does not). A narrow picture
// (under 14rem, so the cut moves with the text size) centres what it carries above the row instead.
describe("a media picture", () => {
it("is a size container, so what sits on it answers its width", () => {
expect(mediaPictureBox("wide")).toContain(MEDIA_PICTURE_CONTAINER);
expect(mediaPictureBox("poster")).toContain(MEDIA_PICTURE_CONTAINER);
// The video card draws its own box (it is not a mediaPictureBox).
expect(videoCard).toContain('clsx("relative min-h-0", MEDIA_PICTURE_CONTAINER, className)');
});
it.each([
["the Play", MEDIA_HOVER_LAYER],
["the Continue + Restart pair", MEDIA_RESUME_LAYER],
])("leaves the hover row its band when narrow: %s", (_, layer) => {
expect(layer.split(" ")).toContain("@max-[14rem]/pic:pb-(--bar-room)");
});
// E31 review 2: the lift applied to every narrow picture, and the feed's row and tile views (a
// 10rem thumbnail, no hover row) showed their Play 1.25rem above centre over an empty band. The band
// is now the row's own: set only by a box that holds one.
it("lifts it only where a hover row is there to clear", () => {
expect(MEDIA_BAR.split(" ")).toContain("media-bar");
expect(MEDIA_PICTURE_CONTAINER.split(" ")).toContain("has-[.media-bar]:[--bar-room:2.5rem]");
for (const layer of [MEDIA_HOVER_LAYER, MEDIA_RESUME_LAYER])
expect(layer).not.toMatch(/pic:pb-\d/);
});
});
@@ -363,3 +363,19 @@ describe("PlexPosterCard on a shelf", () => {
}
});
});
// E31 S4e: a card fetched the detail page's 400px poster for its ~144px picture. It offers the
// proxy's small cut too and lets the browser pick by its width.
describe("a Plex card's picture", () => {
it("offers the small cut beside the full one, lazily", () => {
const { container } = render(
<PlexPosterCard card={{ ...movie(), thumb: "/api/plex/image/1" }} onOpen={vi.fn()} />,
);
const img = container.querySelector("img")!;
expect(img.getAttribute("srcset")).toBe(
"/api/plex/image/1?variant=thumb_sm 240w, /api/plex/image/1 400w",
);
expect(img.getAttribute("sizes")).toBe("auto, 200px");
expect(img.getAttribute("loading")).toBe("lazy");
});
});
+14 -4
View File
@@ -12,12 +12,13 @@ import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { Pressable } from "./ui/Pressable";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
import { HOVER_YIELD } from "./ui/reveal";
import { MediaResumeButtons } from "./MediaResumeButtons";
import {
MEDIA_BADGE,
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_RESUME_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaPictureBox,
@@ -27,6 +28,7 @@ import { MediaCard, MediaProgress, MediaPlayGlyph } from "./ui/MediaCard";
import { MediaPreview } from "./ui/MediaPreview";
import { useMediaPreview } from "./ui/useMediaPreview";
import { SURFACE } from "./ui/surfaces";
import { cardImage } from "../lib/plexImage";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
// state). Rendered by the browse grid, the person page, and the series show/season views.
@@ -211,10 +213,19 @@ function PlexPicture({
// Not read off `pct`: that is rounded, so 20 s into a 3 h film was 0 % and "not started" here while
// the menu offered "from the start" (E28 review D4). One rule, `plexStarted`, for both.
const resumable = plays && started && !!onRestart;
const pic = cardImage(thumb);
return (
<div className={mediaPictureBox(aspect)}>
<Pressable onClick={onOpen} aria-label={label} data-nav-open="" className={MEDIA_PICTURE}>
<img src={thumb} alt="" loading="lazy" decoding="async" className={MEDIA_IMG} />
<img
srcSet={pic.srcSet}
sizes={pic.sizes}
src={pic.src}
alt=""
loading="lazy"
decoding="async"
className={MEDIA_IMG}
/>
{children}
<div className={MEDIA_HOVER_LAYER}>
{plays && !resumable && <MediaPlayGlyph small={small} />}
@@ -224,8 +235,7 @@ function PlexPicture({
{resumable && (
<div
className={clsx(
HOVER_REVEAL,
"pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto",
MEDIA_RESUME_LAYER,
// A poster is too narrow for the pair side by side (150px); it stacks, as in the lab.
aspect === "poster" && "flex-col",
)}
+240
View File
@@ -0,0 +1,240 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { PlexFilters } from "../lib/api";
import { formatMoney, formatRuntime } from "../lib/format";
import type { AsideSection, DetailAsideState, DetailFacts } from "../lib/plexDetail";
import { regionName, vocab } from "../lib/vocab";
import { Filterable, PrefToggle } from "./plexDetailUi";
import { Button } from "./ui/Button";
import { Pressable } from "./ui/Pressable";
// The detail pages' second column (E31, the owner's pick "A"): the TMDB facts and key crew of a movie or
// a show beside its description, inside the hero — as the person page sets its known-for rail beside
// the bio (E30 S6) — and its keywords under the text. They used to sit under the hero (movie) or
// nowhere at all (show), while the hero's text stopped at ~55% of the panel and left the rest empty. A
// narrow hero stacks the column under the text. Each section is a per-user toggle (default ON), the same
// three prefs on both pages (lib/plexDetail useDetailAside).
type OnFilter = (patch: Partial<PlexFilters>) => void;
// The customize menu's rows: one per section that has something (no toggle for an empty one).
export function DetailAsideToggles({ aside }: { aside: DetailAsideState }) {
const { t } = useTranslation();
const label: Record<AsideSection, string> = {
facts: t("plex.info.prefFacts"),
keywords: t("plex.info.prefKeywords"),
crew: t("plex.info.prefCrew"),
};
return (["facts", "keywords", "crew"] as const)
.filter((k) => aside.has[k])
.map((k) => (
<PrefToggle key={k} label={label[k]} on={aside.on[k]} onClick={() => aside.toggle(k)} />
));
}
// The hero's second column: the facts over the key crew — two lists, read down. Null when neither is
// shown. The keywords are chips that filter, as the genres are, so they sit under the text instead
// (DetailKeywords): all three in this column ran it to twice the text's height at 20 keywords and 12
// crew, a hole under the text — the measured worst case (E31 S4c).
export function DetailAside({
facts,
shown,
onFilter,
onOpenPerson,
}: {
facts: DetailFacts;
shown: DetailAsideState["shown"];
onFilter?: OnFilter;
onOpenPerson?: (name: string) => void;
}) {
if (!shown.facts && !shown.crew) return null;
return (
<div data-testid="detail-aside" className="flex min-w-0 flex-col gap-5">
{shown.facts && <FactsSection facts={facts} onFilter={onFilter} />}
{shown.crew && <CrewSection crew={facts.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
</div>
);
}
// The structured facts (E5 S5): release / runtime / cert / budget / revenue / studios / countries, from
// the title's own fields + the persisted TMDB block. What filters the library is an edged chip, a plain
// fact stays flat text (E28 S3h, the S3d1 rule).
function FactsSection({ facts, onFilter }: { facts: DetailFacts; onFilter?: OnFilter }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const tmdb = facts.tmdb;
const studios = tmdb?.studios?.length ? tmdb.studios : facts.studio ? [facts.studio] : [];
const rows: { key: string; label: string; node: ReactNode }[] = [];
// The same values filter here as in the hero's meta line (year, certificate, studios).
const year = facts.year;
if (year != null)
rows.push({
key: "year",
label: t("plex.info.facts.release"),
node: (
<Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
>
{year}
</Filterable>
),
});
if (facts.duration_seconds)
rows.push({
key: "runtime",
label: t("plex.info.facts.runtime"),
node: formatRuntime(facts.duration_seconds, lang),
});
const cert = facts.content_rating;
if (cert)
rows.push({
key: "cert",
label: t("plex.info.facts.cert"),
node: (
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
{vocab(t, "certificate", cert)}
</Filterable>
),
});
if (tmdb?.budget)
rows.push({
key: "budget",
label: t("plex.info.facts.budget"),
node: formatMoney(tmdb.budget, lang),
});
if (tmdb?.revenue)
rows.push({
key: "revenue",
label: t("plex.info.facts.revenue"),
node: formatMoney(tmdb.revenue, lang),
});
if (studios.length)
rows.push({
key: "studios",
label: t("plex.info.facts.studios"),
node: (
<span className="flex flex-wrap gap-1.5">
{studios.map((s) => (
<Filterable
key={s}
tag
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
>
{s}
</Filterable>
))}
</span>
),
});
if (tmdb?.production_countries?.length)
rows.push({
key: "countries",
label: t("plex.info.facts.countries"),
node: tmdb.production_countries
.map((c) => regionName(t, c.code, i18n.language, c.name))
.join(", "),
});
if (rows.length === 0) return null;
return (
<section>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
<dl className="grid grid-cols-[auto_1fr] items-baseline gap-x-4 gap-y-2 text-sm">
{rows.map((r) => (
<div key={r.key} className="contents">
<dt className="text-muted">{r.label}</dt>
<dd className="min-w-0 font-medium">{r.node}</dd>
</div>
))}
</dl>
</section>
);
}
// TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword. Under the hero's text.
export function DetailKeywords({
keywords,
onFilter,
className,
}: {
keywords: string[];
onFilter?: OnFilter;
className?: string;
}) {
const { t } = useTranslation();
return (
<section data-testid="detail-keywords" className={className}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
<div className="flex flex-wrap gap-1.5">
{keywords.map((k) => (
<Filterable
key={k}
tag
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
>
{k}
</Filterable>
))}
</div>
</section>
);
}
// The crew shows its first CREW_SHOWN names and a "Show N more" that opens the rest: the column beside the
// text is the hero's tallest part, and twelve names ran it ~170px past the text (E31, the owner's call).
// A list only a little longer than that shows whole: a toggle that hides one or two names saves no room.
export const CREW_SHOWN = 6;
const CREW_FOLD_OVER = CREW_SHOWN + 2;
// The TMDB key crew (E5 S5) — Director / Writer / Producer / …, each opening their person page. It
// complements the hero's Director line (which comes from Plex directly).
function CrewSection({
crew,
onOpenPerson,
}: {
crew: { name: string; job: string }[];
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const [all, setAll] = useState(false);
const folds = crew.length > CREW_FOLD_OVER;
const shown = folds && !all ? crew.slice(0, CREW_SHOWN) : crew;
return (
<section>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
{/* As many name columns as the column holds: two in the hero's side column, one when narrow. */}
<div className="grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-x-3 gap-y-1.5">
{shown.map((c, i) => (
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? (
<Pressable
selectable
onClick={() => onOpenPerson(c.name)}
title={t("plex.info.viewPerson", { name: c.name })}
className="max-w-full truncate text-sm leading-6 font-medium hover:text-accent pointer-coarse:leading-(--touch-target)"
>
{c.name}
</Pressable>
) : (
<div className="truncate text-sm font-medium">{c.name}</div>
)}
<div className="truncate text-2xs text-muted">{vocab(t, "crewJob", c.job)}</div>
</div>
))}
</div>
{folds && (
<Button
variant={all ? "link-muted" : "link"}
aria-expanded={all}
onClick={() => setAll((v) => !v)}
className="mt-2 text-sm"
>
{all
? t("plex.info.crewLess")
: t("plex.info.crewMore", { count: crew.length - CREW_SHOWN })}
</Button>
)}
</section>
);
}
+22 -5
View File
@@ -9,6 +9,7 @@ import {
type LucideIcon,
} from "lucide-react";
import { useDismiss } from "../lib/useDismiss";
import { DETAIL_POSTER } from "../lib/plexDetail";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
@@ -230,15 +231,18 @@ export function ActionBtn({
/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were
* copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot,
* and the flex info column; the caller passes the customize menu + the title/metadata/actions as
* `children`. Poster is slightly larger on the show page (default) than the season page. */
* `children`. Poster is slightly larger on the show page (default) than the season page. `aside` is the
* show page's second column (the facts and crew, plexDetailAside): beside the text in a wide hero,
* under it in a narrow one. */
export function DetailHero({
thumb,
posterTitle,
posterDisabled,
showPlayOverlay,
onPosterClick,
posterSizeClass = "w-32 sm:w-44",
posterSizeClass = DETAIL_POSTER,
customizeMenu,
aside,
children,
}: {
thumb: string;
@@ -248,11 +252,11 @@ export function DetailHero({
onPosterClick: () => void;
posterSizeClass?: string;
customizeMenu: ReactNode;
aside?: ReactNode;
children: ReactNode;
}) {
return (
<div className={`${SURFACE.panel} relative p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5`}>
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
const main = (
<div className="flex gap-4 sm:gap-5">
<Pressable
onClick={onPosterClick}
disabled={posterDisabled}
@@ -273,6 +277,19 @@ export function DetailHero({
<div className="min-w-0 flex-1">{children}</div>
</div>
);
return (
<div className={`${SURFACE.panel} @container relative p-4 sm:p-5 mb-8`}>
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
{aside ? (
<div className="grid gap-6 @5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]">
{main}
{aside}
</div>
) : (
main
)}
</div>
);
}
/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
//
// E31 (the owner's pick "A"): TMDB enriches shows as it does movies — studios, countries, keywords, key
// crew — and the show page showed none of it, beside a hero whose text stopped at ~55% of the panel. Its
// hero now has the movie page's second column (facts over crew) and the keywords under the text.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => {
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: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
const detail = {
show: {
id: "show",
title: "Fargo",
summary: "Various chronicles.",
year: 2014,
thumb: "",
art: "",
content_rating: "TV-MA",
studio: "26 Keys",
genres: ["Crime"],
cast: [],
status: "new",
episode_count: 0,
collection_keys: [],
tmdb: {
keywords: ["minnesota", "winter"],
crew: [{ name: "Noah Hawley", job: "Creator" }],
studios: ["26 Keys Productions", "MGM Television"],
production_countries: [{ code: "US", name: "United States" }],
recommendations: [],
backdrops: [],
},
},
seasons: [],
related: [],
};
vi.mock("../lib/api", () => ({
api: {
plexShow: () => Promise.resolve(detail),
updatePrefs: () => Promise.resolve({}),
},
EMPTY_PLEX_FILTERS: {},
plexFilterCount: () => 0,
}));
import { PlexShowView } from "./plexViews";
afterEach(cleanup);
function renderShow(onFilter = vi.fn(), onOpenPerson = vi.fn()) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PlexShowView
showId="show"
onBack={() => {}}
onPlay={() => {}}
onOpenSeason={() => {}}
onOpenShow={() => {}}
onFilter={onFilter}
onOpenPerson={onOpenPerson}
/>
</QueryClientProvider>,
);
return { onFilter, onOpenPerson };
}
describe("the show page's facts, keywords and crew (E31)", () => {
it("shows the facts over the crew beside the text, and the keywords under it", async () => {
const { onFilter, onOpenPerson } = renderShow();
const aside = await screen.findByTestId("detail-aside");
expect(aside.textContent).toContain("MGM Television");
expect(aside.textContent).toContain("Noah Hawley");
expect(aside.parentElement!.className).toContain("@5xl:grid-cols-");
const main = aside.parentElement!.children[0]!;
expect(main.contains(screen.getByTestId("detail-keywords"))).toBe(true);
expect(main.contains(screen.getByText("Various chronicles."))).toBe(true);
// They act as on the movie page: a keyword filters, a crew name opens the person.
fireEvent.click(screen.getByText("minnesota"));
expect(onFilter).toHaveBeenCalledWith({ keywords: ["minnesota"] });
fireEvent.click(screen.getByText("Noah Hawley"));
expect(onOpenPerson).toHaveBeenCalledWith("Noah Hawley");
});
});
+27 -2
View File
@@ -34,12 +34,14 @@ import {
Filterable,
WatchedToggle,
} from "./plexDetailUi";
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
import {
epLabel,
seasonTitle,
tmdbBackdropUrl,
useCrossfadeBackdrop,
useDetailPrefs,
useDetailAside,
} from "../lib/plexDetail";
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
import { PlexMenu } from "./PlexMenu";
@@ -449,6 +451,8 @@ export function PlexShowView({
toggleRelated,
toggleCrossfade,
} = useDetailPrefs();
// The facts / keywords / crew (E31): TMDB enriches shows too, and the page showed none of it.
const aside = useDetailAside(show ?? {});
useCrossfadeBackdrop(
[
show?.art,
@@ -554,8 +558,19 @@ export function PlexShowView({
showRelated={showRelated}
onToggleRelated={toggleRelated}
hasRelated={d.related.length > 0}
extra={<DetailAsideToggles aside={aside} />}
/>
}
aside={
aside.column && (
<DetailAside
facts={show}
shown={aside.shown}
onFilter={onFilter}
onOpenPerson={onOpenPerson}
/>
)
}
>
<PageHeading className="text-2xl font-semibold leading-tight">{show.title}</PageHeading>
{/* The same meta row as the media-info page: filtering values are chips, plain facts flat
@@ -583,7 +598,7 @@ export function PlexShowView({
: undefined
}
>
{show.content_rating}
{vocab(t, "certificate", show.content_rating)}
</Filterable>
)}
{show.season_count != null && (
@@ -677,6 +692,13 @@ export function PlexShowView({
/>
)}
</div>
{aside.shown.keywords && (
<DetailKeywords
keywords={show.tmdb?.keywords ?? []}
onFilter={onFilter}
className="mt-5"
/>
)}
</DetailHero>
{/* Seasons — in a glassy panel like the movie info-page strips. */}
@@ -838,7 +860,10 @@ export function PlexSeasonView({
<Button variant="link-muted" selectable onClick={onBack} className="text-sm">
{d.show.title}
</Button>
<PageHeading className="text-2xl font-semibold leading-tight">
<PageHeading
className="text-2xl font-semibold leading-tight"
tabTitle={`${d.show.title} · ${seasonTitle(se, t)}`}
>
{seasonTitle(se, t)}
</PageHeading>
<p className="text-sm text-muted mt-1">
@@ -0,0 +1,155 @@
// @vitest-environment jsdom
//
// E31 S4d: the channel manager's "All" page (~400 rows) blocked one frame for 0.7–0.9 s on prod to
// mount, and every key in its search re-rendered every row. A long page now mounts its first rows at
// once and the rest in small background steps (progressiveRows), and a step leaves the rows it holds
// alone (a row is its own component, whose cells the React Compiler caches).
//
// These tests drive React without act(): act flushes the background steps along with the first render,
// and the first render on its own is the property under test.
import { describe, it, expect, afterEach, beforeAll, afterAll, vi } from "vitest";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { waitFor } from "@testing-library/react";
import DataTable, { type Column } from "./DataTable";
import { FIRST_ROWS } from "./progressiveRows";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
const g = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
let wasAct: boolean | undefined;
beforeAll(() => {
wasAct = g.IS_REACT_ACT_ENVIRONMENT;
g.IS_REACT_ACT_ENVIRONMENT = false;
});
afterAll(() => {
g.IS_REACT_ACT_ENVIRONMENT = wasAct;
});
type Row = { id: string; name: string };
const many = (n: number, prefix = "r"): Row[] =>
Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i}`, name: `${prefix} ${i}` }));
let root: Root | null = null;
let host: HTMLElement | null = null;
afterEach(() => {
root?.unmount();
host?.remove();
root = null;
host = null;
});
const bodyRows = () => host!.querySelectorAll("tbody tr").length;
// Mounts synchronously, so what the DOM holds right after is the first render alone.
function mount(rows: Row[], columns: Column<Row>[], narrowedBy = "") {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
const draw = (r: Row[], n: string) =>
flushSync(() =>
root!.render(
<DataTable rows={r} columns={columns} rowKey={(x) => x.id} pageSize={0} narrowedBy={n} />,
),
);
draw(rows, narrowedBy);
return draw;
}
const plain: Column<Row>[] = [{ key: "name", header: "Name", render: (r) => r.name }];
describe("DataTable — a long page mounts in steps", () => {
it("paints its first rows at once and the rest after", async () => {
mount(many(200), plain);
expect(bodyRows()).toBe(FIRST_ROWS);
await waitFor(() => expect(bodyRows()).toBe(200));
});
it("does not re-render the rows it already holds as it grows", async () => {
const renders = new Map<string, number>();
const counted: Column<Row>[] = [
{
key: "name",
header: "Name",
render: (r) => {
renders.set(r.id, (renders.get(r.id) ?? 0) + 1);
return r.name;
},
},
];
mount(many(200), counted);
await waitFor(() => expect(bodyRows()).toBe(200));
// The first row was drawn by the first render, not again by each step after it.
expect(renders.get("r0")).toBe(1);
});
it("starts over from the first rows when the caller narrows them, not on new data", async () => {
const draw = mount(many(200), plain, "");
await waitFor(() => expect(bodyRows()).toBe(200));
// New data for the same view (a refetch): the page keeps what it shows.
draw(many(200), "");
expect(bodyRows()).toBe(200);
// A search the caller applied: the urgent render is small again.
draw(many(150, "s"), "s");
expect(bodyRows()).toBe(FIRST_ROWS);
await waitFor(() => expect(bodyRows()).toBe(150));
});
// E31 S5: Channels turns the page to the row a priority pick moved and focuses it in the commit
// that follows. That row was at index 40 of a 50-row page: not mounted yet, focus fell to <body>.
it("mounts a revealed row in the render that turns to its page", () => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
const rows = many(100);
const draw = (reveal: { key: string; seq: number } | null) =>
flushSync(() =>
root!.render(
<DataTable
rows={rows}
columns={plain}
rowKey={(x) => x.id}
pageSize={50}
reveal={reveal}
/>,
),
);
draw(null);
draw({ key: "r90", seq: 1 });
expect(host.querySelector("tbody")?.textContent).toContain("r 90");
});
// E31 review 2: the reveal floor held for the page's lifetime. Channels never clears its `follow`, so
// after one priority pick every later sort or search mounted every row up to that channel at once.
it("lets a revealed row go once the reader moves to another view", () => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
const rows = many(200);
const draw = (reveal: { key: string; seq: number } | null, narrowedBy: string) =>
flushSync(() =>
root!.render(
<DataTable
rows={rows}
columns={plain}
rowKey={(x) => x.id}
pageSize={0}
reveal={reveal}
narrowedBy={narrowedBy}
/>,
),
);
draw(null, "");
const follow = { key: "r150", seq: 1 };
draw(follow, "");
expect(host.querySelector("tbody")?.textContent).toContain("r 150");
draw(follow, "s"); // a search: a new view, the same (never cleared) reveal
expect(bodyRows()).toBe(FIRST_ROWS);
});
});
+72 -20
View File
@@ -11,6 +11,7 @@ import { navItemAttrs, useListNav } from "../../lib/listNav";
import { useMediaQuery } from "../../lib/useMediaQuery";
import { SURFACE } from "./surfaces";
import { HiddenFilterCue } from "./HiddenFilterCue";
import { useProgressiveRows } from "./progressiveRows";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@@ -48,6 +49,45 @@ function loadPersist(key?: string): Persisted {
}
}
interface TableRowProps<T> {
row: T;
index: number;
columns: Column<T>[];
rowProps?: (row: T) => Record<string, unknown>;
rowClassName?: (row: T) => string;
keyboardNav: boolean;
}
// A row is its own component so the React Compiler caches its cells by (row, columns, …): a long page
// grows in steps (progressiveRows), and a step must not draw the rows it already holds again. Inline in
// the body's map they all were, and the steps grew from ~70 to ~200 ms (dev build) as the page filled.
function TableRow<T>({
row,
index,
columns,
rowProps,
rowClassName,
keyboardNav,
}: TableRowProps<T>) {
return (
<tr
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(index))}
className={`border-b border-border/50 state-layer transition ${rowClassName?.(row) ?? ""}`}
>
{columns.map((col) => (
<td
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}${columnHideClass(col)}`}
>
{col.render(row)}
</td>
))}
</tr>
);
}
export default function DataTable<T>({
rows,
columns,
@@ -72,6 +112,7 @@ export default function DataTable<T>({
keyboardNav = false,
reveal,
surfacedFilters,
narrowedBy = "",
}: {
rows: T[];
columns: Column<T>[];
@@ -131,6 +172,10 @@ export default function DataTable<T>({
// Filter keys the caller shows somewhere besides their column (the channel manager's tag chips):
// the hidden-filter cue leaves those out, it would name them twice.
surfacedFilters?: string[];
// What the caller narrowed `rows` by before passing them in (the channel manager's name search lives
// in the shared top bar): a change starts a long page over from its first rows, as the table's own
// sort and filter changes do (progressiveRows). Without it every key re-rendered the whole page.
narrowedBy?: string;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -222,13 +267,29 @@ export default function DataTable<T>({
// Adjusted DURING render against the last seq seen (the house's key-change reset), so the frame
// that re-sorted the row already shows its new page. Seeded with the mount's seq: arriving with a
// stale reveal must not move a page nobody asked to move.
const viewOf = (p: number) =>
`${p}:${size}:${JSON.stringify(sort)}:${JSON.stringify(filters)}:${narrowedBy}`;
const [revealSeen, setRevealSeen] = useState(reveal?.seq);
// The view the reveal was made for: the floor below holds only there. A caller keeps its reveal
// after the focus (Channels never clears `follow`), and a floor that outlived its view mounted every
// row up to that channel on each later sort or search (E31 review 2).
const [revealView, setRevealView] = useState<string | null>(null);
if (reveal && reveal.seq !== revealSeen) {
setRevealSeen(reveal.seq);
const at = sorted.findIndex((row) => rowKey(row) === reveal.key);
if (at >= 0 && !allRows) setPage(Math.floor(at / size));
const target = at >= 0 && !allRows ? Math.floor(at / size) : safePage;
if (target !== safePage) setPage(target);
setRevealView(viewOf(target));
}
const view = viewOf(safePage);
const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size);
// A long page ("All", 100) mounts its first rows at once and the rest in the background — but
// always through the revealed row, which the caller focuses as soon as this frame commits.
const shown = useProgressiveRows(
paged,
view,
reveal && revealView === view ? paged.findIndex((row) => rowKey(row) === reveal.key) : -1,
);
function toggleSort(key: string) {
const next = cycleSort(sort, key);
@@ -327,25 +388,16 @@ export default function DataTable<T>({
onFilter={applyFilter}
/>
<tbody ref={bodyRef}>
{paged.map((row, i) => (
<tr
{shown.map((row, i) => (
<TableRow
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`border-b border-border/50 state-layer transition ${
rowClassName?.(row) ?? ""
}`}
>
{columns.map((col) => (
<td
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}${columnHideClass(col)}`}
>
{col.render(row)}
</td>
))}
</tr>
row={row}
index={i}
columns={columns}
rowProps={rowProps}
rowClassName={rowClassName}
keyboardNav={keyboardNav}
/>
))}
</tbody>
</table>
@@ -356,7 +408,7 @@ export default function DataTable<T>({
<>
{cue("cards")}
<div ref={cardsRef} className="flex flex-col gap-2">
{paged.map((row, i) => (
{shown.map((row, i) => (
<div
key={rowKey(row)}
{...rowProps?.(row)}
+11 -4
View File
@@ -1,4 +1,5 @@
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { type ComponentPropsWithoutRef, type ReactNode, useRef } from "react";
import { useNameTabAfter } from "../../lib/documentTitle";
import { useOwnPageHeading } from "./pageHeading";
// The page's heading outline (B4, UX-15): one `h1` per page, then a heading per section. A screen
@@ -6,10 +7,16 @@ import { useOwnPageHeading } from "./pageHeading";
// structure from exactly the people who navigate by it.
/** A view's own visible title (a channel, a Plex show, a playlist): the page's `h1`, in place of the
* shell's hidden page name. */
export function PageHeading(props: ComponentPropsWithoutRef<"h1">) {
* shell's hidden page name, and the object the browser tab names (`lib/documentTitle`). `tabTitle`
* only when the heading alone does not say what it is (a season: "Season 1" → "Voyager · Season 1"). */
export function PageHeading({
tabTitle,
...props
}: ComponentPropsWithoutRef<"h1"> & { tabTitle?: string }) {
useOwnPageHeading();
return <h1 {...props} />;
const ref = useRef<HTMLHeadingElement>(null);
useNameTabAfter(ref, tabTitle);
return <h1 ref={ref} {...props} />;
}
/** A section's caption: the small uppercase muted label, as a heading. `level` 3 inside a dialog or
@@ -62,4 +62,25 @@ describe("ItemCount", () => {
expect(text()).toBe(""); // the Layers icon carries no text
expect(screen.getByTestId("item-count").getAttribute("title")).toBeNull();
});
it("dims the previous filter's count and marks it busy while the new one loads", () => {
const { rerender } = render(<ItemCount count={5} />);
expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBeNull();
rerender(<ItemCount count={5} stale />);
expect(screen.getByTestId("item-count").getAttribute("aria-busy")).toBe("true");
expect(screen.getByText("5 items").className).toContain("opacity-60");
});
// E31 review 2: the dim and aria-busy were the only stale cue, and a screen reader voices neither on
// plain text; a failed count looked like a first load.
it("tells a screen reader a stale count is updating", () => {
render(<ItemCount count={5} stale />);
expect(text()).toBe("5 itemsupdating");
});
it("says a count that failed, rather than looking like a first load", () => {
render(<ItemCount failed />);
expect(text()).toBe("—Couldn't count the items");
expect(screen.getByTestId("item-count").getAttribute("title")).toBe("Couldn't count the items");
});
});
+29 -8
View File
@@ -1,3 +1,4 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Layers } from "lucide-react";
import { formatNumber } from "../../lib/format";
@@ -12,14 +13,27 @@ const OVERFLOW_AT = 1_000_000;
// changing number must never shove the controls beside it, so the box is sized to the widest value
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
// so any surface can reuse it for whatever it counts.
export default function ItemCount({ count }: { count?: number }) {
// so any surface can reuse it for whatever it counts. `stale`: the count is the previous filter's,
// kept while the new one loads (keepPreviousData) — dimmed, and "updating" for a screen reader (which
// does not voice aria-busy on plain text), so a broad search's slow count ("a": ~3 s) never reads as
// the new result. `failed`: the count could not be read — a dash that says so, not the icon-only box
// of a first load (E31 review 2).
export default function ItemCount({
count,
stale = false,
failed = false,
}: {
count?: number;
stale?: boolean;
failed?: boolean;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
// `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just
// show the icon until the number arrives.
const label =
count == null
// `count` undefined and not `failed`: a first, un-cached load — keep the fixed box (nothing shifts),
// just show the icon until the number arrives.
const label = failed
? "—"
: count == null
? ""
: t("common.itemCount", {
count,
@@ -28,11 +42,18 @@ export default function ItemCount({ count }: { count?: number }) {
return (
<span
data-testid="item-count"
title={count == null ? undefined : formatNumber(count, lang)}
title={
failed ? t("common.countFailed") : count == null ? undefined : formatNumber(count, lang)
}
aria-busy={stale || undefined}
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
>
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="tabular-nums">{label}</span>
<span className={clsx("tabular-nums transition-opacity", stale && "opacity-60")}>
{label}
</span>
{failed && <span className="sr-only">{t("common.countFailed")}</span>}
{stale && !failed && <span className="sr-only">{t("common.countUpdating")}</span>}
</span>
);
}
+132 -6
View File
@@ -6,9 +6,9 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { Bell } from "lucide-react";
import { NavRailAction, NavRailItem } from "./NavRail";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Activity, Bell, Shield, Users } from "lucide-react";
import { NavRailAction, NavRailGroup, NavRailItem } from "./NavRail";
vi.mock("../../lib/hints", () => ({ hintsEnabled: () => true, subscribeHints: () => () => {} }));
@@ -24,10 +24,11 @@ describe("NavRailItem", () => {
showLabel={false}
active
badge={3}
href="/?page=notifications"
onClick={() => {}}
/>,
);
const row = screen.getByRole("button", { name: "Notifications (3)" });
const row = screen.getByRole("link", { name: "Notifications (3)" });
expect(row.getAttribute("aria-current")).toBe("page");
expect(row.className).toContain("bg-accent/15");
expect(row.textContent).not.toContain("Alerts");
@@ -40,10 +41,11 @@ describe("NavRailItem", () => {
label="Notifications"
shortLabel="Alerts"
showLabel
href="/?page=notifications"
onClick={() => {}}
/>,
);
const row = screen.getByRole("button", { name: "Alerts" });
const row = screen.getByRole("link", { name: "Alerts" });
expect(row.getAttribute("aria-current")).toBeNull();
});
@@ -57,10 +59,51 @@ describe("NavRailItem", () => {
shortLabel="Alerts"
showLabel
badge={20}
href="/?page=notifications"
onClick={() => {}}
/>,
);
expect(screen.getByRole("button", { name: "Alerts (20)" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Alerts (20)" })).toBeTruthy();
});
// E31 (the owner): a middle-click on a rail row opens the page in a new tab. The row is a real link
// to the page, so the browser's own gestures work; only a plain left click navigates in-app.
it("is a link to its page, navigating in-app only on a plain left click", () => {
const onClick = vi.fn();
render(
<NavRailItem
icon={Bell}
label="Notifications"
shortLabel="Alerts"
showLabel={false}
href="/?page=notifications"
onClick={onClick}
/>,
);
const row = screen.getByRole("link", { name: "Notifications" });
expect(row.getAttribute("href")).toBe("/?page=notifications");
// Whether the row left the click to the browser, read after the app's own handler ran; then the
// click is stopped here, as jsdom cannot follow a link.
const leftToBrowser: boolean[] = [];
const record = (e: Event) => {
leftToBrowser.push(!e.defaultPrevented);
e.preventDefault();
};
window.addEventListener("click", record);
try {
fireEvent.click(row, { button: 0 });
expect(onClick).toHaveBeenCalledTimes(1);
for (const init of [
{ button: 1 },
{ button: 0, ctrlKey: true },
{ button: 0, metaKey: true },
])
fireEvent.click(row, init);
} finally {
window.removeEventListener("click", record);
}
expect(leftToBrowser).toEqual([false, true, true, true]);
expect(onClick).toHaveBeenCalledTimes(1);
});
});
@@ -80,3 +123,86 @@ describe("NavRailAction", () => {
expect(src).not.toMatch(/<IconButton[\s>]/);
});
});
// E31 (the owner's pick): the five admin rows sat below the fold at 133% zoom behind a fade alone, so
// they are one "Admin" row opening a panel of them. The panel's pages stay links, as the rail's are.
describe("NavRailGroup", () => {
const pages = (active: string | null, onSelect = vi.fn()) =>
[
{ id: "scheduler", icon: Activity, label: "Scheduler" },
{ id: "users", icon: Users, label: "Users" },
].map((p) => ({ ...p, href: `/?page=${p.id}`, active: p.id === active, onSelect }));
it("is one row, current while any of its pages is, opening a panel of links", () => {
render(
<NavRailGroup
icon={Shield}
label="Administration"
shortLabel="Admin"
showLabel={false}
pages={pages("users")}
/>,
);
const row = screen.getByRole("button", { name: "Administration" });
expect(row.getAttribute("aria-current")).toBe("page");
expect(screen.queryByRole("link")).toBeNull();
fireEvent.click(row);
// jsdom never positions the panel, so it stays visibility:hidden and nameless to the role query.
const panel = screen.getByRole("dialog", { hidden: true });
expect(panel.getAttribute("aria-label")).toBe("Administration");
const links = Array.from(panel.querySelectorAll("a"));
expect(links.map((a) => [a.textContent, a.getAttribute("href")])).toEqual([
["Scheduler", "/?page=scheduler"],
["Users", "/?page=users"],
]);
expect(links.map((a) => a.getAttribute("aria-current"))).toEqual([null, "page"]);
});
it("is not current while none of its pages is", () => {
render(
<NavRailGroup
icon={Shield}
label="Administration"
shortLabel="Admin"
showLabel
pages={pages(null)}
/>,
);
const row = screen.getByRole("button", { name: "Admin" });
expect(row.getAttribute("aria-current")).toBeNull();
expect(row.className).not.toContain("bg-accent/15");
});
it("a plain click goes to the page and closes the panel; a middle or Ctrl click is the browser's", () => {
const onSelect = vi.fn();
render(
<NavRailGroup
icon={Shield}
label="Administration"
shortLabel="Admin"
showLabel={false}
pages={pages(null, onSelect)}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Administration" }));
const link = () =>
screen.getByRole("dialog", { hidden: true }).querySelector<HTMLAnchorElement>("a")!;
const leftToBrowser: boolean[] = [];
const record = (e: Event) => {
leftToBrowser.push(!e.defaultPrevented);
e.preventDefault();
};
window.addEventListener("click", record);
try {
fireEvent.click(link(), { button: 1 });
fireEvent.click(link(), { button: 0, ctrlKey: true });
expect(onSelect).not.toHaveBeenCalled();
fireEvent.click(link(), { button: 0 });
} finally {
window.removeEventListener("click", record);
}
expect(leftToBrowser).toEqual([true, true, false]);
expect(onSelect).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog", { hidden: true })).toBeNull();
});
});
+129 -4
View File
@@ -1,9 +1,12 @@
import clsx from "clsx";
import type { CSSProperties, ReactNode, Ref } from "react";
import { useState, type CSSProperties, type ReactNode, type Ref } from "react";
import type { LucideIcon } from "lucide-react";
import { PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
import Tooltip from "./Tooltip";
import { isPlainLeftClick } from "../../lib/nav";
// B7 (docs/DESIGN.md): the app's primary navigation rail. Slim — 3.75rem wide whether or not the
// labels are on (they add height, never width) — with "you are here" as a soft accent tint plus a 3px
@@ -76,6 +79,7 @@ export function NavRailItem({
showLabel,
active = false,
badge,
href,
onClick,
testId,
}: {
@@ -88,15 +92,24 @@ export function NavRailItem({
active?: boolean;
/** An unread / in-progress count on the icon's corner (0 hides it). */
badge?: number;
/** The page's own address. A page row is a real link (the owner, E31): a middle-click, a
* Ctrl/Cmd-click or the browser's "Open in new tab" opens the page in a tab of its own. */
href: string;
/** The in-app navigation, run only for a plain left click. */
onClick: () => void;
testId?: string;
}) {
const shown = !!badge && badge > 0;
return (
<Tooltip hint={label} side="right" always openDelay={350}>
<Pressable
<a
data-testid={testId}
onClick={onClick}
href={href}
onClick={(e) => {
if (!isPlainLeftClick(e)) return; // the browser opens its tab or window
e.preventDefault();
onClick();
}}
// With the label visible the row's name starts with that text (WCAG 2.5.3: the spoken name
// contains the visible one); the full name stays in the tooltip. Icon-only, the full name is the
// name. The exact count rides in the name either way, so "Messages (3)" is heard and not only
@@ -131,7 +144,7 @@ export function NavRailItem({
{shortLabel}
</span>
)}
</Pressable>
</a>
</Tooltip>
);
}
@@ -160,3 +173,115 @@ export function NavRailAction({
</Tooltip>
);
}
/** One page of a `NavRailGroup`'s panel. */
export interface NavRailGroupPage {
id: string;
icon: LucideIcon;
/** The page's full name, written out in the panel. */
label: string;
href: string;
active: boolean;
/** The in-app navigation, run only for a plain left click. */
onSelect: () => void;
}
/** One rail row standing for a set of rare pages (E31, the owner's pick: the admin pages), opening a
* panel of them to its right. The rail fits a 133–150% zoom without a scroll nobody sees: five admin
* rows sat below the fold behind a fade alone. The row is "you are here" while any of its pages is.
* The panel's pages are real links, as the rail's own rows are (a middle-click opens a tab), so it is a
* `dialog` popover with its own Tab order, not a roving menu of buttons. */
export function NavRailGroup({
icon: Icon,
label,
shortLabel,
showLabel,
pages,
testId,
}: {
icon: LucideIcon;
/** The group's full name: the tooltip, the row's and the panel's accessible name, the panel heading. */
label: string;
shortLabel: string;
showLabel: boolean;
pages: readonly NavRailGroupPage[];
testId?: string;
}) {
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "dialog",
role: "dialog",
placement: "right-start",
offset: 8,
});
const active = pages.some((p) => p.active);
return (
<>
<Tooltip hint={label} side="right" always openDelay={350}>
<Pressable
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
data-testid={testId}
aria-label={showLabel ? shortLabel : label}
aria-current={active ? "page" : undefined}
className={clsx(
RAIL_ROW,
showLabel ? "py-1.5" : "h-9",
active ? "bg-accent/15 text-accent" : "text-muted state-layer hover:text-fg",
)}
>
{active && (
<span
aria-hidden="true"
className="absolute top-1.5 bottom-1.5 -left-1 w-0.75 rounded-r-full bg-accent"
/>
)}
<Icon className={RAIL_GLYPH} aria-hidden="true" />
{showLabel && (
<span className="max-w-full truncate text-3xs font-medium tracking-tight">
{shortLabel}
</span>
)}
</Pressable>
</Tooltip>
<PopoverSurface
popover={pop}
ariaLabel={label}
className={`${SURFACE.popover} w-56 p-1.5 animate-[popIn_0.16s_ease]`}
>
<div className="px-2 pt-1 pb-1.5 text-2xs font-semibold tracking-wide text-muted uppercase">
{label}
</div>
<ul className="flex flex-col gap-0.5">
{pages.map(({ id, icon: PageIcon, label: name, href, active: here, onSelect }) => (
<li key={id}>
<a
data-testid={`${testId ?? "group"}-${id}`}
href={href}
aria-current={here ? "page" : undefined}
onClick={(e) => {
if (!isPlainLeftClick(e)) return; // the browser opens its tab or window
e.preventDefault();
setOpen(false);
onSelect();
}}
className={clsx(
"flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition touch-target",
here ? "bg-accent/15 text-accent" : "text-fg state-layer",
)}
>
<PageIcon
className={clsx(RAIL_GLYPH, "shrink-0", !here && "text-muted")}
aria-hidden="true"
/>
<span className="min-w-0 truncate">{name}</span>
</a>
</li>
))}
</ul>
</PopoverSurface>
</>
);
}
+7 -7
View File
@@ -36,13 +36,13 @@ domain-free.
## What lives here
| area | components |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
| area | components |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
## The size contract (E10 S1c)

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