release: 0.98.0 (feature/e31-design-review-2)
gate / frontend (push) Successful in 8m29s
gate / backend (push) Successful in 9m54s
gate / frontend (push) Successful in 8m29s
gate / backend (push) Successful in 9m54s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
)
|
||||
@@ -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
|
||||
@@ -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) == []
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user