Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
903e56fba5 | ||
|
|
5a9ac019ae | ||
|
|
7a1487f554 | ||
|
|
7dd50db9b7 | ||
|
|
8d6aa05bc8 | ||
|
|
4dc9846eba | ||
|
|
f1d0845923 | ||
|
|
be6bd586d7 | ||
|
|
36f9aefb76 | ||
|
|
0eca18bcb4 | ||
|
|
02714e4986 | ||
|
|
d265c84c8b | ||
|
|
1dff809268 | ||
|
|
94f9ffdb1c |
@@ -7,6 +7,16 @@ 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.120.0 — 2026-11-14
|
||||
|
||||
### New
|
||||
|
||||
- Filter by year in the feed and in Plex: the recent years sit as chips, and More… opens every year in a decade grid, with how many items each one holds.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A session you keep using stays signed in instead of ending on day 14, and signing out holds even when another tab answers late. After this update everyone signs in once more.
|
||||
|
||||
## 0.119.2 — 2026-11-14
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""revoked_sessions: a signed-out browser session's id, so a cookie that lands after the sign-out is refused
|
||||
|
||||
Revision ID: 0120_revoked_sessions
|
||||
Revises: 0119_plex_transcode_gpu
|
||||
Create Date: 2026-11-14
|
||||
|
||||
The session slides: a request renews its cookie once a day. A request still in flight when the browser
|
||||
signs out answers with the cookie it started with, and that cookie, landing after the sign-out cleared
|
||||
it, signed the browser back in. Each session now carries an id (`sid`); signing out, or signing in as
|
||||
someone else, stores the old id here, and `auth._session_user` refuses a cookie that carries one.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0120_revoked_sessions"
|
||||
down_revision: str | None = "0119_plex_transcode_gpu"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"revoked_sessions",
|
||||
sa.Column("sid", sa.String(32), primary_key=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_revoked_sessions_revoked_at", "revoked_sessions", ["revoked_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_revoked_sessions_revoked_at", table_name="revoked_sessions")
|
||||
op.drop_table("revoked_sessions")
|
||||
+90
-6
@@ -1,6 +1,10 @@
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, Depends, HTTPException, Request
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
@@ -12,7 +16,7 @@ from app import mailtext
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import Invite, LogEvent, User
|
||||
from app.models import Invite, LogEvent, RevokedSession, User
|
||||
from app.security import decrypt_optional
|
||||
from app.utils import canonical_email
|
||||
from app.refusal import refused
|
||||
@@ -261,6 +265,60 @@ def stamp_session(request: Request, user: User) -> None:
|
||||
"""Record the account's current `session_epoch` in the signed cookie, so `current_user` can later
|
||||
reject the cookie once the epoch moves (password reset/change, or "log out everywhere")."""
|
||||
request.session["epoch"] = user.session_epoch or 0
|
||||
request.session.setdefault("sid", new_session_id())
|
||||
request.session["renewed"] = int(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
|
||||
# The signed cookie lives 14 days (`SessionMiddleware`'s max_age, checked again on every read), and
|
||||
# Starlette re-signs it only when the session changes — a signed-in browser in daily use was signed
|
||||
# out on day 14, mid-episode. So the session is a sliding one: the first authenticated request a day
|
||||
# after the last renewal writes a fresh stamp, which re-signs the cookie for another 14 days. Only 14
|
||||
# idle days end it; "log out everywhere" (the epoch) stays the way to revoke it. Once a day, not on
|
||||
# every request: a Set-Cookie on each response would race between parallel requests for nothing.
|
||||
#
|
||||
# A renewal answers with the session its request STARTED with. Landing after a sign-out, that cookie
|
||||
# would sign the browser back in, so each session carries an id (`sid`) and a sign-out revokes it
|
||||
# (`revoke_session`). Landing after "log out everywhere" or a password change re-stamped this
|
||||
# browser's cookie, it carries the old epoch and signs this browser out: that race fails closed (sign
|
||||
# in again; nothing is reached), and is left so.
|
||||
#
|
||||
# The session's age is its `renewed` stamp, not the cookie's signature: any route that writes the
|
||||
# session re-signs the cookie (an anonymous one too — the Google sign-in start stores its state
|
||||
# there), so a refused cookie could be kept fresh forever. `renewed` is written only by a sign-in and
|
||||
# by a renewal, and a refused session gets neither, so it dies `SESSION_MAX_AGE` after its last one.
|
||||
SESSION_MAX_AGE = 14 * 86400 # the cookie's life; `main` hands it to SessionMiddleware
|
||||
_RENEW_AFTER_SECONDS = 86400
|
||||
|
||||
|
||||
def _session_expired(request: Request) -> bool:
|
||||
renewed = request.session.get("renewed")
|
||||
now = int(datetime.now(timezone.utc).timestamp())
|
||||
return not isinstance(renewed, int) or now - renewed > SESSION_MAX_AGE
|
||||
|
||||
|
||||
def _renew_session(request: Request) -> None:
|
||||
now = int(datetime.now(timezone.utc).timestamp())
|
||||
if now - request.session["renewed"] >= _RENEW_AFTER_SECONDS:
|
||||
request.session["renewed"] = now
|
||||
|
||||
|
||||
def new_session_id() -> str:
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
def revoke_session(db: Session, request: Request) -> None:
|
||||
"""Revoke this browser's session id, so a cookie carrying it that lands later is refused. Not
|
||||
committed — the caller commits. A row is dropped once no cookie with its id can still be valid:
|
||||
a refused session is never renewed, so it expires `SESSION_MAX_AGE` after its `renewed` stamp —
|
||||
and that stamp is at most a request's time after the revoke (a request past its revoked-id check
|
||||
can still renew), which the day of margin covers."""
|
||||
sid = request.session.get("sid")
|
||||
if not isinstance(sid, str):
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(seconds=SESSION_MAX_AGE + 86400)
|
||||
db.execute(delete(RevokedSession).where(RevokedSession.revoked_at < cutoff))
|
||||
db.execute(pg_insert(RevokedSession).values(sid=sid, revoked_at=now).on_conflict_do_nothing())
|
||||
|
||||
|
||||
# The account a tab was built for, sent with every request it makes (`identityHeaders`, the
|
||||
@@ -293,6 +351,12 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
return user
|
||||
|
||||
|
||||
def _refuse(request: Request, reason: str) -> HTTPException:
|
||||
"""The one answer to a refused session, its reason kept for the probe's log line."""
|
||||
request.state.session_refused = reason
|
||||
return refused(401, "not_authenticated")
|
||||
|
||||
|
||||
def _session_user(request: Request, db: Session, user_id: int | None) -> User:
|
||||
if not user_id:
|
||||
raise refused(401, "not_authenticated")
|
||||
@@ -300,14 +364,30 @@ def _session_user(request: Request, db: Session, user_id: int | None) -> User:
|
||||
if user is None or not user.is_active or user.is_suspended:
|
||||
# Suspended/deactivated mid-session → end it so the block takes effect at once.
|
||||
request.session.clear()
|
||||
raise refused(401, "not_authenticated")
|
||||
raise _refuse(request, "account")
|
||||
# A cookie whose epoch is behind the account's (revoked by a password reset/change or "log out
|
||||
# everywhere") is refused — and so is one with no epoch at all, which only a build before the
|
||||
# one-account session wrote: its holder signs in once more.
|
||||
epoch = request.session.get("epoch")
|
||||
if epoch is None or int(epoch) != (user.session_epoch or 0):
|
||||
request.session.clear()
|
||||
raise refused(401, "not_authenticated")
|
||||
raise _refuse(request, "epoch")
|
||||
# A session signed out (or replaced by a sign-in as someone else) whose cookie landed late; one
|
||||
# with no id, which only a build before session ids wrote (its holder signs in once more); or one
|
||||
# not renewed for its whole life. Not cleared: this request may have left before a sign-in as
|
||||
# someone else answered, and a cookie deletion landing after that sign-in's cookie would sign the
|
||||
# browser out of it. Such a session is refused here on every request and never renewed. What
|
||||
# reads the cookie alone (`session_user_id`) still sees its account: the poster proxy, by design
|
||||
# (a signed cookie is enough for a poster); anything that must know the browser is signed in
|
||||
# asks `optional_current_user`.
|
||||
sid = request.session.get("sid")
|
||||
if not isinstance(sid, str):
|
||||
raise _refuse(request, "no_session_id")
|
||||
if _session_expired(request):
|
||||
raise _refuse(request, "expired")
|
||||
if db.get(RevokedSession, sid) is not None:
|
||||
raise _refuse(request, "revoked")
|
||||
_renew_session(request)
|
||||
# Attribute this request's log lines to the account. Here, not in the request middleware:
|
||||
# this is the one place a session becomes an account, and the middleware runs outside both the
|
||||
# session middleware and this task's context. `request.state` is shared across middleware layers
|
||||
@@ -333,10 +413,14 @@ def optional_current_user(request: Request, db: Session = Depends(get_db)) -> Us
|
||||
return _session_user(request, db, user_id)
|
||||
except HTTPException:
|
||||
# A session that named an account, refused: the browser is signed out without being told
|
||||
# why, so say it here.
|
||||
# why, so say it here — and why, since only some reasons mean the account itself changed.
|
||||
log.info(
|
||||
"a session was refused: its account no longer holds",
|
||||
extra=logs.event("auth.session.probe_rejected", user_id=user_id),
|
||||
"a session was refused",
|
||||
extra=logs.event(
|
||||
"auth.session.probe_rejected",
|
||||
user_id=user_id,
|
||||
reason=getattr(request.state, "session_refused", None),
|
||||
),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
+14
-15
@@ -282,33 +282,32 @@ def run_download_gc(db: Session) -> dict:
|
||||
kept_assets = select(DownloadJob.asset_id).where(
|
||||
DownloadJob.kept.is_(True), DownloadJob.asset_id.is_not(None)
|
||||
)
|
||||
candidate_ids = (
|
||||
db.execute(
|
||||
select(MediaAsset.id)
|
||||
.where(
|
||||
MediaAsset.status == "ready",
|
||||
MediaAsset.source_kind != "upload",
|
||||
MediaAsset.id.not_in(kept_assets),
|
||||
)
|
||||
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
||||
candidates = db.execute(
|
||||
select(MediaAsset.id, MediaAsset.last_access_at)
|
||||
.where(
|
||||
MediaAsset.status == "ready",
|
||||
MediaAsset.source_kind != "upload",
|
||||
MediaAsset.id.not_in(kept_assets),
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
.order_by(MediaAsset.last_access_at.asc().nulls_first())
|
||||
).all()
|
||||
db.rollback()
|
||||
for asset_id in candidate_ids:
|
||||
for asset_id, seen_access in candidates:
|
||||
# Counted afresh before each: a file deleted since (this one, an upload, a candidate
|
||||
# further down) has freed its space, and no other user's file goes for that space.
|
||||
if _on_disk(db) <= cap:
|
||||
break
|
||||
# Locked and re-read like an expiry: since the SELECT a holder may have kept it,
|
||||
# every holder deleted it, or someone asked for it again (a fresh cache hit).
|
||||
# every holder deleted it, or someone asked for it again (a fresh cache hit). A hit
|
||||
# is a CHANGED stamp, not one later than this pass's clock: a hit stamped just
|
||||
# before the pass began but committed after the SELECT is still a hit, and a clock
|
||||
# that steps back would make a fresh one look old.
|
||||
asset, holders = _relock_evictable(db, asset_id)
|
||||
if (
|
||||
asset is None
|
||||
or asset.status != "ready"
|
||||
or any(j.kept for j in holders)
|
||||
or (asset.last_access_at is not None and asset.last_access_at > now)
|
||||
or asset.last_access_at != seen_access
|
||||
):
|
||||
db.commit()
|
||||
continue
|
||||
|
||||
@@ -26,6 +26,7 @@ configure_logging("api")
|
||||
|
||||
log = logs.get_logger(__name__)
|
||||
from app import bodylimit, collation, logsink, state, sysconfig # noqa: E402 — logging is configured first
|
||||
from app import auth as auth_core # noqa: E402 — logging is configured first
|
||||
from app.config import settings # noqa: E402 — logging is configured first
|
||||
from app.db import SessionLocal, engine # noqa: E402 — logging is configured first
|
||||
import app.tables # noqa: E402 — every table, before the first query
|
||||
@@ -221,6 +222,7 @@ async def _nul_in_text(request: Request, exc: DataError) -> JSONResponse:
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.secret_key,
|
||||
max_age=auth_core.SESSION_MAX_AGE, # the revoked-session rows outlive it (`auth.revoke_session`)
|
||||
same_site="lax", # required so the cookie rides the OAuth redirect back from Google
|
||||
https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod)
|
||||
)
|
||||
|
||||
@@ -190,6 +190,18 @@ class AuthToken(Base, TimestampMixin):
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class RevokedSession(Base):
|
||||
"""The id of a browser session that signed out, or that a sign-in as someone else replaced
|
||||
(`auth.revoke_session`). A request still in flight then can answer with that session's cookie,
|
||||
re-signed by its renewal, after the sign-out cleared it; a cookie whose id is here is refused. A
|
||||
row outlives every cookie that could carry its id, then `revoke_session` drops it."""
|
||||
|
||||
__tablename__ = "revoked_sessions"
|
||||
|
||||
sid: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
revoked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
|
||||
|
||||
class Invite(Base, CanonicalEmailMixin):
|
||||
"""Access-request / whitelist row. Source of truth for who may sign in; the env
|
||||
ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed)."""
|
||||
|
||||
+14
-11
@@ -242,6 +242,7 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr
|
||||
"content_ratings": [],
|
||||
"studios": [],
|
||||
"playable": [],
|
||||
"years": [],
|
||||
"year_min": None,
|
||||
"year_max": None,
|
||||
"rating_max": None,
|
||||
@@ -302,7 +303,7 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr
|
||||
cr_counts: dict[str, int] = {}
|
||||
studio_counts: dict[str, int] = {}
|
||||
playable_counts: dict[str, int] = {}
|
||||
years: list[int] = []
|
||||
year_counts: dict[int, int] = {}
|
||||
ratings: list[float] = []
|
||||
durs: list[int] = []
|
||||
|
||||
@@ -383,21 +384,22 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr
|
||||
)
|
||||
for cls, c in db.execute(pcq).all():
|
||||
playable_counts[cls] = playable_counts.get(cls, 0) + c
|
||||
# Year bounds — exclude the year filter.
|
||||
by = db.execute(
|
||||
# Titles per year — exclude the year filter, so a picked year never empties the list it was
|
||||
# picked from. The year bounds are read from the same counts.
|
||||
yq = (
|
||||
wsq(
|
||||
select(func.min(model.year), func.max(model.year)),
|
||||
select(model.year, func.count()),
|
||||
model,
|
||||
ids,
|
||||
{**p, "year_min": None, "year_max": None},
|
||||
kind_movie,
|
||||
True,
|
||||
)
|
||||
).one()
|
||||
if by[0] is not None:
|
||||
years.append(by[0])
|
||||
if by[1] is not None:
|
||||
years.append(by[1])
|
||||
.where(model.year.isnot(None))
|
||||
.group_by(model.year)
|
||||
)
|
||||
for y, c in db.execute(yq).all():
|
||||
year_counts[y] = year_counts.get(y, 0) + c
|
||||
# Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the
|
||||
# rating filter and the UI badge use (IMDb preferred, audience fallback).
|
||||
br = db.scalar(
|
||||
@@ -465,8 +467,9 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr
|
||||
for c in PLAYBACK_CLASSES
|
||||
if c in playable_counts
|
||||
],
|
||||
"year_min": min(years) if years else None,
|
||||
"year_max": max(years) if years else None,
|
||||
"years": [{"year": y, "count": c} for y, c in sorted(year_counts.items(), reverse=True)],
|
||||
"year_min": min(year_counts) if year_counts else None,
|
||||
"year_max": max(year_counts) if year_counts else None,
|
||||
"rating_max": max(ratings) if ratings else None,
|
||||
"duration_min": min(durs) if durs else None,
|
||||
"duration_max": max(durs) if durs else None,
|
||||
|
||||
@@ -40,6 +40,7 @@ from app.auth import (
|
||||
log,
|
||||
operator_contact,
|
||||
optional_current_user,
|
||||
revoke_session,
|
||||
session_user_id,
|
||||
stamp_session,
|
||||
)
|
||||
@@ -391,9 +392,10 @@ async def callback(
|
||||
# or connecting via /auth/google/connect) never sees the pre-auth login banner, so
|
||||
# surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on the login page
|
||||
# with the oauth_cancelled banner instead. Keyed on the live session, not on link_uid, so a
|
||||
# sign-in started while signed in (no marker) is covered too.
|
||||
# sign-in started while signed in (no marker) is covered too — a session that still HOLDS:
|
||||
# a refused one keeps its cookie (`auth._session_user`), and its browser is on the login page.
|
||||
log.info("Google OAuth cancelled/failed: %s", exc.error)
|
||||
if session_user_id(request) is not None:
|
||||
if optional_current_user(request, db) is not None:
|
||||
return RedirectResponse(url="/?oauth=cancelled")
|
||||
return RedirectResponse(url="/?login=oauth_cancelled")
|
||||
|
||||
@@ -531,7 +533,7 @@ async def callback(
|
||||
db.flush()
|
||||
|
||||
_store_token(db, user, token)
|
||||
_establish_session(request, user)
|
||||
_establish_session(db, request, user)
|
||||
db.commit()
|
||||
log.info(
|
||||
"signed in",
|
||||
@@ -641,7 +643,7 @@ def _complete_link(
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
def logout(request: Request, db: Session = Depends(get_db)):
|
||||
"""Sign this browser out: the session holds one account, and it ends — unless the tab asking was
|
||||
built for another account than the one signed in now (a sign-in as someone else in another tab):
|
||||
its Sign out would end THAT person's session (see `EXPECTED_ACCOUNT_HEADER`)."""
|
||||
@@ -650,14 +652,19 @@ async def logout(request: Request):
|
||||
signed_in, request.headers.get(EXPECTED_ACCOUNT_HEADER)
|
||||
):
|
||||
raise refused(401, "account_changed")
|
||||
revoke_session(db, request) # a request still in flight must not sign it back in
|
||||
db.commit()
|
||||
request.session.clear()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
def _establish_session(request: Request, user: User) -> None:
|
||||
def _establish_session(db: Session, request: Request, user: User) -> None:
|
||||
"""Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback
|
||||
and password login, so both stamp `last_login_at` (the caller commits). One account per session:
|
||||
the sign-in replaces whoever was signed in here before."""
|
||||
the sign-in replaces whoever was signed in here before, and revokes that session's id, so a request
|
||||
of theirs still in flight cannot land it back."""
|
||||
revoke_session(db, request)
|
||||
request.session.pop("sid", None)
|
||||
request.session["user_id"] = user.id
|
||||
stamp_session(request, user)
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
@@ -1040,7 +1047,7 @@ def password_login(
|
||||
raise _login_refused(403, "email_unverified", user)
|
||||
if not user.is_active:
|
||||
raise _login_refused(403, "pending_approval", user)
|
||||
_establish_session(request, user)
|
||||
_establish_session(db, request, user)
|
||||
db.commit()
|
||||
log.info(
|
||||
"signed in",
|
||||
|
||||
@@ -332,25 +332,52 @@ class _NeverBack(_dt.datetime):
|
||||
t = cls.clock.now(tz)
|
||||
last = cls.last
|
||||
if last is not None and (last.tzinfo is None) == (t.tzinfo is None) and t <= last:
|
||||
t = last + _dt.timedelta(microseconds=1)
|
||||
# In the zone asked for: a Pacific reading clamped to a UTC one gave the UTC date.
|
||||
nudged = last + _dt.timedelta(microseconds=1)
|
||||
t = nudged.astimezone(t.tzinfo) if t.tzinfo is not None else nudged
|
||||
cls.last = t
|
||||
return t
|
||||
|
||||
|
||||
_last_signature_second = [0]
|
||||
|
||||
|
||||
def _signature_second_never_back(self) -> int:
|
||||
"""The session cookie's signing clock (itsdangerous, whole seconds), never going back: it refuses
|
||||
a signature from the future, so a step back across a second boundary refused the cookie a
|
||||
sign-in had just set. Read through the module at call time, so a probe can step it."""
|
||||
import itsdangerous.timed
|
||||
|
||||
t = max(int(itsdangerous.timed.time.time()), _last_signature_second[0])
|
||||
_last_signature_second[0] = t
|
||||
return t
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clock_never_goes_back(monkeypatch):
|
||||
"""Rows a test makes one after another are stamped in that order. The wall clock of the docker
|
||||
VM the suite runs in steps BACK now and then (measured: 2 steps in 20 s, up to 0.5 s), and a
|
||||
test that makes three files and reads them newest first then got them out of order. Every loaded
|
||||
`app.*` module that took `datetime` from the datetime module reads this clock instead (a sweep,
|
||||
as the limiter reset below does, so a new module is covered the day it lands). A database
|
||||
as the limiter reset below does, so a new module is covered the day it lands) — and so does every
|
||||
test module, so a test comparing a stamp the app wrote with "now" reads the same clock. The
|
||||
session cookie's signature has a clock of its own (`_signature_second_never_back`). A database
|
||||
default (`server_default=func.now()`) is Postgres's clock and is not covered: a test that orders
|
||||
such rows made within a second of each other gives them distinct times itself."""
|
||||
import sys
|
||||
|
||||
from itsdangerous import TimestampSigner
|
||||
|
||||
tests_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
for name, mod in list(sys.modules.items()):
|
||||
if (name == "app" or name.startswith("app.")) and vars(mod).get("datetime") is _dt.datetime:
|
||||
if vars(mod).get("datetime") is not _dt.datetime:
|
||||
continue
|
||||
mod_file = getattr(mod, "__file__", None) or ""
|
||||
if name == "app" or name.startswith("app.") or mod_file.startswith(tests_dir + os.sep):
|
||||
monkeypatch.setattr(mod, "datetime", _NeverBack)
|
||||
# Per test: a cookie never outlives its test, and a probe's stepped clock must not carry over.
|
||||
_last_signature_second[0] = 0
|
||||
monkeypatch.setattr(TimestampSigner, "get_timestamp", _signature_second_never_back)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -122,9 +122,165 @@ def test_deleting_the_account_signs_the_browser_out(client, make_user):
|
||||
assert "user_id" not in _session(client)
|
||||
|
||||
|
||||
_DAY = 86400
|
||||
_PROTECTED = "/api/me/notifications" # any route behind `current_user` that answers 200 offline
|
||||
|
||||
|
||||
def _signed_at(monkeypatch, at: int, payload: dict) -> str:
|
||||
"""A session cookie carrying `payload`, signed as if at the unix time `at`."""
|
||||
with monkeypatch.context() as m: # not `undo()`: that would also undo the fixtures' patches
|
||||
m.setattr(TimestampSigner, "get_timestamp", lambda self: at)
|
||||
return _cookie(payload)
|
||||
|
||||
|
||||
def _hold(client, cookie: str) -> None:
|
||||
"""Make `cookie` the browser's only session cookie (a plain `set` would sit beside the one the
|
||||
server set, under another domain)."""
|
||||
client.cookies.clear()
|
||||
client.cookies.set("session", cookie)
|
||||
|
||||
|
||||
def test_a_session_in_use_does_not_expire_after_the_cookie_lifetime(client, make_user, monkeypatch):
|
||||
# The cookie's signature is checked against a 14-day life, and Starlette re-signs it only when
|
||||
# the session changes. A browser in daily use must still stay signed in: a session that has not
|
||||
# been renewed for a day is renewed by the next authenticated request.
|
||||
make_user(email="slide@example.com", password=_PW)
|
||||
assert _login(client, "slide@example.com", _PW).status_code == 200
|
||||
now = int(TimestampSigner(str(settings.secret_key)).get_timestamp())
|
||||
payload = {**_session(client), "renewed": now - 2 * _DAY}
|
||||
old = _signed_at(monkeypatch, now - 13 * _DAY, payload)
|
||||
|
||||
_hold(client, old)
|
||||
r = client.get(_PROTECTED)
|
||||
assert r.status_code == 200
|
||||
renewed = r.cookies.get("session")
|
||||
assert renewed and renewed != old # the request re-signed the cookie
|
||||
|
||||
# Two days on, the cookie signed 13 days ago is past its 14 days; the renewed one is not.
|
||||
monkeypatch.setattr(TimestampSigner, "get_timestamp", lambda self: now + 2 * _DAY)
|
||||
_hold(client, old)
|
||||
assert client.get(_PROTECTED).status_code == 401
|
||||
_hold(client, renewed)
|
||||
assert client.get(_PROTECTED).status_code == 200
|
||||
|
||||
|
||||
def test_a_session_renewed_today_is_not_re_signed_on_every_request(client, make_user):
|
||||
make_user(email="slide2@example.com", password=_PW)
|
||||
assert _login(client, "slide2@example.com", _PW).status_code == 200
|
||||
r = client.get(_PROTECTED)
|
||||
assert r.status_code == 200
|
||||
assert "set-cookie" not in r.headers
|
||||
|
||||
|
||||
def test_a_session_with_no_renewal_stamp_signs_in_once_more(client, make_user):
|
||||
# The session's age is its `renewed` stamp; a cookie without one cannot say how old it is.
|
||||
make_user(email="slide3@example.com", password=_PW)
|
||||
assert _login(client, "slide3@example.com", _PW).status_code == 200
|
||||
_hold(client, _cookie({k: v for k, v in _session(client).items() if k != "renewed"}))
|
||||
r = client.get(_PROTECTED)
|
||||
assert r.status_code == 401
|
||||
assert "set-cookie" not in r.headers
|
||||
|
||||
|
||||
def test_a_fresh_signature_does_not_make_an_old_session_young(client, make_user):
|
||||
# Any route that writes the session re-signs the cookie — an anonymous one too (the Google
|
||||
# sign-in start stores its state there) — so a refused cookie could be kept freshly signed for
|
||||
# ever, and outlive the row that revoked it. Its age is its `renewed` stamp instead.
|
||||
make_user(email="slide4@example.com", password=_PW)
|
||||
assert _login(client, "slide4@example.com", _PW).status_code == 200
|
||||
now = int(TimestampSigner(str(settings.secret_key)).get_timestamp())
|
||||
stale = {**_session(client), "renewed": now - 15 * _DAY}
|
||||
_hold(client, _cookie(stale)) # signed just now
|
||||
assert client.get(_PROTECTED).status_code == 401
|
||||
|
||||
|
||||
# A request still in flight when the browser signs out (or signs in as someone else) answers with the
|
||||
# session it started with — re-signed, if it renewed it — and that late cookie lands after the one the
|
||||
# sign-out cleared. Holding it must not sign the browser back in.
|
||||
def test_a_cookie_that_lands_after_sign_out_is_refused(client, make_user):
|
||||
make_user(email="late1@example.com", password=_PW)
|
||||
assert _login(client, "late1@example.com", _PW).status_code == 200
|
||||
in_flight = client.cookies.get("session")
|
||||
assert client.post("/auth/logout").status_code == 200
|
||||
_hold(client, in_flight)
|
||||
assert client.get(_PROTECTED).status_code == 401
|
||||
assert client.get("/api/me").json() == {"authenticated": False}
|
||||
|
||||
|
||||
def test_a_cookie_from_before_a_sign_in_as_another_account_is_refused(client, make_user):
|
||||
make_user(email="late2a@example.com", password=_PW)
|
||||
make_user(email="late2b@example.com", password=_PW)
|
||||
assert _login(client, "late2a@example.com", _PW).status_code == 200
|
||||
in_flight = client.cookies.get("session")
|
||||
assert _login(client, "late2b@example.com", _PW).status_code == 200
|
||||
bob = client.cookies.get("session")
|
||||
_hold(client, in_flight)
|
||||
r = client.get(_PROTECTED)
|
||||
assert r.status_code == 401
|
||||
# The refusal deletes no cookie: landing after Bob's, a deletion would sign Bob out.
|
||||
assert "set-cookie" not in r.headers
|
||||
_hold(client, bob) # Bob's own session is untouched
|
||||
assert client.get("/api/me").json()["email"] == "late2b@example.com"
|
||||
|
||||
|
||||
def test_the_probe_says_why_a_session_was_refused(client, make_user, caplog):
|
||||
# Only some refusals mean the account changed; a cookie with no session id is not one of them.
|
||||
make_user(email="why@example.com", password=_PW)
|
||||
assert _login(client, "why@example.com", _PW).status_code == 200
|
||||
_hold(client, _cookie({k: v for k, v in _session(client).items() if k != "sid"}))
|
||||
with caplog.at_level("INFO", logger="siftlode.auth"):
|
||||
assert client.get("/api/me").json() == {"authenticated": False}
|
||||
[line] = [
|
||||
r for r in caplog.records if getattr(r, "event", None) == "auth.session.probe_rejected"
|
||||
]
|
||||
assert line.getMessage() == "a session was refused"
|
||||
assert line.attrs["reason"] == "no_session_id"
|
||||
|
||||
|
||||
def test_a_cookie_from_before_session_ids_signs_in_once_more(client, make_user):
|
||||
# Its sign-out could revoke nothing, so a request of its still in flight signed the browser back
|
||||
# in. Every such cookie would have run out within 14 days anyway: its holder signs in once more.
|
||||
make_user(email="late3@example.com", password=_PW)
|
||||
assert _login(client, "late3@example.com", _PW).status_code == 200
|
||||
_hold(client, _cookie({k: v for k, v in _session(client).items() if k != "sid"}))
|
||||
r = client.get(_PROTECTED)
|
||||
assert r.status_code == 401
|
||||
assert "set-cookie" not in r.headers
|
||||
assert client.get("/api/me").json() == {"authenticated": False}
|
||||
|
||||
|
||||
def test_a_revoke_drops_only_the_revocations_no_cookie_can_outlive(db):
|
||||
# Each revoke prunes the table: a row goes once no cookie carrying its id can still be valid, and
|
||||
# never sooner — a later sign-out must not undo an earlier one.
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.auth import SESSION_MAX_AGE, revoke_session
|
||||
from app.models import RevokedSession
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
life = timedelta(seconds=SESSION_MAX_AGE)
|
||||
db.add(RevokedSession(sid="outlived", revoked_at=now - life - timedelta(days=2)))
|
||||
db.add(RevokedSession(sid="still-live", revoked_at=now - life + timedelta(hours=1)))
|
||||
# Past the cookie's life but inside the day of margin: a renewal a request's time after the
|
||||
# revoke may still be valid.
|
||||
db.add(RevokedSession(sid="in-the-margin", revoked_at=now - life - timedelta(hours=12)))
|
||||
db.commit()
|
||||
revoke_session(db, SimpleNamespace(session={"user_id": 1, "sid": "this-one"}, cookies={}))
|
||||
db.commit()
|
||||
left = set(db.scalars(select(RevokedSession.sid)).all())
|
||||
assert left == {"still-live", "in-the-margin", "this-one"}
|
||||
|
||||
|
||||
def _session(client) -> dict:
|
||||
"""The decoded payload of the client's session cookie."""
|
||||
raw = client.cookies.get("session")
|
||||
return _decode(client.cookies.get("session"))
|
||||
|
||||
|
||||
def _decode(raw: str | None) -> dict:
|
||||
"""The payload of a session cookie's value."""
|
||||
if not raw:
|
||||
return {}
|
||||
data = TimestampSigner(str(settings.secret_key)).unsign(raw.encode())
|
||||
@@ -140,7 +296,14 @@ def test_a_google_link_lands_only_on_the_account_still_signed_in(db, make_user,
|
||||
from app.routes.auth import _complete_link
|
||||
|
||||
starter, other = make_user(), make_user()
|
||||
session = {"user_id": starter.id if signed_in_as == "starter" else other.id, "epoch": 0}
|
||||
import time
|
||||
|
||||
session = {
|
||||
"user_id": starter.id if signed_in_as == "starter" else other.id,
|
||||
"epoch": 0,
|
||||
"sid": "a-session",
|
||||
"renewed": int(time.time()),
|
||||
}
|
||||
request = Request({"type": "http", "session": session, "headers": []})
|
||||
resp = _complete_link(request, db, starter.id, True, "sub-123", {"email": ""}, {"scope": ""})
|
||||
db.refresh(starter)
|
||||
|
||||
@@ -739,6 +739,28 @@ def test_a_file_asked_for_again_while_the_cache_is_over_its_cap_stays(
|
||||
assert (_root / "old.mp4").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stamped", ["before_the_pass", "by_a_clock_that_stepped_back"])
|
||||
def test_a_cache_hit_stamped_earlier_than_the_pass_still_keeps_the_file(
|
||||
db, make_user, _root, monkeypatch, stamped
|
||||
):
|
||||
# A hit is a stamp that changed since the pass's SELECT. Compared with the pass's own clock, a
|
||||
# hit stamped before the pass began (committed after its SELECT), or by a clock that had stepped
|
||||
# back, read as old and the file the user just asked for went.
|
||||
jold, jnew = _two_over_the_cap(db, make_user, _root)
|
||||
seen = NOW - timedelta(days=9)
|
||||
stamp = seen + (timedelta(hours=1) if stamped == "before_the_pass" else timedelta(days=9))
|
||||
|
||||
def asked_again(other, asset_id):
|
||||
if asset_id == jold.asset_id:
|
||||
other.get(MediaAsset, asset_id).last_access_at = stamp - timedelta(seconds=1)
|
||||
|
||||
_meanwhile(monkeypatch, asked_again, step="_relock_evictable")
|
||||
_gc(db)
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, jold.id) is not None
|
||||
assert (_root / "old.mp4").exists()
|
||||
|
||||
|
||||
def test_a_file_asked_for_while_its_cached_copy_is_freed_gets_a_fresh_one(db, make_user):
|
||||
# The enqueue read the cached copy unlocked: freed meanwhile, its job pointed at a row that was gone
|
||||
# (a foreign-key failure, a 500).
|
||||
|
||||
@@ -59,6 +59,29 @@ def _sign_in(client, google, state: str | None = None, **userinfo):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("session", ["holds", "refused"])
|
||||
def test_a_cancel_at_google_lands_where_the_browser_is(client, google, make_user, session):
|
||||
# Signed in, the cancel is a toast in the app; signed out, the login page's banner. A refused
|
||||
# session keeps its cookie and its account id, and its browser is on the login page.
|
||||
import base64
|
||||
import json
|
||||
|
||||
from itsdangerous import TimestampSigner
|
||||
|
||||
make_user(email="cancel@example.com", password=_PW)
|
||||
_password_login(client, "cancel@example.com")
|
||||
if session == "refused":
|
||||
signer = TimestampSigner(str(settings.secret_key))
|
||||
payload = json.loads(base64.b64decode(signer.unsign(client.cookies["session"])))
|
||||
payload.pop("sid") # a cookie from before session ids
|
||||
refused = signer.sign(base64.b64encode(json.dumps(payload).encode())).decode()
|
||||
client.cookies.clear()
|
||||
client.cookies.set("session", refused)
|
||||
r = _sign_in(client, google, state="not-this-flow")
|
||||
expected = "/?oauth=cancelled" if session == "holds" else "/?login=oauth_cancelled"
|
||||
assert r.headers["location"] == expected
|
||||
|
||||
|
||||
def _rows(db, model, column, value):
|
||||
db.expire_all()
|
||||
return db.execute(select(model).where(column == value)).scalars().all()
|
||||
|
||||
@@ -105,6 +105,28 @@ def test_the_facets_count_what_the_caller_would_see(db, make_user):
|
||||
assert (theirs["year_min"], mine["year_min"]) == (2001, 1990)
|
||||
|
||||
|
||||
def test_the_facets_count_titles_per_year_without_the_year_filter(db, make_user):
|
||||
# The year picker lists every year with its count. Like each facet it drops its own filter (a
|
||||
# picked year must not empty the list it was picked from) and keeps the others; movies and shows
|
||||
# of one year add up, newest first.
|
||||
me = make_user()
|
||||
movies, shows = _lib(db), _lib(db, "2", kind="show")
|
||||
_movie(db, movies, "a", genres=["Drama"], year=2001)
|
||||
_movie(db, movies, "b", genres=["Drama"], year=2001)
|
||||
_movie(db, movies, "c", genres=["Drama"], year=1990)
|
||||
_movie(db, movies, "d", genres=["Horror"], year=1985)
|
||||
_movie(db, movies, "e", genres=["Drama"]) # no year: not a year to pick
|
||||
db.add(PlexShow(rating_key="s1", library_id=shows.id, title="S", genres=["Drama"], year=1990))
|
||||
db.commit()
|
||||
|
||||
f = LibraryFilters(genres="Drama", year_min=2001, year_max=2001)
|
||||
got = library.facets(db, me, scope="both", show="all", lang="en", f=f)
|
||||
assert got["years"] == [{"year": 2001, "count": 2}, {"year": 1990, "count": 2}]
|
||||
assert (got["year_min"], got["year_max"]) == (1990, 2001)
|
||||
movie_only = library.facets(db, me, scope="movie", show="all", lang="en", f=f)
|
||||
assert movie_only["years"] == [{"year": 2001, "count": 2}, {"year": 1990, "count": 1}]
|
||||
|
||||
|
||||
def test_an_episode_search_joins_the_callers_state(db, make_user):
|
||||
me, other = make_user(), make_user()
|
||||
lib = _lib(db, "2", kind="show")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""The suite's clock (conftest `_clock_never_goes_back`), DB-lane.
|
||||
|
||||
The wall clock of the docker VM the suite runs in steps BACK now and then (measured: up to 0.5 s).
|
||||
These hold the two places that read it besides the app: the session cookie's signature, and a test
|
||||
comparing a stamp the app wrote with "now".
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import itsdangerous.timed
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def test_a_cookie_signed_just_before_the_clock_steps_back_still_signs_in(
|
||||
client, make_user, monkeypatch
|
||||
):
|
||||
# The signature carries whole seconds and itsdangerous refuses one from the future: a step back
|
||||
# across a second boundary refused the cookie a sign-in had just set (a 401 at random).
|
||||
clock = [int(time.time()) + 0.2]
|
||||
monkeypatch.setattr(itsdangerous.timed, "time", SimpleNamespace(time=lambda: clock[0]))
|
||||
make_user(email="clock@example.com", password=_PW)
|
||||
r = client.post("/auth/password-login", json={"email": "clock@example.com", "password": _PW})
|
||||
assert r.status_code == 200
|
||||
clock[0] -= 0.5
|
||||
assert client.get("/api/me/notifications").status_code == 200
|
||||
|
||||
|
||||
def test_a_test_reads_the_clock_the_app_stamps_with(monkeypatch):
|
||||
# A test asserting "the app's stamp is not in the future" compared it with the wall clock, which
|
||||
# may have stepped back since the app read its own.
|
||||
import conftest
|
||||
from app import diagnostics
|
||||
|
||||
# The app reads 12:00:01, then the wall clock steps back half a second (a time past any real one,
|
||||
# so a test reading the wall clock instead is behind the stamp).
|
||||
later = datetime(2099, 1, 1, 12, 0, 1, tzinfo=timezone.utc)
|
||||
stepped_back = datetime(2099, 1, 1, 12, 0, 0, 500000, tzinfo=timezone.utc)
|
||||
readings = iter([later])
|
||||
|
||||
class Stepping(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return next(readings, stepped_back)
|
||||
|
||||
monkeypatch.setattr(conftest._NeverBack, "clock", Stepping)
|
||||
monkeypatch.setattr(conftest._NeverBack, "last", None)
|
||||
stamped = diagnostics.datetime.now(timezone.utc)
|
||||
assert datetime.now(timezone.utc) >= stamped
|
||||
|
||||
|
||||
def test_a_clamped_reading_is_in_the_zone_asked_for(monkeypatch):
|
||||
# The Pacific quota day read through a clamp came back in UTC: its date was the next day's.
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import conftest
|
||||
|
||||
pacific = ZoneInfo("America/Los_Angeles")
|
||||
utc_reading = datetime(2099, 1, 2, 3, 0, 0, tzinfo=timezone.utc) # 1 January 19:00 in Pacific
|
||||
stepped_back = datetime(2099, 1, 1, 18, 59, 59, tzinfo=pacific) # a second earlier
|
||||
|
||||
class Stepping(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return utc_reading if tz is timezone.utc else stepped_back
|
||||
|
||||
monkeypatch.setattr(conftest._NeverBack, "clock", Stepping)
|
||||
monkeypatch.setattr(conftest._NeverBack, "last", None)
|
||||
conftest._NeverBack.now(timezone.utc)
|
||||
clamped = conftest._NeverBack.now(pacific)
|
||||
assert clamped.tzinfo is pacific
|
||||
assert clamped.date().isoformat() == "2099-01-01"
|
||||
@@ -75,6 +75,7 @@ CORE_MODELS = {
|
||||
"User",
|
||||
"OAuthToken",
|
||||
"AuthToken",
|
||||
"RevokedSession",
|
||||
"Invite",
|
||||
"ApiQuotaUsage",
|
||||
"QuotaEvent",
|
||||
|
||||
@@ -232,6 +232,8 @@ Also in force:
|
||||
`FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) is a lint error. A text-less button must have an accessible name, and the type enforces
|
||||
it. [WCAG 4.1.2]
|
||||
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
|
||||
One exception, the `cell` size: a cell of a grid of choices (the year dialog's decades) is a 36-high
|
||||
rounded box with its count under the label, so ten fit across a dialog at a tablet's width.
|
||||
`filter` is a selectable option, solid accent when `pressed`; the "+N" and "More…" triggers are filter
|
||||
chips too, and while their popover is open they show it as an open trigger does, the accent edge and
|
||||
text (not the solid fill, which would say "selected"); `token` is an applied narrowing, the accent tint, the whole chip removing
|
||||
@@ -894,3 +896,5 @@ of a full-width panel and nothing on the right.
|
||||
| 2026-11-11 | A GIF from the clip panel (owner D1 A) | A Format · Video / GIF toggle above the name row; GIF shows the size (long side 320 / 480 / 640), the expected size, a note over 15 MB or 30 MB, and over 30 s the reason with Make GIF unavailable; a GIF is never made from the download dialog | A GIF format in the download dialog with its own range picker; the size as a free number | The range is already marked in the panel; a size people can judge beats pixels to type, and a chat's link preview stops moving over 15 MB |
|
||||
| 2026-11-11 | A GIF from the Downloads editor (owner D2 A) | Its own Format row (a video only); with GIF each kept segment is its own GIF, Join unavailable with its reason, one line in place of precise / fast, a note per segment over a limit | One GIF of the joined segments; GIF as an output mode beside Separate / Join | A GIF longer than 30 s is refused anyway, and a joined GIF hides which segment is over the limit |
|
||||
| 2026-11-11 | A shared GIF shows itself (owner D3) | The share page shows the picture itself, no player; Download beside it, and a tip that the downloaded file moves in any chat; its failure is said only once its retry failed too | The watch player; a file row that only downloads | A GIF plays and repeats on its own and nobody pauses one; a chat's link preview is the chat's to draw, the dragged-in file moves everywhere |
|
||||
| 2026-11-14 | One year filter for the feed and Plex (owner ★) | `ui/YearPicker`: the six newest years as chips (the selected one kept beside them), "More…" opens every year laid out by decade, no paging, sortable by count; Plex keeps its range fields under it, a year is a range of one | The feed's scrolling popover; Plex's range fields alone | A library spanning ninety years fits on one screen by decade, and both rails pick a year the same way |
|
||||
| 2026-11-14 | The year dialog's cell (owner ★A) | The count under the year in a 36-high rounded box, compact ("17.2K"), one row per decade at every width | The year-and-count pill, five columns and two rows per decade on a narrow screen | The pill spilled out of its cell at a tablet's width (measured 74 px in 62); a decade on one row still reads at a glance |
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatCompact } from "../../lib/format";
|
||||
|
||||
// A small count pill shown beside a tab label or filter chip (pending requests, item counts). One
|
||||
// source for the count-badge token so Tabs and FilterChipGroup can't drift. The `active` variant sits
|
||||
// on an accent-filled parent (a pressed tab/chip), so it tints against accent-fg; the resting variant
|
||||
// carries its own accent tint. Callers guard on `count > 0` — this just renders the pill.
|
||||
// carries its own accent tint. Callers guard on `count > 0` — this just renders the pill. The count is
|
||||
// compact in the UI language ("17.2K" / "17,2 E"), the same as every other count in the app.
|
||||
export default function CountBadge({ count, active }: { count: number; active?: boolean }) {
|
||||
const { i18n } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
className={`tabular-nums text-2xs leading-none px-1.5 py-0.5 rounded-full ${
|
||||
active ? "bg-accent-fg/20" : "bg-accent/20 text-accent"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
{formatCompact(count, i18n.language)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
||||
|
||||
## What lives here
|
||||
|
||||
| area | components |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `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`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `RowSelectBox` (a table row's selecting box, Shift says a range), `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`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `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`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
|
||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `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`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `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), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||
| area | components |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `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`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `RowSelectBox` (a table row's selecting box, Shift says a range), `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`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `DropZone` (the dashed place a file upload starts from: a drop, or a click that opens the file dialog), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `unavailable` (an off control's props: with a reason it stays focusable, `aria-disabled` and the reason in its tooltip; without one, `disabled`), `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`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `YearPicker` (one calendar year from a list of years with counts: the recent ones as chips, every one in a decade-grid dialog; the feed and Plex rails), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
|
||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps; `rowOpen`: a press on the row runs its default action), `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`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `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), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||
|
||||
## The size contract (E10 S1c)
|
||||
|
||||
@@ -52,7 +52,7 @@ change at 1024x768, 820x1180 and a desktop size. That contract lives in these co
|
||||
|
||||
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES`, `IconButton`'s and `Chip`'s sizes
|
||||
are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media
|
||||
and player rungs). Pick a rung; do not hand-roll padding on a primitive.
|
||||
and player rungs; the `cell` chip, 36, a grid of choices). Pick a rung; do not hand-roll padding on a primitive.
|
||||
- **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale`
|
||||
on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an
|
||||
eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// @vitest-environment jsdom
|
||||
import { useState } from "react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, within, act } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (k: string, o?: Record<string, unknown>) =>
|
||||
o && "from" in o ? `${k}:${String(o.from)}-${String(o.to)}` : k,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
|
||||
|
||||
import YearPicker from "./YearPicker";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Eight years over three decades, 1987 and 2014 without titles.
|
||||
const YEARS = [
|
||||
{ year: 2026, count: 3 },
|
||||
{ year: 2024, count: 9 },
|
||||
{ year: 2023, count: 1 },
|
||||
{ year: 2021, count: 4 },
|
||||
{ year: 2020, count: 2 },
|
||||
{ year: 2015, count: 5 },
|
||||
{ year: 2013, count: 7 },
|
||||
{ year: 1985, count: 6 },
|
||||
];
|
||||
|
||||
const open = () => fireEvent.click(screen.getByRole("button", { name: /sidebar\.year\.more/ }));
|
||||
|
||||
describe("YearPicker", () => {
|
||||
it("shows the six newest years as chips, the selected one kept beside them", () => {
|
||||
render(<YearPicker years={YEARS} selected={1985} onPick={() => {}} />);
|
||||
const chips = screen.getAllByRole("button", { pressed: false }).map((b) => b.textContent);
|
||||
expect(chips.slice(0, 6)).toEqual(["20263", "20249", "20231", "20214", "20202", "20155"]);
|
||||
expect(screen.getByRole("button", { pressed: true }).textContent).toBe("19856");
|
||||
});
|
||||
|
||||
it("lays every year out by decade in the dialog; a year without titles is not a choice", () => {
|
||||
render(<YearPicker years={YEARS} onPick={() => {}} />);
|
||||
open();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const rows = within(dialog).getAllByRole("group", { name: /sidebar\.year\.decade/ });
|
||||
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual([
|
||||
"sidebar.year.decade:2020-2029",
|
||||
"sidebar.year.decade:2010-2019",
|
||||
"sidebar.year.decade:1980-1989",
|
||||
]);
|
||||
const in1980s = within(rows[2]!)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.dataset.year);
|
||||
expect(in1980s).toEqual(["1985"]);
|
||||
expect(rows[1]!.querySelector('[data-year="2014"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("sorted by count, the busiest year comes first", () => {
|
||||
render(<YearPicker years={YEARS} onPick={() => {}} />);
|
||||
open();
|
||||
fireEvent.click(screen.getByRole("button", { name: "sidebar.year.sortCount" }));
|
||||
const order = within(screen.getByRole("dialog"))
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.dataset.year)
|
||||
.filter(Boolean);
|
||||
expect(order.slice(0, 3)).toEqual(["2024", "2013", "1985"]);
|
||||
});
|
||||
|
||||
it("a pick in the dialog names the year and closes it; the selected year again clears it", () => {
|
||||
const onPick = vi.fn();
|
||||
const { rerender } = render(<YearPicker years={YEARS} onPick={onPick} />);
|
||||
open();
|
||||
fireEvent.click(screen.getByRole("dialog").querySelector('[data-year="1985"]')!);
|
||||
expect(onPick).toHaveBeenLastCalledWith(1985);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
rerender(<YearPicker years={YEARS} selected={2024} onPick={onPick} />);
|
||||
fireEvent.click(screen.getByRole("button", { pressed: true }));
|
||||
expect(onPick).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("offers More… while a year with titles is not among the chips, the kept selection aside", () => {
|
||||
// Seven years, and a selected year with no titles kept beside the six newest: 2013 is hidden.
|
||||
render(<YearPicker years={YEARS.slice(0, 7)} selected={1999} onPick={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: "sidebar.year.more" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers no More… once every year with titles is a chip", () => {
|
||||
const { rerender } = render(<YearPicker years={YEARS.slice(0, 6)} onPick={() => {}} />);
|
||||
expect(screen.queryByRole("button", { name: "sidebar.year.more" })).toBeNull();
|
||||
// Seven years, the seventh selected: it is kept as a chip, so nothing is left for the dialog.
|
||||
rerender(<YearPicker years={YEARS.slice(0, 7)} selected={2013} onPick={() => {}} />);
|
||||
expect(screen.queryByRole("button", { name: "sidebar.year.more" })).toBeNull();
|
||||
});
|
||||
|
||||
it("picking the one year the chips hid moves the focus to its chip, not to <body>", () => {
|
||||
// Seven years: the pick keeps 2013 as a chip, so More… leaves with the dialog it opened.
|
||||
function Picker() {
|
||||
const [year, setYear] = useState<number | undefined>();
|
||||
return <YearPicker years={YEARS.slice(0, 7)} selected={year} onPick={setYear} />;
|
||||
}
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(<Picker />);
|
||||
const more = screen.getByRole("button", { name: "sidebar.year.more" });
|
||||
more.focus();
|
||||
fireEvent.click(more);
|
||||
const cell = screen.getByRole("dialog").querySelector<HTMLElement>('[data-year="2013"]')!;
|
||||
cell.focus();
|
||||
fireEvent.click(cell);
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "sidebar.year.more" })).toBeNull();
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { pressed: true }));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("a chip's count is compact too, the same as its cell's", () => {
|
||||
render(<YearPicker years={[{ year: 2025, count: 17243 }]} onPick={() => {}} />);
|
||||
expect(screen.getByRole("button").textContent).toBe("202517.2K");
|
||||
});
|
||||
|
||||
it("More… is a plain chip: the ellipsis says a dialog follows, no chevron", () => {
|
||||
render(<YearPicker years={YEARS} onPick={() => {}} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "sidebar.year.more" }).querySelector("svg"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("clearing the kept older year hands the focus on, not to <body>", () => {
|
||||
const { rerender } = render(<YearPicker years={YEARS} selected={1985} onPick={() => {}} />);
|
||||
const kept = screen.getByRole("button", { pressed: true });
|
||||
kept.focus();
|
||||
fireEvent.click(kept);
|
||||
rerender(<YearPicker years={YEARS} onPick={() => {}} />);
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "sidebar.year.more" }));
|
||||
});
|
||||
|
||||
it("a decade row is named once: its caption is not read again", () => {
|
||||
render(<YearPicker years={YEARS} onPick={() => {}} />);
|
||||
open();
|
||||
const row = screen.getAllByRole("group", { name: /sidebar\.year\.decade/ })[0]!;
|
||||
expect(row.firstElementChild?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("a cell carries its count under the year, compact", () => {
|
||||
render(<YearPicker years={[...YEARS, { year: 2025, count: 17243 }]} onPick={() => {}} />);
|
||||
open();
|
||||
const cell = screen.getByRole("dialog").querySelector<HTMLElement>('[data-year="2025"]')!;
|
||||
expect(cell.textContent).toBe("202517.2K");
|
||||
});
|
||||
|
||||
it("the arrows walk the cells: along a decade, then down to the next one", () => {
|
||||
render(<YearPicker years={YEARS} onPick={() => {}} />);
|
||||
open();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
dialog.querySelector<HTMLElement>('[data-year="2020"]')!.focus();
|
||||
press("ArrowRight");
|
||||
expect((document.activeElement as HTMLElement).dataset.year).toBe("2021");
|
||||
press("ArrowDown");
|
||||
expect((document.activeElement as HTMLElement).dataset.year).toMatch(/^201/);
|
||||
});
|
||||
});
|
||||
|
||||
/** A keydown where a real one starts: on whatever holds focus, bubbling to the window listener. */
|
||||
function press(key: string): void {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Chip } from "./Chip";
|
||||
import CountBadge from "./CountBadge";
|
||||
import Modal from "./Modal";
|
||||
import NavGrid from "./NavGrid";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import { formatCompact } from "../../lib/format";
|
||||
import { navItemAttrs } from "../../lib/listNav";
|
||||
import { handOffFocus } from "../../lib/useFocusKeeper";
|
||||
|
||||
export interface YearCount {
|
||||
year: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// A filter for one calendar year, shared by the feed and Plex rails: chips for the most recent years
|
||||
// and a "More…" dialog with every year and its count. The dialog lays the years out by decade — a row
|
||||
// per decade, a cell per year, the count under the year so ten fit across at a tablet's width — and a
|
||||
// library spanning ninety years fits on one screen without paging; sorted by count it is one grid,
|
||||
// the busiest year first. A year with no titles is a blank cell, not a choice. The arrows walk the
|
||||
// cells (lib/listNav): ←→ along a decade, ↑↓ to the nearest year of the next one. Picking the
|
||||
// selected year again clears it. What a pick means (a whole-year date range, a min = max range) is
|
||||
// the caller's: this only names the year.
|
||||
|
||||
const QUICK = 6; // recent-year chips before "More…"
|
||||
type Sort = "year" | "count";
|
||||
|
||||
export default function YearPicker({
|
||||
years,
|
||||
selected,
|
||||
onPick,
|
||||
}: {
|
||||
/** Every year that has titles, with its count; any order. */
|
||||
years: readonly YearCount[];
|
||||
selected?: number;
|
||||
onPick: (year?: number) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sort, setSort] = useState<Sort>("year");
|
||||
|
||||
const newestFirst = useMemo(() => [...years].sort((a, b) => b.year - a.year), [years]);
|
||||
const recent = useMemo(() => newestFirst.slice(0, QUICK), [newestFirst]);
|
||||
// The selected year stays visible among the chips, even when it is older than the recent ones.
|
||||
const quick = useMemo(() => {
|
||||
if (selected == null || recent.some((y) => y.year === selected)) return recent;
|
||||
const sel = newestFirst.find((y) => y.year === selected) ?? { year: selected, count: 0 };
|
||||
return [...recent, sel].sort((a, b) => b.year - a.year);
|
||||
}, [newestFirst, recent, selected]);
|
||||
// "More…" while a year with titles is not among the chips — the one kept for the selection counts
|
||||
// only if it is one of them.
|
||||
const more = years.some((y) => !quick.some((q) => q.year === y.year));
|
||||
|
||||
const chipsRef = useRef<HTMLDivElement>(null);
|
||||
const toggle = (year: number) => onPick(selected === year ? undefined : year);
|
||||
const pickAndClose = (year: number) => {
|
||||
toggle(year);
|
||||
setOpen(false);
|
||||
// A pick that leaves the dialog nothing to show takes More… away with it, and the dialog then has
|
||||
// nowhere to give the focus back: it fell to <body>, which also folded an unpinned rail. So it
|
||||
// goes to the picked year's chip (the first chip if the pick cleared it). A timer, not a frame: a
|
||||
// page the browser is not painting runs no requestAnimationFrame.
|
||||
window.setTimeout(() => {
|
||||
const now = document.activeElement;
|
||||
const row = chipsRef.current;
|
||||
if ((now && now !== document.body && now.isConnected) || !row) return;
|
||||
const chip = row.querySelector<HTMLElement>(`[data-year="${year}"]`);
|
||||
(chip ?? row.querySelector<HTMLElement>("button"))?.focus({ preventScroll: true });
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const cell = (y: YearCount, index: number) => {
|
||||
const on = selected === y.year;
|
||||
return (
|
||||
<Chip
|
||||
key={y.year}
|
||||
size="cell"
|
||||
pressed={on}
|
||||
className="w-full"
|
||||
onClick={() => pickAndClose(y.year)}
|
||||
data-year={y.year}
|
||||
{...navItemAttrs(index)}
|
||||
>
|
||||
<span className="tabular-nums">{y.year}</span>
|
||||
<span className={on ? "text-2xs tabular-nums" : "text-muted text-2xs tabular-nums"}>
|
||||
{formatCompact(y.count, i18n.language)}
|
||||
</span>
|
||||
</Chip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={chipsRef} className="flex flex-wrap gap-1.5">
|
||||
{quick.map((y) => {
|
||||
const on = selected === y.year;
|
||||
// The chip kept only for the selection leaves with it: from the keyboard, the focus goes to
|
||||
// its neighbour first, not to <body> (which also folded an unpinned rail).
|
||||
const leaves = on && !recent.some((r) => r.year === y.year);
|
||||
return (
|
||||
<Chip
|
||||
key={y.year}
|
||||
pressed={on}
|
||||
data-year={y.year}
|
||||
onClick={(e) => {
|
||||
if (leaves && e.currentTarget === document.activeElement)
|
||||
handOffFocus(e.currentTarget, () => toggle(y.year));
|
||||
else toggle(y.year);
|
||||
}}
|
||||
>
|
||||
<span className="tabular-nums">{y.year}</span>
|
||||
{y.count > 0 && <CountBadge count={y.count} active={on} />}
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
{more && (
|
||||
<Chip aria-haspopup="dialog" onClick={() => setOpen(true)}>
|
||||
{t("sidebar.year.more")}
|
||||
</Chip>
|
||||
)}
|
||||
{open && (
|
||||
<Modal
|
||||
title={t("sidebar.year.moreLabel")}
|
||||
initialFocus="close"
|
||||
onClose={() => setOpen(false)}
|
||||
maxWidth="max-w-4xl"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-end gap-2">
|
||||
<span className="text-muted text-xs" aria-hidden="true">
|
||||
{t("sidebar.year.sortBy")}
|
||||
</span>
|
||||
<SegmentedToggle
|
||||
ariaLabel={t("sidebar.year.sortBy")}
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
options={[
|
||||
{ value: "year", label: t("sidebar.year.sortYear") },
|
||||
{ value: "count", label: t("sidebar.year.sortCount") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{sort === "year" ? (
|
||||
<DecadeGrid years={newestFirst} cell={cell} />
|
||||
) : (
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(4rem,1fr))] gap-1.5">
|
||||
{[...years].sort((a, b) => b.count - a.count || b.year - a.year).map(cell)}
|
||||
</NavGrid>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row per decade, newest first, with a cell per year of it; a year without titles is blank.
|
||||
* Each decade is a list of its own, so ↑↓ land on the nearest year of the next decade. */
|
||||
function DecadeGrid({
|
||||
years,
|
||||
cell,
|
||||
}: {
|
||||
years: readonly YearCount[];
|
||||
cell: (y: YearCount, index: number) => React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const byYear = new Map(years.map((y) => [y.year, y]));
|
||||
const decades = [...new Set(years.map((y) => y.year - (y.year % 10)))].sort((a, b) => b - a);
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{decades.map((from) => {
|
||||
const row = Array.from({ length: 10 }, (_, i) => byYear.get(from + i));
|
||||
return (
|
||||
<div
|
||||
key={from}
|
||||
role="group"
|
||||
aria-label={t("sidebar.year.decade", { from, to: from + 9 })}
|
||||
className="grid grid-cols-[3rem_minmax(0,1fr)] items-center gap-1.5"
|
||||
>
|
||||
{/* The group's name says the decade; read twice, it was noise. */}
|
||||
<span className="text-muted text-xs tabular-nums" aria-hidden="true">
|
||||
{from}
|
||||
</span>
|
||||
<NavGrid className="grid grid-cols-10 items-center gap-1.5">
|
||||
{row.map((y, i) =>
|
||||
y ? (
|
||||
// Its index among the decade's years: a blank cell is not an item.
|
||||
cell(y, row.slice(0, i).filter(Boolean).length)
|
||||
) : (
|
||||
<span
|
||||
key={from + i}
|
||||
aria-hidden="true"
|
||||
className="text-muted/50 text-center text-xs tabular-nums"
|
||||
>
|
||||
{from + i}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</NavGrid>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ export type ChipVariant = "filter" | "token" | "tag" | "suggest" | "ghost";
|
||||
export type ChipSize =
|
||||
| "md" // 28, text-sm — the applied-filter row above the feed
|
||||
| "sm" // 24, text-xs — a filter row
|
||||
| "xs"; // 24, text-2xs, tight — a tag in a table row
|
||||
| "xs" // 24, text-2xs, tight — a tag in a table row
|
||||
| "cell"; // 36, two lines (a label over its count), a rounded box — a cell of a grid of choices
|
||||
|
||||
const PAINT: Record<ChipVariant, string> = {
|
||||
// `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed
|
||||
@@ -23,13 +24,15 @@ const PAINT: Record<ChipVariant, string> = {
|
||||
};
|
||||
|
||||
const SIZES: Record<ChipSize, string> = {
|
||||
md: "h-7 px-2.5 text-sm gap-1",
|
||||
sm: "h-6 px-2.5 text-xs gap-1.5",
|
||||
xs: "h-6 px-1.5 text-2xs gap-1",
|
||||
md: "h-7 px-2.5 text-sm gap-1 rounded-full",
|
||||
sm: "h-6 px-2.5 text-xs gap-1.5 rounded-full",
|
||||
xs: "h-6 px-1.5 text-2xs gap-1 rounded-full",
|
||||
// Narrow enough for ten across a dialog at a tablet's width: the count goes under the label.
|
||||
cell: "h-9 px-1 text-xs leading-tight flex-col rounded-lg",
|
||||
};
|
||||
|
||||
const BASE =
|
||||
"inline-flex min-w-6 shrink-0 items-center justify-center rounded-full whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0";
|
||||
"inline-flex min-w-6 shrink-0 items-center justify-center whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0";
|
||||
|
||||
/** The chip's geometry + paint as a class string: for the hidden measuring mirror of an overflowing
|
||||
* chip lane, which must lay out exactly like the chips it measures but must not be buttons. */
|
||||
|
||||
@@ -35,7 +35,7 @@ const IV = keys<IconButtonVariant>({
|
||||
}); // prettier-ignore
|
||||
const IS = keys<IconButtonSize>({ sm: true, md: true, lg: true, player: true });
|
||||
const CV = keys<ChipVariant>({ filter: true, token: true, tag: true, suggest: true, ghost: true });
|
||||
const CS = keys<ChipSize>({ md: true, sm: true, xs: true });
|
||||
const CS = keys<ChipSize>({ md: true, sm: true, xs: true, cell: true });
|
||||
|
||||
const missing = (emitted: string[], file: string) => {
|
||||
const found = tokens(file);
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
[
|
||||
{
|
||||
"version": "0.120.0",
|
||||
"date": "2026-11-14",
|
||||
"new": [
|
||||
"Filter by year in the feed and in Plex: the recent years sit as chips, and More… opens every year in a decade grid, with how many items each one holds."
|
||||
],
|
||||
"fixed": [
|
||||
"A session you keep using stays signed in instead of ending on day 14, and signing out holds even when another tab answers late. After this update everyone signs in once more."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.119.2",
|
||||
"date": "2026-11-14",
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
[
|
||||
{
|
||||
"version": "0.120.0",
|
||||
"date": "2026-11-14",
|
||||
"new": [
|
||||
"Év szerint is szűrhetsz a hírfolyamban és a Plexben: a legutóbbi évek chipként látszanak, a Több… pedig évtizedes rácsban mutatja az összes évet, mindegyiknél azzal, hány elem tartozik hozzá."
|
||||
],
|
||||
"fixed": [
|
||||
"A használatban lévő munkamenet bejelentkezve marad, nem ér véget a 14. napon, és a kijelentkezés akkor is érvényes, ha egy másik lap későn válaszol. A frissítés után mindenkinek egyszer újra be kell lépnie."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.119.2",
|
||||
"date": "2026-11-14",
|
||||
|
||||
@@ -76,6 +76,8 @@
|
||||
"rating": "IMDb rating",
|
||||
"genre": "Genre",
|
||||
"year": "Year",
|
||||
"yearFrom": "From year",
|
||||
"yearTo": "To year",
|
||||
"duration": "Length",
|
||||
"durationUnit": "min",
|
||||
"durationOpt": {
|
||||
|
||||
@@ -34,8 +34,9 @@
|
||||
"year": "Year"
|
||||
},
|
||||
"year": {
|
||||
"more": "More",
|
||||
"more": "More…",
|
||||
"moreLabel": "All years",
|
||||
"decade": "{{from}}–{{to}}",
|
||||
"none": "No years yet",
|
||||
"sortBy": "Sort",
|
||||
"sortYear": "Year",
|
||||
|
||||
@@ -76,6 +76,8 @@
|
||||
"rating": "IMDb pont",
|
||||
"genre": "Műfaj",
|
||||
"year": "Év",
|
||||
"yearFrom": "Ettől az évtől",
|
||||
"yearTo": "Eddig az évig",
|
||||
"duration": "Hossz",
|
||||
"durationUnit": "perc",
|
||||
"durationOpt": {
|
||||
|
||||
@@ -34,8 +34,9 @@
|
||||
"year": "Év"
|
||||
},
|
||||
"year": {
|
||||
"more": "Több",
|
||||
"more": "Több…",
|
||||
"moreLabel": "Összes év",
|
||||
"decade": "{{from}}–{{to}}",
|
||||
"none": "Még nincs év",
|
||||
"sortBy": "Rendezés",
|
||||
"sortYear": "Év",
|
||||
|
||||
@@ -52,6 +52,8 @@ import { plexApi } from "./api/plex";
|
||||
export type * from "./api/plex";
|
||||
export {
|
||||
EMPTY_PLEX_FILTERS,
|
||||
oneYear,
|
||||
singleYear,
|
||||
plexFilterCount,
|
||||
coercePlexFilters,
|
||||
plexFiltersSignature,
|
||||
|
||||
@@ -261,6 +261,17 @@ export interface PlexPersonDetail {
|
||||
movies: PlexCard[]; // enabled-library movies whose cast OR directors include this person
|
||||
shows: PlexCard[]; // enabled-library shows, same
|
||||
}
|
||||
/** One year as a Plex filter: a range of one, both ends set (the year picker, a year on an info page).
|
||||
* `null` clears it. */
|
||||
export const oneYear = (year?: number | null): Pick<PlexFilters, "yearMin" | "yearMax"> => ({
|
||||
yearMin: year ?? null,
|
||||
yearMax: year ?? null,
|
||||
});
|
||||
|
||||
/** The one year a filter narrows to, when its range is a single year. */
|
||||
export const singleYear = (f: Pick<PlexFilters, "yearMin" | "yearMax">): number | undefined =>
|
||||
f.yearMin != null && f.yearMin === f.yearMax ? f.yearMin : undefined;
|
||||
|
||||
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
|
||||
export interface PlexFilters {
|
||||
genres: string[];
|
||||
@@ -410,6 +421,8 @@ export interface PlexFacets {
|
||||
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker
|
||||
// Playback-class facet (movie-only): value ∈ direct|remux|transcode_hw|transcode_sw. Empty on shows.
|
||||
playable: { value: string; count: number }[];
|
||||
// Titles per year, newest first, the year filter itself left out (the sidebar's year picker).
|
||||
years: { year: number; count: number }[];
|
||||
year_min: number | null;
|
||||
year_max: number | null;
|
||||
rating_max: number | null;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildPlexActiveFilters } from "./plexActiveFilters";
|
||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "./api";
|
||||
|
||||
const t = (key: string) => key;
|
||||
const yearLabel = (f: Partial<PlexFilters>) =>
|
||||
buildPlexActiveFilters(
|
||||
{ ...EMPTY_PLEX_FILTERS, ...f },
|
||||
"",
|
||||
() => {},
|
||||
() => {},
|
||||
t,
|
||||
).find((c) => c.key === "year")?.label;
|
||||
|
||||
describe("the Plex year chip", () => {
|
||||
it("names one year as the year, not a range of one", () => {
|
||||
expect(yearLabel({ yearMin: 1999, yearMax: 1999 })).toBe("plex.filter.year: 1999");
|
||||
});
|
||||
|
||||
it("names a range and an open end as before", () => {
|
||||
expect(yearLabel({ yearMin: 1990, yearMax: 1999 })).toBe("plex.filter.year: 1990–1999");
|
||||
expect(yearLabel({ yearMin: 1990 })).toBe("plex.filter.year: ≥1990");
|
||||
expect(yearLabel({ yearMax: 1999 })).toBe("plex.filter.year: ≤1999");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { vocabI18nKey } from "./vocab";
|
||||
import { type ActiveFilter } from "./useActiveFilters";
|
||||
import { type PlexFilters } from "./api";
|
||||
import { singleYear, type PlexFilters } from "./api";
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
@@ -42,12 +42,15 @@ export function buildPlexActiveFilters(
|
||||
});
|
||||
}
|
||||
if (has(filters.yearMin) || has(filters.yearMax)) {
|
||||
const one = singleYear(filters);
|
||||
const range =
|
||||
has(filters.yearMin) && has(filters.yearMax)
|
||||
? `${filters.yearMin}–${filters.yearMax}`
|
||||
: has(filters.yearMin)
|
||||
? `≥${filters.yearMin}`
|
||||
: `≤${filters.yearMax}`;
|
||||
one != null
|
||||
? String(one)
|
||||
: has(filters.yearMin) && has(filters.yearMax)
|
||||
? `${filters.yearMin}–${filters.yearMax}`
|
||||
: has(filters.yearMin)
|
||||
? `≥${filters.yearMin}`
|
||||
: `≤${filters.yearMax}`;
|
||||
out.push({
|
||||
key: "year",
|
||||
label: `${t("plex.filter.year")}: ${range}`,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The feed rail's Year: a year and the Upload-date control are the same narrowing, so picking a year
|
||||
// clears an upload-date window or range.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import YearFilter from "./YearFilter";
|
||||
import { api, type FeedFilters } from "../../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const BASE: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: { language: "or", topic: "or", other: "or" },
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
channelIds: [],
|
||||
};
|
||||
|
||||
describe("the feed rail's Year", () => {
|
||||
it("picking a year clears the Upload-date window and range", async () => {
|
||||
vi.spyOn(api, "feedYears").mockResolvedValue({ years: [{ year: 2010, count: 3 }] });
|
||||
const setFilters = vi.fn();
|
||||
const filters = { ...BASE, maxAgeDays: 7, dateFrom: "2020-01-01", dateTo: "2020-02-01" };
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<YearFilter filters={filters} setFilters={setFilters} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: /2010/ }));
|
||||
expect(setFilters).toHaveBeenCalledWith({
|
||||
...filters,
|
||||
year: 2010,
|
||||
maxAgeDays: undefined,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,21 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { MenuCheck } from "../../components/ui/MenuCheck";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { api, type FeedFilters, type YearCount } from "../../lib/api";
|
||||
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { Chip } from "../../components/ui/Chip";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import CountBadge from "../../components/ui/CountBadge";
|
||||
import YearPicker from "../../components/ui/YearPicker";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// The feed rail's "Year" control: quick chips for the most recent years plus a
|
||||
// "More…" popover listing ALL years with per-year counts, sortable by year or count. Selecting a year
|
||||
// filters the feed to that calendar year (a whole-year published_after/before range, applied in the
|
||||
// api layer); it's a single-select toggle (click the active year to clear it) and mutually exclusive
|
||||
// with the Upload-date control — picking a year clears any maxAge/custom range, and vice versa.
|
||||
// The feed rail's "Year" control (ui/YearPicker: recent-year chips and a "More" dialog of every
|
||||
// year). Selecting a year filters the feed to that calendar year (a whole-year
|
||||
// published_after/before range, applied in the api layer); it is mutually exclusive with the
|
||||
// Upload-date control — picking a year clears any maxAge/custom range, and vice versa.
|
||||
//
|
||||
// The per-year counts come from /feed/years, which drops the publish-date narrowing itself, so the
|
||||
// list is keyed on the date-stripped filters: picking a year re-queries the FEED but never this list
|
||||
// (its numbers wouldn't change) — the same drill-down principle as the tag facets dropping their
|
||||
// own category.
|
||||
|
||||
const QUICK = 6; // recent-year chips shown before the "More…" overflow
|
||||
// One shared empty list while the counts load: a fresh `[]` per render made the memos below recompute.
|
||||
// One shared empty list while the counts load: a fresh `[]` per render would change the picker's input.
|
||||
const NO_YEARS: YearCount[] = [];
|
||||
|
||||
export default function YearFilter({
|
||||
@@ -36,8 +26,6 @@ export default function YearFilter({
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sortByCount, setSortByCount] = useState(false);
|
||||
|
||||
// Debounce the search term (like the sibling summary facet in Sidebar) so typing doesn't refire the
|
||||
// per-year count query on every keystroke — each distinct q is a fresh cache key, so without this
|
||||
@@ -58,39 +46,6 @@ export default function YearFilter({
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const years = yearsQuery.data?.years ?? NO_YEARS;
|
||||
const selected = filters.year;
|
||||
|
||||
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
|
||||
const pick = (year?: number) =>
|
||||
setFilters({ ...filters, year, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined });
|
||||
|
||||
// Quick chips = the most recent years, plus the selected year if it falls outside that window (so the
|
||||
// active pick is always visible), re-sorted newest-first.
|
||||
const quick = useMemo(() => {
|
||||
const top = years.slice(0, QUICK);
|
||||
if (selected != null && !top.some((y) => y.year === selected)) {
|
||||
const sel = years.find((y) => y.year === selected) ?? { year: selected, count: 0 };
|
||||
return [...top, sel].sort((a, b) => b.year - a.year);
|
||||
}
|
||||
return top;
|
||||
}, [years, selected]);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...years].sort((a, b) =>
|
||||
sortByCount ? b.count - a.count || b.year - a.year : b.year - a.year,
|
||||
),
|
||||
[years, sortByCount],
|
||||
);
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
// Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row).
|
||||
if (years.length === 0) {
|
||||
@@ -112,85 +67,19 @@ export default function YearFilter({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{quick.map((y) => {
|
||||
const on = selected === y.year;
|
||||
return (
|
||||
<Chip key={y.year} pressed={on} onClick={() => pick(on ? undefined : y.year)}>
|
||||
<span className="tabular-nums">{y.year}</span>
|
||||
{y.count > 0 && <CountBadge count={y.count} active={on} />}
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
|
||||
{years.length > quick.length && (
|
||||
<>
|
||||
<Chip
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={t("sidebar.year.moreLabel")}
|
||||
>
|
||||
<span>{t("sidebar.year.more")}</span>
|
||||
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Chip>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("sidebar.year.moreLabel")}
|
||||
className={`${SURFACE.menu} min-w-44 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 border-b border-border px-1 pb-2">
|
||||
<span className="text-2xs text-muted">{t("sidebar.year.sortBy")}</span>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(
|
||||
[
|
||||
["sortYear", false],
|
||||
["sortCount", true],
|
||||
] as const
|
||||
).map(([key, v]) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
pressed={sortByCount === v}
|
||||
onClick={() => setSortByCount(v)}
|
||||
>
|
||||
{t("sidebar.year." + key)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="max-h-64 overflow-y-auto no-scrollbar"
|
||||
role="group"
|
||||
aria-label={t("sidebar.year.moreLabel")}
|
||||
>
|
||||
{sorted.map((y) => {
|
||||
const on = selected === y.year;
|
||||
return (
|
||||
<PopoverItem
|
||||
key={y.year}
|
||||
role="menuitemradio"
|
||||
checked={on}
|
||||
onSelect={() => {
|
||||
pick(on ? undefined : y.year);
|
||||
setOpen(false);
|
||||
}}
|
||||
label={String(y.year)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<MenuCheck kind="one" on={on} />
|
||||
<span className="flex-1 tabular-nums">{y.year}</span>
|
||||
<span className="text-2xs text-muted shrink-0 tabular-nums">{y.count}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<YearPicker
|
||||
years={years}
|
||||
selected={filters.year}
|
||||
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
|
||||
onPick={(year) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
year,
|
||||
maxAgeDays: undefined,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { api, oneYear, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api";
|
||||
import { formatBytes, formatCompact, formatDecimal, formatRuntime } from "../../lib/format";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
||||
@@ -427,11 +427,7 @@ export default function PlexInfo({
|
||||
title={
|
||||
onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined
|
||||
}
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ yearMin: detail.year, yearMax: detail.year })
|
||||
: undefined
|
||||
}
|
||||
onClick={onFilter ? () => onFilter(oneYear(detail.year)) : undefined}
|
||||
>
|
||||
{detail.year}
|
||||
</Filterable>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { usePlexCollectionTitles } from "../../lib/usePlexCollectionTitles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -6,7 +6,14 @@ import { vocab } from "../../lib/vocab";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
|
||||
import { api, plexFilterCount, type PlexFilters } from "../../lib/api";
|
||||
import {
|
||||
api,
|
||||
oneYear,
|
||||
plexFilterCount,
|
||||
singleYear,
|
||||
type PlexFacets,
|
||||
type PlexFilters,
|
||||
} from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
@@ -21,6 +28,8 @@ import { usePlexSavedViews } from "./plexSavedViews";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { DismissibleChip } from "../../components/ui/Chip";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import YearPicker from "../../components/ui/YearPicker";
|
||||
import { StateMessage } from "../../components/ui/QueryState";
|
||||
import { useRail } from "../../lib/useRail";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
@@ -77,6 +86,22 @@ export default function PlexSidebar() {
|
||||
// just lists them as removable chips. Studios now have their own picker section (below).
|
||||
const peopleActive = filters.directors.length + filters.actors.length > 0;
|
||||
|
||||
// A section the facets fill, shown when its facet has values.
|
||||
const facetSection = (
|
||||
id: string,
|
||||
title: string,
|
||||
has: (f: PlexFacets) => boolean,
|
||||
body: (f: PlexFacets) => ReactNode,
|
||||
): PanelItem[] => (facets && has(facets) ? [{ id, title, body: body(facets) }] : []);
|
||||
// While the facets are not here, the rail says so ONCE — a first load or a failed one must not
|
||||
// read as a library with no genres (honest states) — with a way to try again. In the Year section,
|
||||
// which every library has; in the rail's foot if the reader hid it.
|
||||
const facetsStatus =
|
||||
facets || (!facetsQ.isError && !facetsQ.isLoading) ? null : (
|
||||
<FacetsStatus failed={facetsQ.isError} onRetry={() => void facetsQ.refetch()} />
|
||||
);
|
||||
const yearHidden = !!layout.hidden.year;
|
||||
|
||||
// Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed,
|
||||
// so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved
|
||||
// layout and lets the user reorder / hide / collapse them.
|
||||
@@ -146,46 +171,36 @@ export default function PlexSidebar() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(facets && facets.genres.length > 0
|
||||
? [
|
||||
{
|
||||
id: "genre",
|
||||
title: t("plex.filter.genre"),
|
||||
body: (
|
||||
<>
|
||||
<MatchToggle
|
||||
picked={filters.genres.length}
|
||||
value={filters.genreMode ?? "any"}
|
||||
onChange={(m) => patch({ genreMode: m })}
|
||||
/>
|
||||
<Combobox
|
||||
label={t("plex.filter.genre")}
|
||||
options={facets.genres}
|
||||
selected={filters.genres}
|
||||
onChange={(next) => patch({ genres: next })}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...facetSection(
|
||||
"genre",
|
||||
t("plex.filter.genre"),
|
||||
(f) => f.genres.length > 0,
|
||||
(f) => (
|
||||
<>
|
||||
<MatchToggle
|
||||
picked={filters.genres.length}
|
||||
value={filters.genreMode ?? "any"}
|
||||
onChange={(m) => patch({ genreMode: m })}
|
||||
/>
|
||||
<Combobox
|
||||
label={t("plex.filter.genre")}
|
||||
options={f.genres}
|
||||
selected={filters.genres}
|
||||
onChange={(next) => patch({ genres: next })}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
),
|
||||
{
|
||||
id: "year",
|
||||
title: t("plex.filter.year"),
|
||||
body: (
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput
|
||||
value={filters.yearMin ?? null}
|
||||
placeholder={facets?.year_min ?? undefined}
|
||||
onChange={(v) => patch({ yearMin: v })}
|
||||
/>
|
||||
<span className="text-muted text-xs">–</span>
|
||||
<NumberInput
|
||||
value={filters.yearMax ?? null}
|
||||
placeholder={facets?.year_max ?? undefined}
|
||||
onChange={(v) => patch({ yearMax: v })}
|
||||
/>
|
||||
</div>
|
||||
<PlexYearFilter
|
||||
filters={filters}
|
||||
facets={facets}
|
||||
status={yearHidden ? null : facetsStatus}
|
||||
patch={patch}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(scope === "movie"
|
||||
@@ -233,62 +248,55 @@ export default function PlexSidebar() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(facets && facets.content_ratings.length > 0
|
||||
? [
|
||||
{
|
||||
id: "contentrating",
|
||||
title: t("plex.filter.contentRating"),
|
||||
body: (
|
||||
<Combobox
|
||||
label={t("plex.filter.contentRating")}
|
||||
options={facets.content_ratings.map((o) => ({
|
||||
...o,
|
||||
label: vocab(t, "certificate", o.value),
|
||||
}))}
|
||||
selected={filters.contentRatings}
|
||||
onChange={(next) => patch({ contentRatings: next })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(facets && facets.studios.length > 0
|
||||
? [
|
||||
{
|
||||
id: "studio",
|
||||
title: t("plex.filter.studio"),
|
||||
body: (
|
||||
<Combobox
|
||||
label={t("plex.filter.studio")}
|
||||
options={facets.studios}
|
||||
selected={filters.studios}
|
||||
onChange={(next) => patch({ studios: next })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...facetSection(
|
||||
"contentrating",
|
||||
t("plex.filter.contentRating"),
|
||||
(f) => f.content_ratings.length > 0,
|
||||
(f) => (
|
||||
<Combobox
|
||||
label={t("plex.filter.contentRating")}
|
||||
options={f.content_ratings.map((o) => ({
|
||||
...o,
|
||||
label: vocab(t, "certificate", o.value),
|
||||
}))}
|
||||
selected={filters.contentRatings}
|
||||
onChange={(next) => patch({ contentRatings: next })}
|
||||
/>
|
||||
),
|
||||
),
|
||||
...facetSection(
|
||||
"studio",
|
||||
t("plex.filter.studio"),
|
||||
(f) => f.studios.length > 0,
|
||||
(f) => (
|
||||
<Combobox
|
||||
label={t("plex.filter.studio")}
|
||||
options={f.studios}
|
||||
selected={filters.studios}
|
||||
onChange={(next) => patch({ studios: next })}
|
||||
/>
|
||||
),
|
||||
),
|
||||
// Playback class (Direct Play / Direct Stream / Transcode HW/SW) — movie-only (shows have no
|
||||
// class), and dropped when the facet is empty. Labels are localized (the facet ships class keys).
|
||||
...(scope === "movie" && facets && facets.playable.length > 0
|
||||
? [
|
||||
{
|
||||
id: "playable",
|
||||
title: t("plex.filter.playable"),
|
||||
body: (
|
||||
<Combobox
|
||||
label={t("plex.filter.playable")}
|
||||
options={facets.playable.map((p) => ({
|
||||
value: p.value,
|
||||
label: t(`plex.filter.playableOpt.${p.value}`),
|
||||
count: p.count,
|
||||
}))}
|
||||
selected={filters.playable}
|
||||
onChange={(next) => patch({ playable: next })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
...(scope === "movie"
|
||||
? facetSection(
|
||||
"playable",
|
||||
t("plex.filter.playable"),
|
||||
(f) => f.playable.length > 0,
|
||||
(f) => (
|
||||
<Combobox
|
||||
label={t("plex.filter.playable")}
|
||||
options={f.playable.map((p) => ({
|
||||
value: p.value,
|
||||
label: t(`plex.filter.playableOpt.${p.value}`),
|
||||
count: p.count,
|
||||
}))}
|
||||
selected={filters.playable}
|
||||
onChange={(next) => patch({ playable: next })}
|
||||
/>
|
||||
),
|
||||
)
|
||||
: []),
|
||||
];
|
||||
|
||||
@@ -312,7 +320,9 @@ export default function PlexSidebar() {
|
||||
iconExpanded={<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />}
|
||||
onTitleClick={openModal}
|
||||
titleActionLabel={t("views.manageTitle")}
|
||||
/>
|
||||
>
|
||||
{yearHidden && facetsStatus}
|
||||
</FilterRail>
|
||||
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
|
||||
{modalOpen && <PersonalViewsModal />}
|
||||
</SavedViewsScope>
|
||||
@@ -483,17 +493,82 @@ function RemovableChip({ label, onRemove }: { label: string; onRemove: () => voi
|
||||
);
|
||||
}
|
||||
|
||||
/** The rail's Year section: the year picker over the range fields. One year is a range of one:
|
||||
* picking it sets both ends, as the year on an info page does, so the range fields show it and a
|
||||
* decade stays typeable there. */
|
||||
export function PlexYearFilter({
|
||||
filters,
|
||||
facets,
|
||||
status,
|
||||
patch,
|
||||
}: {
|
||||
filters: PlexFilters;
|
||||
facets: PlexFacets | undefined;
|
||||
/** The rail's word on facets still loading or failed, when it shows here. */
|
||||
status?: ReactNode;
|
||||
patch: (p: Partial<PlexFilters>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{status}
|
||||
{facets && facets.years.length > 0 && (
|
||||
<YearPicker
|
||||
years={facets.years}
|
||||
selected={singleYear(filters)}
|
||||
onPick={(year) => patch(oneYear(year))}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput
|
||||
label={t("plex.filter.yearFrom")}
|
||||
value={filters.yearMin ?? null}
|
||||
placeholder={facets?.year_min ?? undefined}
|
||||
onChange={(v) => patch({ yearMin: v })}
|
||||
/>
|
||||
<span className="text-muted text-xs">–</span>
|
||||
<NumberInput
|
||||
label={t("plex.filter.yearTo")}
|
||||
value={filters.yearMax ?? null}
|
||||
placeholder={facets?.year_max ?? undefined}
|
||||
onChange={(v) => patch({ yearMax: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The facets still loading, or failed with a way to try again — said once for the whole rail. */
|
||||
export function FacetsStatus({ failed, onRetry }: { failed: boolean; onRetry: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
if (failed)
|
||||
return (
|
||||
<StateMessage tone="error" compact onRetry={onRetry}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
);
|
||||
return (
|
||||
<p className="text-muted px-1 py-1.5 text-xs" role="status">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
/** Its accessible name: the placeholder (a year) is not one. */
|
||||
label: string;
|
||||
value: number | null;
|
||||
placeholder?: number;
|
||||
onChange: (v: number | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
aria-label={label}
|
||||
className="w-20 tabular-nums"
|
||||
size="sm"
|
||||
type="number"
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The rail's Year section: a year from the picker is a range of one (both ends), the picker shows a
|
||||
// range of one as its selected year, and the two range fields carry a name of their own.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("./plexContext", () => ({
|
||||
usePlex: () => ({}),
|
||||
usePlexActions: () => ({ setPlaylistOpen: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { FacetsStatus, PlexYearFilter } from "./PlexSidebar";
|
||||
import { EMPTY_PLEX_FILTERS, type PlexFacets } from "../../lib/api";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const FACETS = {
|
||||
years: [
|
||||
{ year: 2001, count: 4 },
|
||||
{ year: 1999, count: 2 },
|
||||
],
|
||||
year_min: 1999,
|
||||
year_max: 2001,
|
||||
} as unknown as PlexFacets;
|
||||
|
||||
describe("the Plex rail's Year section", () => {
|
||||
it("picks a year as a range of one, and clears both ends again", () => {
|
||||
const patch = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PlexYearFilter filters={EMPTY_PLEX_FILTERS} facets={FACETS} patch={patch} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /1999/ }));
|
||||
expect(patch).toHaveBeenLastCalledWith({ yearMin: 1999, yearMax: 1999 });
|
||||
|
||||
rerender(
|
||||
<PlexYearFilter
|
||||
filters={{ ...EMPTY_PLEX_FILTERS, yearMin: 1999, yearMax: 1999 }}
|
||||
facets={FACETS}
|
||||
patch={patch}
|
||||
/>,
|
||||
);
|
||||
const on = screen.getByRole("button", { pressed: true });
|
||||
expect(on.textContent).toContain("1999");
|
||||
fireEvent.click(on);
|
||||
expect(patch).toHaveBeenLastCalledWith({ yearMin: null, yearMax: null });
|
||||
});
|
||||
|
||||
it("a wider range selects no year", () => {
|
||||
render(
|
||||
<PlexYearFilter
|
||||
filters={{ ...EMPTY_PLEX_FILTERS, yearMin: 1999, yearMax: 2001 }}
|
||||
facets={FACETS}
|
||||
patch={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { pressed: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("says the facets are loading, or failed with a way to try again, rather than showing none", () => {
|
||||
const retry = vi.fn();
|
||||
const { rerender } = render(<FacetsStatus failed={false} onRetry={retry} />);
|
||||
expect(screen.getByRole("status").textContent).toBe("common.loading");
|
||||
rerender(<FacetsStatus failed onRetry={retry} />);
|
||||
expect(screen.getByRole("alert").textContent).toContain("common.loadError");
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.retry" }));
|
||||
expect(retry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("names the range fields", () => {
|
||||
render(<PlexYearFilter filters={EMPTY_PLEX_FILTERS} facets={FACETS} patch={() => {}} />);
|
||||
expect(screen.getByRole("spinbutton", { name: "plex.filter.yearFrom" })).toBeTruthy();
|
||||
expect(screen.getByRole("spinbutton", { name: "plex.filter.yearTo" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PlexFilters } from "../../lib/api";
|
||||
import { oneYear, 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";
|
||||
@@ -75,7 +75,7 @@ function FactsSection({ facts, onFilter }: { facts: DetailFacts; onFilter?: OnFi
|
||||
<Filterable
|
||||
tag
|
||||
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
|
||||
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
|
||||
onClick={onFilter ? () => onFilter(oneYear(year)) : undefined}
|
||||
>
|
||||
{year}
|
||||
</Filterable>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import {
|
||||
api,
|
||||
oneYear,
|
||||
type PlexCard,
|
||||
type PlexFilters,
|
||||
type PlexPersonDetail,
|
||||
@@ -613,11 +614,7 @@ export function PlexShowView({
|
||||
<Filterable
|
||||
tag
|
||||
title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined}
|
||||
onClick={
|
||||
onFilter
|
||||
? () => onFilter({ yearMin: show.year, yearMax: show.year })
|
||||
: undefined
|
||||
}
|
||||
onClick={onFilter ? () => onFilter(oneYear(show.year)) : undefined}
|
||||
>
|
||||
{show.year}
|
||||
</Filterable>
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: { count?: number }) =>
|
||||
opts?.count != null ? `${key}:${opts.count}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user