Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc1f196a2c | ||
|
|
e3dac34bb0 | ||
|
|
938135bb59 | ||
|
|
7d2bee59a3 | ||
|
|
6af238c2ea | ||
|
|
b71b33654a | ||
|
|
5d99a5f253 | ||
|
|
c0d869089d | ||
|
|
15f5a5694a | ||
|
|
14a44bff6e | ||
|
|
37d11b602f | ||
|
|
da3cb5e694 | ||
|
|
1e5232f47a | ||
|
|
dddaeb9a38 | ||
|
|
70a74f8629 | ||
|
|
8f1309fa18 | ||
|
|
b7a4eaff8d | ||
|
|
a20e27800c | ||
|
|
fa7a09a420 | ||
|
|
f983a491c0 | ||
|
|
f85dc12819 | ||
|
|
9798e506cf | ||
|
|
a60164edc4 | ||
|
|
6c7038cf1f | ||
|
|
f8e144a658 | ||
|
|
b4984dc38b | ||
|
|
21c9b34540 | ||
|
|
041cd526b7 | ||
|
|
9477fea3dc | ||
|
|
e4b19b5320 | ||
|
|
7692253636 | ||
|
|
e90603d4db | ||
|
|
dd76e9dcf7 | ||
|
|
fca989c098 | ||
|
|
7ebbf45921 | ||
|
|
829cadf5fb | ||
|
|
8f08073564 | ||
|
|
af08a7850b | ||
|
|
d2193ccd1b | ||
|
|
cda70164ba | ||
|
|
5e1cc4d981 | ||
|
|
4ee452b854 | ||
|
|
edbc9dede5 | ||
|
|
53ca4e2849 | ||
|
|
1801a58f6b | ||
|
|
011acd919e | ||
|
|
45d8b47366 | ||
|
|
c3820a0b44 | ||
|
|
c7b393901f | ||
|
|
f1e14c94c5 | ||
|
|
ddb171fca3 | ||
|
|
ec3bbc78dc | ||
|
|
1be1dc9d06 | ||
|
|
6109e0c6f1 | ||
|
|
5851ddd329 | ||
|
|
ad0cf5827b | ||
|
|
f6192aa6bf |
@@ -0,0 +1,39 @@
|
||||
"""saved_views.source (ask 4: per-module saved views)
|
||||
|
||||
Saved "smart views" were feed-only; the Plex module now has them too. Add a `source` discriminator
|
||||
(default "feed" so every existing row stays a feed view) and widen the per-user unique name constraint
|
||||
to (user_id, source, name) so a feed view and a Plex view can share a name. `source` is NOT NULL with a
|
||||
server_default, so the ADD is a metadata-only rewrite-free change; the constraint swap is quick on a
|
||||
small per-user table.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0073_saved_view_source"
|
||||
down_revision: Union[str, None] = "0072_plex_person_bio"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"saved_views",
|
||||
sa.Column("source", sa.String(length=16), nullable=False, server_default="feed"),
|
||||
)
|
||||
op.drop_constraint("uq_user_saved_view", "saved_views", type_="unique")
|
||||
op.create_unique_constraint(
|
||||
"uq_user_source_saved_view", "saved_views", ["user_id", "source", "name"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# LOSSY / effectively forward-only: upgrade lets a user hold a feed view and a plex view of the same
|
||||
# name, so re-creating the narrow (user_id, name) constraint can raise IntegrityError on such a pair.
|
||||
# De-dup by dropping the non-feed rows first (they can't exist in the pre-0073 schema anyway) so the
|
||||
# narrow constraint re-applies cleanly for the drift test's downgrade→re-upgrade cycle.
|
||||
op.execute("DELETE FROM saved_views WHERE source <> 'feed'")
|
||||
op.drop_constraint("uq_user_source_saved_view", "saved_views", type_="unique")
|
||||
op.create_unique_constraint("uq_user_saved_view", "saved_views", ["user_id", "name"])
|
||||
op.drop_column("saved_views", "source")
|
||||
+11
-10
@@ -117,7 +117,7 @@ def filtered_query(
|
||||
user: User,
|
||||
*,
|
||||
tags: list[int],
|
||||
tag_mode: str,
|
||||
tag_modes: dict[str, str],
|
||||
channel_ids: list[str],
|
||||
q: str | None,
|
||||
min_duration: int | None,
|
||||
@@ -293,9 +293,10 @@ def filtered_query(
|
||||
query = query.where(or_(func.unaccent(Video.title).ilike(like), channel_match))
|
||||
|
||||
if tags:
|
||||
# AND across tag categories (e.g. language AND topic narrows), OR within a category; the any/all
|
||||
# toggle controls multiple topic tags. ⚠️ feed_summary.passes() reimplements this exact rule in
|
||||
# Python for the sidebar count/facets — keep the two in lockstep (equivalence-tested by
|
||||
# AND across tag categories (e.g. language AND topic narrows), OR within a category — unless that
|
||||
# category's mode is "and" (its per-category any/all toggle), which requires a channel to carry
|
||||
# ALL its selected tags. ⚠️ feed_summary.passes() reimplements this exact rule in Python for the
|
||||
# sidebar count/facets — keep the two in lockstep (equivalence-tested by
|
||||
# test_feed_summary_matches_count_plus_facets).
|
||||
by_category = tags_by_category(db, tags)
|
||||
# Facet counting drops the category being counted so its own chips don't zero each
|
||||
@@ -305,7 +306,7 @@ def filtered_query(
|
||||
by_category.pop(exclude_tag_category, None)
|
||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||
for category, ids in by_category.items():
|
||||
if category == "topic" and tag_mode == "and" and len(ids) > 1:
|
||||
if tag_modes.get(category, "or") == "and" and len(ids) > 1:
|
||||
sub = (
|
||||
select(ChannelTag.channel_id)
|
||||
.where(ChannelTag.tag_id.in_(ids), visible)
|
||||
@@ -363,8 +364,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
(the tag's own category dropped, unless it's a conjunctive topic-AND — mirroring the
|
||||
old /facets exclude_tag_category logic exactly).
|
||||
Tag matching is channel-based, identical to filtered_query's tag block (AND across categories, OR
|
||||
within, topic-AND requires ALL selected topics)."""
|
||||
tag_mode = params.get("tag_mode", "or")
|
||||
within, a category in "and" mode requires ALL its selected tags)."""
|
||||
tag_modes = params.get("tag_modes", {})
|
||||
|
||||
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
|
||||
# is present to facet over.
|
||||
@@ -401,8 +402,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
for cat, sel in selected_by_cat.items():
|
||||
if cat == exclude_cat:
|
||||
continue
|
||||
if cat == "topic" and tag_mode == "and" and len(sel) > 1:
|
||||
if not sel <= ch_tags: # conjunctive: channel must carry ALL selected topics
|
||||
if tag_modes.get(cat, "or") == "and" and len(sel) > 1:
|
||||
if not sel <= ch_tags: # conjunctive: channel must carry ALL this category's selected tags
|
||||
return False
|
||||
elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag
|
||||
return False
|
||||
@@ -416,7 +417,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
for category in ("language", "topic", "other"):
|
||||
# Drop this category's own selections so its chips keep independent counts; a conjunctive
|
||||
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
|
||||
exclude = None if (category == "topic" and tag_mode == "and") else category
|
||||
exclude = None if tag_modes.get(category, "or") == "and" else category
|
||||
for cid, ch_tags in channel_tags.items():
|
||||
if not passes(ch_tags, exclude):
|
||||
continue
|
||||
|
||||
@@ -646,19 +646,23 @@ class PlaylistItem(Base):
|
||||
|
||||
|
||||
class SavedView(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A per-user named snapshot of the feed's filter/sort state (a "smart view").
|
||||
"""A per-user named snapshot of a module's filter/sort state (a "smart view").
|
||||
|
||||
`filters` is the serialized FeedFilters JSON the frontend applies with one click.
|
||||
`position` orders them in the sidebar (drag-reorder); `is_default` marks the one view
|
||||
applied automatically on load — at most one per user, enforced by the API on set."""
|
||||
`source` names the module the view belongs to ("feed" | "plex"); `filters` is that module's
|
||||
serialized filter JSON the frontend applies with one click. `position` orders them in the sidebar
|
||||
(drag-reorder, per source); `is_default` marks the one view applied automatically on load — at most
|
||||
one per (user, source), enforced by the API on set. The name is unique per (user, source)."""
|
||||
|
||||
__tablename__ = "saved_views"
|
||||
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_user_saved_view"),)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "source", "name", name="uq_user_source_saved_view"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE")
|
||||
)
|
||||
source: Mapped[str] = mapped_column(String(16), default="feed", server_default="feed")
|
||||
name: Mapped[str] = mapped_column(String(80))
|
||||
filters: Mapped[dict] = mapped_column(JSON)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
|
||||
@@ -6,7 +6,7 @@ Pure query fragments — every function returns SQLAlchemy expressions or a para
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import and_, case, func, or_, type_coerce
|
||||
from sqlalchemy import and_, case, func, or_, select, type_coerce
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
TS_CONFIG = "public.unaccent_simple"
|
||||
@@ -41,6 +41,58 @@ def genres_col(model, lang: str):
|
||||
return model.genres
|
||||
|
||||
|
||||
def top_art_keys(
|
||||
db, model, lib_ids: list[int], key_col, keys: list[str], *, per: int = 4, extra_conds=()
|
||||
) -> dict[str, list[str]]:
|
||||
"""For each value in `keys` (a member of the JSONB array `key_col` — e.g. a genre value or a
|
||||
collection rating_key), the rating_keys of its top `per` items (best-rated, then most-recent) as
|
||||
{key: [rating_key, …]}. ONE windowed query: unnest the array, rank within each key, take the top.
|
||||
The caller renders posters from /api/plex/image/{rating_key}, so only the keys are returned."""
|
||||
if not lib_ids or not keys:
|
||||
return {}
|
||||
k = func.jsonb_array_elements_text(key_col).label("k")
|
||||
expanded = (
|
||||
select(model.rating_key.label("rk"), model.rating.label("r"), model.added_at.label("a"), k)
|
||||
.where(model.library_id.in_(lib_ids), func.jsonb_typeof(key_col) == "array", *extra_conds)
|
||||
.subquery()
|
||||
)
|
||||
ranked = select(
|
||||
expanded.c.rk,
|
||||
expanded.c.k,
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=expanded.c.k,
|
||||
order_by=(expanded.c.r.desc().nullslast(), expanded.c.a.desc().nullslast()),
|
||||
)
|
||||
.label("rn"),
|
||||
).subquery()
|
||||
out: dict[str, list[str]] = {}
|
||||
for rk, key in db.execute(
|
||||
select(ranked.c.rk, ranked.c.k)
|
||||
.where(ranked.c.k.in_(keys), ranked.c.rn <= per)
|
||||
.order_by(ranked.c.k, ranked.c.rn)
|
||||
).all():
|
||||
out.setdefault(key, []).append(rk)
|
||||
return out
|
||||
|
||||
|
||||
def runtime_by_key(db, model, lib_ids: list[int], key_col, keys: list[str]) -> dict[str, int]:
|
||||
"""Total runtime (sum of duration_s, seconds) of the items under each key in `keys` (a member of the
|
||||
JSONB array `key_col`), as {key: seconds}. ONE grouped query. Used for a collection tile's runtime."""
|
||||
if not lib_ids or not keys:
|
||||
return {}
|
||||
k = func.jsonb_array_elements_text(key_col).label("k")
|
||||
exp = (
|
||||
select(func.coalesce(model.duration_s, 0).label("d"), k)
|
||||
.where(model.library_id.in_(lib_ids), func.jsonb_typeof(key_col) == "array")
|
||||
.subquery()
|
||||
)
|
||||
rows = db.execute(
|
||||
select(exp.c.k, func.sum(exp.c.d)).where(exp.c.k.in_(keys)).group_by(exp.c.k)
|
||||
).all()
|
||||
return {key: int(total or 0) for key, total in rows}
|
||||
|
||||
|
||||
def vector_col(model, lang: str):
|
||||
# RANKING vector. In HU we rank by the Hungarian vector; a row matched only via its English vector
|
||||
# (see `vector_match`) gets rank 0 and sorts last, which is the intended HU-first ordering.
|
||||
@@ -142,8 +194,10 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list:
|
||||
conds.append(model.studio.in_(stds))
|
||||
for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page (E5 S5)
|
||||
conds.append(model.keywords.contains([kw]))
|
||||
if p.get("collection"):
|
||||
conds.append(model.collection_keys.contains([p["collection"]]))
|
||||
csel = split_csv(p.get("collections"))
|
||||
if csel: # OR (any) or AND (all) — a title can belong to several collections, mirroring genre_mode
|
||||
cc = [model.collection_keys.contains([c]) for c in csel]
|
||||
conds.append(and_(*cc) if p.get("collection_mode") == "all" else or_(*cc))
|
||||
return conds
|
||||
|
||||
|
||||
@@ -174,7 +228,8 @@ class LibraryFilters:
|
||||
)
|
||||
studios: str | None = None
|
||||
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
|
||||
collection: str | None = None
|
||||
collections: str | None = None # CSV of collection rating_keys
|
||||
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
@@ -192,7 +247,8 @@ class LibraryFilters:
|
||||
"actor_mode": self.actor_mode,
|
||||
"studios": self.studios,
|
||||
"keywords": self.keywords,
|
||||
"collection": self.collection,
|
||||
"collections": self.collections,
|
||||
"collection_mode": self.collection_mode,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,11 @@ FINISH_MARGIN_SECONDS = 10
|
||||
# Shared query parameters for /feed and /feed/count.
|
||||
def _feed_params(
|
||||
tags: list[int] = Query(default=[]),
|
||||
tag_mode: str = "or",
|
||||
# Per-category tag match: each of language / topic / other independently ORs (any) or ANDs (all)
|
||||
# its own selected tags. Defaults to "or". (Superseded the single global `tag_mode`.)
|
||||
tag_mode_language: str = "or",
|
||||
tag_mode_topic: str = "or",
|
||||
tag_mode_other: str = "or",
|
||||
channel_ids: list[str] = Query(default=[]),
|
||||
q: str | None = None,
|
||||
min_duration: int | None = None,
|
||||
@@ -65,7 +69,11 @@ def _feed_params(
|
||||
) -> dict:
|
||||
return {
|
||||
"tags": tags,
|
||||
"tag_mode": tag_mode,
|
||||
"tag_modes": {
|
||||
"language": tag_mode_language,
|
||||
"topic": tag_mode_topic,
|
||||
"other": tag_mode_other,
|
||||
},
|
||||
"channel_ids": channel_ids,
|
||||
"q": q,
|
||||
"min_duration": min_duration,
|
||||
@@ -162,7 +170,7 @@ def get_facets(
|
||||
# independent counts (you can OR more of them in). Conjunctive (AND, topics only)
|
||||
# keeps them applied, so each remaining chip narrows to channels that ALSO have all
|
||||
# already-selected topics — and tags that can't co-occur drop to zero (hidden).
|
||||
conjunctive = category == "topic" and params.get("tag_mode") == "and"
|
||||
conjunctive = params.get("tag_modes", {}).get(category) == "and"
|
||||
base, _rank = filtered_query(
|
||||
db,
|
||||
user,
|
||||
|
||||
@@ -9,13 +9,33 @@ from app.auth import admin_user, current_user
|
||||
from app.db import get_db
|
||||
from app.models import (
|
||||
PlexCollection,
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexShow,
|
||||
User,
|
||||
)
|
||||
from app.plex import rollup
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex.cards import movie_card, show_card
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex.lookups import by_rating_key
|
||||
from app.plex.query import like_escape
|
||||
from app.plex.query import like_escape, runtime_by_key, top_art_keys
|
||||
from app.routes.plex._shared import _content_libs
|
||||
|
||||
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
|
||||
# members (Plex shows one cert on the collection; we don't store it, so derive it).
|
||||
_CERT_RANK = {
|
||||
"G": 1, "TV-Y": 1, "TV-Y7": 1, "TV-G": 1,
|
||||
"PG": 2, "TV-PG": 2,
|
||||
"PG-13": 3, "TV-14": 3,
|
||||
"R": 4, "TV-MA": 4,
|
||||
"NC-17": 5,
|
||||
}
|
||||
|
||||
|
||||
def _collection_cert(certs: list[str | None]) -> str | None:
|
||||
ranked = [(c, _CERT_RANK.get(c, 0)) for c in certs if c]
|
||||
return max(ranked, key=lambda x: x[1])[0] if ranked else None
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
@@ -93,7 +113,74 @@ def collections(
|
||||
func.lower(PlexCollection.title),
|
||||
)
|
||||
).scalars().all()
|
||||
return {"collections": [_collection_card(c) for c in rows]}
|
||||
# A montage of the top titles per tile + the collection's total runtime. Art unions MOVIE and SHOW
|
||||
# members (a collection lives in one library, so exactly one side has art) — a show collection would
|
||||
# otherwise render blank; the collection's own poster is the next fallback, then the colour hash.
|
||||
# Runtime is movie-only (shows have no single duration).
|
||||
movie_ids, show_ids = _content_libs(db)
|
||||
keys = [c.rating_key for c in rows]
|
||||
art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2)
|
||||
art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2)
|
||||
runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys)
|
||||
|
||||
def _art(c: PlexCollection) -> list[str]:
|
||||
member = art_m.get(c.rating_key) or art_s.get(c.rating_key)
|
||||
return member or ([c.rating_key] if c.thumb_key else [])
|
||||
|
||||
return {
|
||||
"collections": [
|
||||
{
|
||||
**_collection_card(c),
|
||||
"art": _art(c),
|
||||
"total_runtime_s": runtime.get(c.rating_key, 0),
|
||||
}
|
||||
for c in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/collections/{rating_key}")
|
||||
def collection_detail(
|
||||
rating_key: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""One collection's hero data + its members (movies + shows), ordered by release year then title —
|
||||
the Collection info page. Read-only here; edits go through the admin mutation endpoints below."""
|
||||
col = by_rating_key(db, PlexCollection, rating_key)
|
||||
if col is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown collection")
|
||||
movies = db.execute(
|
||||
select(PlexItem)
|
||||
.where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key]))
|
||||
.order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title))
|
||||
).scalars().all()
|
||||
shows = db.execute(
|
||||
select(PlexShow)
|
||||
.where(PlexShow.collection_keys.contains([rating_key]))
|
||||
.order_by(PlexShow.year.asc().nullslast(), func.lower(PlexShow.title))
|
||||
).scalars().all()
|
||||
mstate = rollup.item_state_map(db, user.id, [m.id for m in movies])
|
||||
sstate = rollup.state_map(db, user.id, [s.id for s in shows])
|
||||
# Merge the two type-homogeneous lists into ONE member list ordered by (year, title).
|
||||
tagged = [(m.year, (m.title or "").lower(), "movie", m) for m in movies] + [
|
||||
(s.year, (s.title or "").lower(), "show", s) for s in shows
|
||||
]
|
||||
tagged.sort(key=lambda t: (t[0] is None, t[0] or 0, t[1]))
|
||||
items = [
|
||||
movie_card(o, mstate.get(o.id)) if kind == "movie" else show_card(o, sstate.get(o.id, "new"))
|
||||
for _, _, kind, o in tagged
|
||||
]
|
||||
years = [y for y, _, _, _ in tagged if y is not None]
|
||||
certs = [m.content_rating for m in movies] + [s.content_rating for s in shows]
|
||||
return {
|
||||
**_collection_card(col),
|
||||
"content_rating": _collection_cert(certs),
|
||||
"year_min": min(years) if years else None,
|
||||
"year_max": max(years) if years else None,
|
||||
"total_runtime_s": sum(m.duration_s or 0 for m in movies),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
|
||||
@@ -105,6 +192,41 @@ def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
|
||||
return col
|
||||
|
||||
|
||||
def _reject_duplicate_name(
|
||||
db: Session, library_id: int, title: str, exclude_rk: str | None = None
|
||||
) -> None:
|
||||
"""No two collections in a section may share a name: Plex CONFLATES same-named collections'
|
||||
membership (both rating_keys resolve to one merged member list — a real footgun), so forbid it up
|
||||
front on create + rename rather than mirror the mess."""
|
||||
dup = db.execute(
|
||||
select(PlexCollection).where(
|
||||
PlexCollection.library_id == library_id,
|
||||
func.lower(PlexCollection.title) == title.lower(),
|
||||
)
|
||||
).scalars().first()
|
||||
if dup is not None and dup.rating_key != exclude_rk:
|
||||
raise HTTPException(status_code=409, detail="A collection with this name already exists")
|
||||
|
||||
|
||||
def _write_or_pending_error(wrote: bool, e: Exception) -> HTTPException:
|
||||
"""Map a PlexError raised inside a mutate-then-resync block to the honest message. If the mutating
|
||||
call itself failed (``wrote`` False) the write never landed → "Plex write failed". If the write
|
||||
succeeded but the follow-up resync READ (Plex metadata/children) failed, the change IS on the Plex
|
||||
server → don't claim a write failure; say the local copy will reconcile on the next sync.
|
||||
|
||||
Both use 422, NOT a 5xx: the frontend treats 502/503/504 as "gateway unreachable" and raises a
|
||||
sticky offline banner (see api/core.ts RETRIABLE_GATEWAY). A 422 surfaces the detail as an ordinary
|
||||
action-error toast, which is what both of these are — the app answered, it didn't go unreachable."""
|
||||
if wrote:
|
||||
detail = (
|
||||
"Collection updated in Plex, but refreshing the local copy failed; "
|
||||
f"it will reconcile on the next sync ({e})"
|
||||
)
|
||||
else:
|
||||
detail = f"Plex write failed: {e}"
|
||||
return HTTPException(status_code=422, detail=detail)
|
||||
|
||||
|
||||
class CreateCollectionIn(BaseModel):
|
||||
library: str = ""
|
||||
title: str = ""
|
||||
@@ -125,20 +247,28 @@ def create_collection(
|
||||
raise HTTPException(
|
||||
status_code=400, detail="title and item_rating_key are required"
|
||||
)
|
||||
lib = db.execute(
|
||||
select(PlexLibrary).where(PlexLibrary.plex_key == library)
|
||||
).scalars().first()
|
||||
if library:
|
||||
lib = db.execute(
|
||||
select(PlexLibrary).where(PlexLibrary.plex_key == library)
|
||||
).scalars().first()
|
||||
else:
|
||||
# The "New collection" flow only knows the seed title → derive its library.
|
||||
seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed)
|
||||
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
|
||||
if lib is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown library")
|
||||
_reject_duplicate_name(db, lib.id, title)
|
||||
wrote = False
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
new = plex.create_collection(lib.plex_key, title, seed)
|
||||
wrote = True
|
||||
col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey")))
|
||||
col.editable = True
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
|
||||
raise _write_or_pending_error(wrote, e)
|
||||
return _collection_card(col)
|
||||
|
||||
|
||||
@@ -151,14 +281,16 @@ def collection_add_item(
|
||||
) -> dict:
|
||||
col = _editable_collection_or_403(db, rating_key)
|
||||
lib = db.get(PlexLibrary, col.library_id)
|
||||
wrote = False
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
plex.add_collection_item(rating_key, str(item_rating_key))
|
||||
wrote = True
|
||||
col = plex_sync.resync_collection(db, plex, lib, rating_key)
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
|
||||
raise _write_or_pending_error(wrote, e)
|
||||
return _collection_card(col)
|
||||
|
||||
|
||||
@@ -171,14 +303,16 @@ def collection_remove_item(
|
||||
) -> dict:
|
||||
col = _editable_collection_or_403(db, rating_key)
|
||||
lib = db.get(PlexLibrary, col.library_id)
|
||||
wrote = False
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
plex.remove_collection_item(rating_key, str(item_rating_key))
|
||||
wrote = True
|
||||
col = plex_sync.resync_collection(db, plex, lib, rating_key)
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
|
||||
raise _write_or_pending_error(wrote, e)
|
||||
return _collection_card(col)
|
||||
|
||||
|
||||
@@ -197,15 +331,18 @@ def collection_rename(
|
||||
title = body.title.strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
_reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
|
||||
lib = db.get(PlexLibrary, col.library_id)
|
||||
wrote = False
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
plex.rename_collection(lib.plex_key, rating_key, title)
|
||||
wrote = True
|
||||
col = plex_sync.resync_collection(db, plex, lib, rating_key)
|
||||
db.commit()
|
||||
except (PlexError, PlexNotConfigured) as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
|
||||
raise _write_or_pending_error(wrote, e)
|
||||
return _collection_card(col)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ recs the S2 TMDB engine already persisted (`PlexItem.recommendations`), never a
|
||||
every card comes from the same `movie_card`/`show_card` serializers the grid uses (so rails inherit
|
||||
S1's `art` + S3's rating/summary for free). The rails carry EN metadata (like the other non-grid
|
||||
card paths); the HU overlay coalesces only in the main grid.
|
||||
|
||||
The sidebar metadata filters (genre / rating / year / collection / …) narrow EVERY rail too, so the
|
||||
Discover surface behaves like the Browse grid — the same `LibraryFilters` / `meta_filter_conds` the
|
||||
grid uses, applied to each rail's movie + show query (a rail may legitimately come back empty, which
|
||||
the client hides). Genre-language matches the grid; duration is movie-only.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
@@ -17,8 +22,15 @@ from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import PlexItem, PlexShow, PlexState, User
|
||||
from app.plex import rollup
|
||||
from app.plex.cards import movie_card, show_card
|
||||
from app.plex.query import apply_show_state_filter, genres_col
|
||||
from app.plex.cards import leaf_card, movie_card, show_card
|
||||
from app.plex.query import (
|
||||
LibraryFilters,
|
||||
apply_meta_filters,
|
||||
apply_show_state_filter,
|
||||
genres_col,
|
||||
meta_filter_conds,
|
||||
top_art_keys,
|
||||
)
|
||||
from app.plex.tmdb import resolve_recs
|
||||
from app.routes.plex._shared import _content_libs, _norm_lang
|
||||
|
||||
@@ -26,6 +38,7 @@ router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
RAIL_LIMIT = 20
|
||||
GENRE_TILES = 12
|
||||
SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows (6 total)
|
||||
|
||||
|
||||
def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]:
|
||||
@@ -37,6 +50,16 @@ def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]:
|
||||
)
|
||||
|
||||
|
||||
def _movie_meta(q, p: dict, lang: str, f: LibraryFilters):
|
||||
"""Apply the shared metadata filters + movie-only duration to a movie query (same as the grid)."""
|
||||
q = apply_meta_filters(q, PlexItem, p, lang)
|
||||
if f.duration_min is not None:
|
||||
q = q.where(PlexItem.duration_s >= f.duration_min)
|
||||
if f.duration_max is not None:
|
||||
q = q.where(PlexItem.duration_s <= f.duration_max)
|
||||
return q
|
||||
|
||||
|
||||
def _movie_cards(db: Session, uid: int, items: list[PlexItem]) -> list[dict]:
|
||||
sts = rollup.item_state_map(db, uid, [it.id for it in items])
|
||||
return [movie_card(it, sts.get(it.id)) for it in items]
|
||||
@@ -48,13 +71,17 @@ def _show_cards(db: Session, uid: int, shows: list[PlexShow]) -> list[dict]:
|
||||
|
||||
|
||||
def _recently_added(
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int]
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
|
||||
) -> list[dict]:
|
||||
"""Newest movies + shows, interleaved by `added_at` desc into one rail."""
|
||||
movies = (
|
||||
db.execute(
|
||||
select(PlexItem)
|
||||
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids))
|
||||
_movie_meta(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)
|
||||
),
|
||||
p, lang, f,
|
||||
)
|
||||
.order_by(PlexItem.added_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).scalars().all()
|
||||
@@ -63,8 +90,9 @@ def _recently_added(
|
||||
)
|
||||
shows = (
|
||||
db.execute(
|
||||
select(PlexShow)
|
||||
.where(PlexShow.library_id.in_(show_ids))
|
||||
apply_meta_filters(
|
||||
select(PlexShow).where(PlexShow.library_id.in_(show_ids)), PlexShow, p, lang
|
||||
)
|
||||
.order_by(PlexShow.added_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).scalars().all()
|
||||
@@ -91,15 +119,57 @@ def _recently_added(
|
||||
]
|
||||
|
||||
|
||||
def _recently_watched(
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
|
||||
) -> list[dict]:
|
||||
"""Recently watched rail: the titles the user last FULLY watched, newest first. A movie is its own
|
||||
card; a show contributes the specific EPISODE watched (carrying its show title). Movies honour the
|
||||
sidebar filters (like the other rails); episodes carry no filterable metadata, so they don't."""
|
||||
rows: list[tuple[object, dict]] = [] # (watched_at, card)
|
||||
if movie_ids:
|
||||
mq = (
|
||||
select(PlexItem, PlexState)
|
||||
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
||||
.where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
PlexState.status == "watched",
|
||||
)
|
||||
)
|
||||
for it, st in db.execute(
|
||||
_movie_meta(mq, p, lang, f)
|
||||
.order_by(PlexState.watched_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all():
|
||||
rows.append((st.watched_at, leaf_card(it, st)))
|
||||
if show_ids:
|
||||
eq = (
|
||||
select(PlexItem, PlexState, PlexShow.title, PlexShow.rating_key)
|
||||
.join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
|
||||
.join(PlexShow, PlexShow.id == PlexItem.show_id)
|
||||
.where(
|
||||
PlexItem.kind == "episode",
|
||||
PlexItem.library_id.in_(show_ids),
|
||||
PlexState.status == "watched",
|
||||
)
|
||||
.order_by(PlexState.watched_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
)
|
||||
for ep, st, sh_title, sh_rk in db.execute(eq).all():
|
||||
rows.append((st.watched_at, leaf_card(ep, st, show_title=sh_title, show_rk=sh_rk)))
|
||||
rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True)
|
||||
return [card for _, card in rows[:RAIL_LIMIT]]
|
||||
|
||||
|
||||
def _continue_watching(
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int]
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
|
||||
) -> list[dict]:
|
||||
"""Resume rail: in-progress movies + partially-watched shows, most-recently-progressed first.
|
||||
A movie's resume time is its own `progress_updated_at`; a show's is the max across its episodes."""
|
||||
rows: list[tuple[object, dict]] = [] # (resumed_at, card)
|
||||
|
||||
if movie_ids:
|
||||
mrows = db.execute(
|
||||
mq = (
|
||||
select(PlexItem, PlexState)
|
||||
.join(
|
||||
PlexState,
|
||||
@@ -116,6 +186,9 @@ def _continue_watching(
|
||||
PlexState.status.notin_(["watched", "hidden"]),
|
||||
),
|
||||
)
|
||||
)
|
||||
mrows = db.execute(
|
||||
_movie_meta(mq, p, lang, f)
|
||||
.order_by(PlexState.progress_updated_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all()
|
||||
@@ -127,15 +200,15 @@ def _continue_watching(
|
||||
# its `resumed_at` = max(progress_updated_at); the in-progress predicate comes from the same shared
|
||||
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
|
||||
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
|
||||
sq = apply_show_state_filter(
|
||||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
||||
prog.c.total,
|
||||
prog.c.watched,
|
||||
prog.c.inprog,
|
||||
"in_progress",
|
||||
).where(PlexShow.library_id.in_(show_ids))
|
||||
srows = db.execute(
|
||||
apply_show_state_filter(
|
||||
select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
|
||||
prog.c.total,
|
||||
prog.c.watched,
|
||||
prog.c.inprog,
|
||||
"in_progress",
|
||||
)
|
||||
.where(PlexShow.library_id.in_(show_ids))
|
||||
apply_meta_filters(sq, PlexShow, p, lang)
|
||||
.order_by(prog.c.resumed_at.desc().nullslast())
|
||||
.limit(RAIL_LIMIT)
|
||||
).all()
|
||||
@@ -147,17 +220,20 @@ def _continue_watching(
|
||||
|
||||
|
||||
def _genre_tiles(
|
||||
db: Session, movie_ids: list[int], show_ids: list[int], lang: str
|
||||
db: Session, movie_ids: list[int], show_ids: list[int], p: dict, lang: str
|
||||
) -> list[dict]:
|
||||
"""The most-common genres across the scope, as {value, count} tiles → tap to filter the grid.
|
||||
The genre vocabulary is language-specific (coalesce(genres_hu, genres) in HU), matching the grid."""
|
||||
The genre vocabulary is language-specific (coalesce(genres_hu, genres) in HU), matching the grid.
|
||||
The active metadata filters narrow the counts too, so the tiles agree with the filtered rails."""
|
||||
counts: dict[str, int] = {}
|
||||
for model, ids in ((PlexItem, movie_ids), (PlexShow, show_ids)):
|
||||
if not ids:
|
||||
continue
|
||||
gcol = genres_col(model, lang)
|
||||
base = select(func.jsonb_array_elements_text(gcol).label("g")).where(
|
||||
model.library_id.in_(ids), func.jsonb_typeof(gcol) == "array"
|
||||
model.library_id.in_(ids),
|
||||
func.jsonb_typeof(gcol) == "array",
|
||||
*meta_filter_conds(model, p, lang),
|
||||
)
|
||||
if model is PlexItem:
|
||||
base = base.where(model.kind == "movie")
|
||||
@@ -167,15 +243,25 @@ def _genre_tiles(
|
||||
).all():
|
||||
counts[g] = counts.get(g, 0) + c
|
||||
top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:GENRE_TILES]
|
||||
return [{"value": g, "count": c} for g, c in top]
|
||||
# A 2×2 poster collage per tile: the top movies in that genre (color hash is the client fallback).
|
||||
art = top_art_keys(
|
||||
db,
|
||||
PlexItem,
|
||||
movie_ids,
|
||||
genres_col(PlexItem, lang),
|
||||
[g for g, _ in top],
|
||||
extra_conds=(PlexItem.kind == "movie", *meta_filter_conds(PlexItem, p, lang)),
|
||||
)
|
||||
return [{"value": g, "count": c, "art": art.get(g, [])} for g, c in top]
|
||||
|
||||
|
||||
def _because_you_watched(
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int]
|
||||
db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
|
||||
) -> dict | None:
|
||||
"""Recommendations anchored on the user's most-recently-watched, TMDB-enriched movie. Only the
|
||||
recs that resolve to OUR library (by guid) become cards — the rest aren't actionable here. Returns
|
||||
None (rail hidden) when there's no enriched watch history or none of its recs are in the library."""
|
||||
None (rail hidden) when there's no enriched watch history or none of its recs are in the library.
|
||||
The metadata filters narrow the resolved recs (not the anchor pick) so the rail matches the grid."""
|
||||
if not movie_ids:
|
||||
return None
|
||||
anchor = db.execute(
|
||||
@@ -207,15 +293,20 @@ def _because_you_watched(
|
||||
movies = {
|
||||
m.rating_key: m
|
||||
for m in db.execute(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie", PlexItem.rating_key.in_(rks)
|
||||
_movie_meta(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie", PlexItem.rating_key.in_(rks)
|
||||
),
|
||||
p, lang, f,
|
||||
)
|
||||
).scalars().all()
|
||||
}
|
||||
shows = {
|
||||
s.rating_key: s
|
||||
for s in db.execute(
|
||||
select(PlexShow).where(PlexShow.rating_key.in_(rks))
|
||||
apply_meta_filters(
|
||||
select(PlexShow).where(PlexShow.rating_key.in_(rks)), PlexShow, p, lang
|
||||
)
|
||||
).scalars().all()
|
||||
}
|
||||
ordered_movies = [movies[rk] for rk in rks if rk in movies]
|
||||
@@ -238,52 +329,86 @@ def _because_you_watched(
|
||||
def discover(
|
||||
scope: str = "both",
|
||||
lang: str = "en",
|
||||
f: LibraryFilters = Depends(),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""The Discover landing payload — every rail in one pass (serve-from-DB). Empty rails come back as
|
||||
empty lists (the client hides them); `because_you_watched` is null when there's no anchor."""
|
||||
"""The Discover landing payload — every rail in one pass (serve-from-DB), narrowed by the active
|
||||
sidebar filters. Empty rails come back as empty lists (the client hides them); `because_you_watched`
|
||||
is null when there's no anchor."""
|
||||
lang = _norm_lang(lang)
|
||||
p = f.as_dict()
|
||||
movie_ids, show_ids = _scope_libs(db, scope)
|
||||
return {
|
||||
"scope": scope,
|
||||
"continue_watching": _continue_watching(db, user.id, movie_ids, show_ids),
|
||||
"recently_added": _recently_added(db, user.id, movie_ids, show_ids),
|
||||
"genres": _genre_tiles(db, movie_ids, show_ids, lang),
|
||||
"because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids),
|
||||
"continue_watching": _continue_watching(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
"recently_watched": _recently_watched(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
"recently_added": _recently_added(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
"genres": _genre_tiles(db, movie_ids, show_ids, p, lang),
|
||||
"because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids, p, lang, f),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/surprise")
|
||||
def surprise(
|
||||
scope: str = "both",
|
||||
limit: int = Query(default=RAIL_LIMIT, ge=1, le=40),
|
||||
lang: str = "en",
|
||||
limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
|
||||
f: LibraryFilters = Depends(),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""A fresh random sample for the reshuffling Surprise-me rail (a random draw each call). Movies and
|
||||
shows are drawn separately then interleaved so a lopsided library doesn't crowd out one type."""
|
||||
"""A fresh random sample for the reshuffling Surprise-me rail (a random draw each call), narrowed by
|
||||
the active filters. Only titles the user has NOT seen — a watched movie / a fully-watched show is
|
||||
excluded (the point is to surface something to watch). Capped at SURPRISE_PER_KIND movies +
|
||||
SURPRISE_PER_KIND shows, drawn separately then interleaved so a lopsided library doesn't crowd out
|
||||
one type."""
|
||||
lang = _norm_lang(lang)
|
||||
p = f.as_dict()
|
||||
movie_ids, show_ids = _scope_libs(db, scope)
|
||||
movies = (
|
||||
db.execute(
|
||||
select(PlexItem)
|
||||
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids))
|
||||
per = min(limit, SURPRISE_PER_KIND)
|
||||
movies = []
|
||||
if movie_ids:
|
||||
# Exclude movies this user has watched.
|
||||
watched_movie = (
|
||||
select(PlexState.id)
|
||||
.where(
|
||||
PlexState.item_id == PlexItem.id,
|
||||
PlexState.user_id == user.id,
|
||||
PlexState.status == "watched",
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
movies = db.execute(
|
||||
_movie_meta(
|
||||
select(PlexItem).where(
|
||||
PlexItem.kind == "movie",
|
||||
PlexItem.library_id.in_(movie_ids),
|
||||
~watched_movie,
|
||||
),
|
||||
p, lang, f,
|
||||
)
|
||||
.order_by(func.random())
|
||||
.limit(limit)
|
||||
.limit(per)
|
||||
).scalars().all()
|
||||
if movie_ids
|
||||
else []
|
||||
)
|
||||
shows = (
|
||||
db.execute(
|
||||
select(PlexShow)
|
||||
.where(PlexShow.library_id.in_(show_ids))
|
||||
shows = []
|
||||
if show_ids:
|
||||
# Exclude fully-watched shows (LEFT JOIN so a never-started show — no rollup row — still counts
|
||||
# as unseen). `total IS NULL / == 0 / watched < total` == "not fully watched".
|
||||
prog = rollup.agg_subquery(db, user.id).subquery()
|
||||
not_fully_watched = or_(
|
||||
prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
|
||||
)
|
||||
shows = db.execute(
|
||||
apply_meta_filters(
|
||||
select(PlexShow)
|
||||
.outerjoin(prog, prog.c.show_id == PlexShow.id)
|
||||
.where(PlexShow.library_id.in_(show_ids), not_fully_watched),
|
||||
PlexShow, p, lang,
|
||||
)
|
||||
.order_by(func.random())
|
||||
.limit(limit)
|
||||
.limit(per)
|
||||
).scalars().all()
|
||||
if show_ids
|
||||
else []
|
||||
)
|
||||
mcards = _movie_cards(db, user.id, movies)
|
||||
scards = _show_cards(db, user.id, shows)
|
||||
# Interleave movie/show cards, then trim to `limit`.
|
||||
|
||||
@@ -9,11 +9,19 @@ from app.db import get_db
|
||||
from app.models import SavedView, User
|
||||
from app.routes._common import owned_or_404
|
||||
|
||||
# Saved "smart views": per-user named snapshots of the feed's FeedFilters. Gated behind
|
||||
# require_human so the shared demo account can't pollute a communal list (mirrors how other
|
||||
# personal-state writes are scoped).
|
||||
# Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by
|
||||
# `source`). Gated behind require_human so the shared demo account can't pollute a communal list
|
||||
# (mirrors how other personal-state writes are scoped).
|
||||
router = APIRouter(prefix="/api/saved-views", tags=["saved-views"])
|
||||
|
||||
# The modules that own saved views. A view is scoped to exactly one; the default is "feed" so a caller
|
||||
# (or an older client) that omits `source` keeps the original feed behaviour.
|
||||
_SOURCES = {"feed", "plex"}
|
||||
|
||||
|
||||
def _norm_source(source: str) -> str:
|
||||
return source if source in _SOURCES else "feed"
|
||||
|
||||
|
||||
def _serialize(v: SavedView) -> dict:
|
||||
return {
|
||||
@@ -27,12 +35,15 @@ def _serialize(v: SavedView) -> dict:
|
||||
|
||||
@router.get("")
|
||||
def list_views(
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
source: str = "feed",
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
source = _norm_source(source)
|
||||
rows = (
|
||||
db.execute(
|
||||
select(SavedView)
|
||||
.where(SavedView.user_id == user.id)
|
||||
.where(SavedView.user_id == user.id, SavedView.source == source)
|
||||
.order_by(SavedView.position, SavedView.id)
|
||||
)
|
||||
.scalars()
|
||||
@@ -44,6 +55,7 @@ def list_views(
|
||||
class CreateViewIn(BaseModel):
|
||||
name: str
|
||||
filters: dict
|
||||
source: str = "feed"
|
||||
|
||||
|
||||
@router.post("")
|
||||
@@ -55,13 +67,19 @@ def create_view(
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
source = _norm_source(body.source)
|
||||
# Position is per (user, source), so each module's views number 0..N independently.
|
||||
maxpos = db.execute(
|
||||
select(func.coalesce(func.max(SavedView.position), -1)).where(
|
||||
SavedView.user_id == user.id
|
||||
SavedView.user_id == user.id, SavedView.source == source
|
||||
)
|
||||
).scalar_one()
|
||||
view = SavedView(
|
||||
user_id=user.id, name=name[:80], filters=body.filters, position=maxpos + 1
|
||||
user_id=user.id,
|
||||
source=source,
|
||||
name=name[:80],
|
||||
filters=body.filters,
|
||||
position=maxpos + 1,
|
||||
)
|
||||
db.add(view)
|
||||
try:
|
||||
@@ -107,10 +125,14 @@ def update_view(
|
||||
if "is_default" in sent:
|
||||
make_default = bool(body.is_default)
|
||||
if make_default:
|
||||
# At most one default per user: clear the flag on the others first.
|
||||
# At most one default per (user, source): clear the flag on this module's others first.
|
||||
db.execute(
|
||||
update(SavedView)
|
||||
.where(SavedView.user_id == user.id, SavedView.id != view.id)
|
||||
.where(
|
||||
SavedView.user_id == user.id,
|
||||
SavedView.source == view.source,
|
||||
SavedView.id != view.id,
|
||||
)
|
||||
.values(is_default=False)
|
||||
)
|
||||
view.is_default = make_default
|
||||
|
||||
@@ -96,6 +96,7 @@ def _seed_tagged(db, make_user):
|
||||
"T1": Tag(user_id=None, name="Tech", category="topic"),
|
||||
"T2": Tag(user_id=None, name="Gaming", category="topic"),
|
||||
"O1": Tag(user_id=None, name="Favourite", category="other"),
|
||||
"O2": Tag(user_id=None, name="Docs", category="other"),
|
||||
# A USER-owned tag (+ a user-owned ChannelTag link below) so the visibility branch
|
||||
# `ChannelTag.user_id == user.id` is exercised, not only the system `user_id IS NULL` one.
|
||||
"U1": Tag(user_id=user.id, name="MyPin", category="other"),
|
||||
@@ -104,8 +105,17 @@ def _seed_tagged(db, make_user):
|
||||
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chA", "chB", "chC", "chD")}
|
||||
db.add_all(chans.values())
|
||||
db.flush()
|
||||
# channel -> (system) tag keys
|
||||
links = {"chA": ["L1", "T1"], "chB": ["L1", "T2"], "chC": ["L2", "T1", "O1"], "chD": []}
|
||||
# channel -> (system) tag keys. chA carries two languages (L1+L2); chC two "other" tags (O1+O2)
|
||||
# AND chA carries O1 too — so the per-category any/all match narrows NON-TRIVIALLY (AND != OR) for
|
||||
# language and other, not only topic: e.g. other {O1,O2} → OR {chA,chC} vs AND {chC}; language
|
||||
# {L1,L2} → OR {chA,chB,chC} vs AND {chA}. (For topic, chA and chB each carry a DIFFERENT topic tag
|
||||
# — T1 vs T2 — so topic {T1,T2} → OR {chA,chB,chC} vs AND {} empty.)
|
||||
links = {
|
||||
"chA": ["L1", "L2", "T1", "O1"],
|
||||
"chB": ["L1", "T2"],
|
||||
"chC": ["L2", "T1", "O1", "O2"],
|
||||
"chD": [],
|
||||
}
|
||||
for cid, tkeys in links.items():
|
||||
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
|
||||
for tk in tkeys:
|
||||
@@ -127,8 +137,12 @@ _SUMMARY_CASES = [
|
||||
"show=all&tags={L1}",
|
||||
"show=all&tags={T1}",
|
||||
"show=all&tags={L1}&tags={T1}",
|
||||
"show=all&tags={T1}&tags={T2}&tag_mode=or",
|
||||
"show=all&tags={T1}&tags={T2}&tag_mode=and",
|
||||
"show=all&tags={T1}&tags={T2}&tag_mode_topic=or",
|
||||
"show=all&tags={T1}&tags={T2}&tag_mode_topic=and",
|
||||
# Per-category any/all is NOT topic-only — language and other must drill down identically too.
|
||||
"show=all&tags={L1}&tags={L2}&tag_mode_language=or",
|
||||
"show=all&tags={L1}&tags={L2}&tag_mode_language=and",
|
||||
"show=all&tags={O1}&tags={O2}&tag_mode_other=and",
|
||||
"show=all&tags={U1}", # user-owned tag (visibility == user.id branch)
|
||||
"show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category
|
||||
"show=all&q=clip",
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Piece 1a: the Plex collection filter is multi-select with any/all combine (a title can belong to
|
||||
several collections). `collections=A,B` = ANY (OR, default); `&collection_mode=all` = ALL (AND).
|
||||
DB lane (`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
import app.state as state
|
||||
from app.auth import admin_user, current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
|
||||
from app.plex.client import PlexError
|
||||
from app.routes.plex import collections as coll_routes
|
||||
|
||||
|
||||
def _seed(db):
|
||||
"""Three movies: one in collection A, one in B, one in both — so any/all are distinguishable."""
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
common = dict(
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
db.add_all([
|
||||
PlexItem(rating_key="mA", title="Only A", collection_keys=["A"],
|
||||
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc), **common),
|
||||
PlexItem(rating_key="mB", title="Only B", collection_keys=["B"],
|
||||
added_at=datetime(2024, 1, 2, tzinfo=timezone.utc), **common),
|
||||
PlexItem(rating_key="mAB", title="A and B", collection_keys=["A", "B"],
|
||||
added_at=datetime(2024, 1, 3, tzinfo=timezone.utc), **common),
|
||||
])
|
||||
user = User(email="plexcoll@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _get(db, user, monkeypatch, path):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
try:
|
||||
return TestClient(app).get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _titles(resp):
|
||||
return {i["title"] for i in resp.json()["items"]}
|
||||
|
||||
|
||||
def test_single_collection_filters_to_its_members(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A")
|
||||
assert _titles(resp) == {"Only A", "A and B"}
|
||||
|
||||
|
||||
def test_multiple_collections_any_is_the_union(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A,B")
|
||||
assert _titles(resp) == {"Only A", "Only B", "A and B"}
|
||||
|
||||
|
||||
def test_multiple_collections_all_is_the_intersection(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
resp = _get(
|
||||
db, user, monkeypatch,
|
||||
"/api/plex/library?scope=movie&collections=A,B&collection_mode=all",
|
||||
)
|
||||
assert _titles(resp) == {"A and B"}
|
||||
|
||||
|
||||
def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch):
|
||||
# The browse-tile montage (top films, best-rated first) + the collection's total runtime.
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(PlexCollection(rating_key="A", library_id=lib.id, title="Coll A", child_count=2))
|
||||
common = dict(library_id=lib.id, kind="movie", collection_keys=["A"])
|
||||
db.add_all([
|
||||
PlexItem(rating_key="m1", title="M1", duration_s=3600, rating=8.0,
|
||||
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc), **common),
|
||||
PlexItem(rating_key="m2", title="M2", duration_s=1800, rating=9.0,
|
||||
added_at=datetime(2024, 1, 2, tzinfo=timezone.utc), **common),
|
||||
])
|
||||
user = User(email="collart@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
card = next(c for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"]
|
||||
if c["id"] == "A")
|
||||
assert card["art"] == ["m2", "m1"] # best-rated first (m2 9.0 > m1 8.0)
|
||||
assert card["total_runtime_s"] == 5400 # 3600 + 1800
|
||||
|
||||
|
||||
def test_create_collection_rejects_duplicate_name(db, monkeypatch):
|
||||
# Same-named collections conflate in Plex, so a create with an existing name (case-insensitive) is
|
||||
# rejected 409 — BEFORE any Plex call (the guard runs first, so the DB lane can assert it).
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(PlexCollection(rating_key="A", library_id=lib.id, title="Marvel"))
|
||||
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M1"))
|
||||
user = User(email="dup@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
app.dependency_overrides[admin_user] = lambda: user
|
||||
try:
|
||||
r = TestClient(app).post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "marvel", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_collection_detail_hero_and_ordered_members(db, monkeypatch):
|
||||
# The Collection info page: hero aggregates (year range, most-restrictive cert, total runtime) +
|
||||
# members ordered by release year then title.
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(PlexCollection(rating_key="A", library_id=lib.id, title="Coll A", summary="S", child_count=2))
|
||||
common = dict(library_id=lib.id, kind="movie", collection_keys=["A"])
|
||||
db.add_all([
|
||||
PlexItem(rating_key="later", title="Later", year=1996, duration_s=3600, content_rating="PG", **common),
|
||||
PlexItem(rating_key="early", title="Early", year=1992, duration_s=1800, content_rating="G", **common),
|
||||
])
|
||||
user = User(email="cd@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
d = _get(db, user, monkeypatch, "/api/plex/collections/A").json()
|
||||
assert d["title"] == "Coll A" and d["summary"] == "S"
|
||||
assert d["year_min"] == 1992 and d["year_max"] == 1996
|
||||
assert d["content_rating"] == "PG" # most restrictive of {G, PG}
|
||||
assert d["total_runtime_s"] == 5400
|
||||
assert [i["id"] for i in d["items"]] == ["early", "later"] # release-year order
|
||||
|
||||
|
||||
# --- Write-through collection mutations (create/add/remove/rename write to Plex, then resync the local
|
||||
# mirror). We stub PlexClient so the DB lane can drive the happy path, the 422-on-mutation-failure
|
||||
# rollback, and the 502-on-resync-failure split (a successful write whose follow-up refresh read fails
|
||||
# must NOT be reported as a write failure). ------------------------------------------------------------
|
||||
class _CollStub:
|
||||
"""A PlexClient stand-in recording calls; `fail_on` makes one method raise PlexError."""
|
||||
|
||||
def __init__(self, fail_on: str | None = None):
|
||||
self.fail_on = fail_on
|
||||
self.calls: list[str] = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def _maybe(self, name: str):
|
||||
self.calls.append(name)
|
||||
if self.fail_on == name:
|
||||
raise PlexError("boom")
|
||||
|
||||
def create_collection(self, section_key, title, item_rating_key):
|
||||
self._maybe("create_collection")
|
||||
return {"ratingKey": "NEW"}
|
||||
|
||||
def add_collection_item(self, rk, item):
|
||||
self._maybe("add_collection_item")
|
||||
|
||||
def remove_collection_item(self, rk, item):
|
||||
self._maybe("remove_collection_item")
|
||||
|
||||
def rename_collection(self, section_key, rk, title):
|
||||
self._maybe("rename_collection")
|
||||
|
||||
def delete_collection(self, rk):
|
||||
self._maybe("delete_collection")
|
||||
|
||||
|
||||
def _resync_ok(db, plex, lib, rk):
|
||||
"""Stand in for the post-write refresh: return the existing local row (no live Plex read)."""
|
||||
return db.execute(select(PlexCollection).where(PlexCollection.rating_key == rk)).scalars().first()
|
||||
|
||||
|
||||
def _seed_editable(db):
|
||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
# Plain, admin-editable collection (title isn't a provider word, not smart → source "collection").
|
||||
db.add(PlexCollection(rating_key="A", library_id=lib.id, title="My Coll", editable=True,
|
||||
child_count=1))
|
||||
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M1",
|
||||
collection_keys=["A"]))
|
||||
user = User(email="mut@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return lib, user
|
||||
|
||||
|
||||
def _admin_client(db, user, monkeypatch, stub, resync=_resync_ok):
|
||||
monkeypatch.setattr(state, "_configured_cache", True)
|
||||
monkeypatch.setattr(coll_routes, "PlexClient", lambda db: stub)
|
||||
monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user
|
||||
app.dependency_overrides[admin_user] = lambda: user
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_rename_writes_through_to_plex(db, monkeypatch):
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
client = _admin_client(db, user, monkeypatch, stub)
|
||||
try:
|
||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert "rename_collection" in stub.calls
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_remove_item_writes_through_to_plex(db, monkeypatch):
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
client = _admin_client(db, user, monkeypatch, stub)
|
||||
try:
|
||||
r = client.delete("/api/plex/collections/A/items/m1")
|
||||
assert r.status_code == 200, r.text
|
||||
assert "remove_collection_item" in stub.calls
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mutation_failure_returns_422_write_failed(db, monkeypatch):
|
||||
# The Plex mutation itself raised → the write never landed → 422 "Plex write failed", and resync
|
||||
# is never reached (stub.calls holds only the mutating call).
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub(fail_on="rename_collection")
|
||||
client = _admin_client(db, user, monkeypatch, stub)
|
||||
try:
|
||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert "write failed" in r.json()["detail"].lower()
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeypatch):
|
||||
# The mutation landed on Plex; only the follow-up refresh READ failed. We must (a) NOT claim a
|
||||
# write failure and NOT use a 5xx (which the frontend treats as offline) → 422 with a "reconcile
|
||||
# on the next sync" detail, and (b) roll back any partial local upsert the resync had flushed.
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
|
||||
def _resync_boom(db_, plex, lib_, rk):
|
||||
# Simulate resync having flushed a partial local change before its Plex read blew up — the
|
||||
# route's rollback must undo it (this is what makes the rollback assertion non-vacuous).
|
||||
row = db_.execute(select(PlexCollection).where(PlexCollection.rating_key == rk)).scalars().first()
|
||||
row.title = "PARTIAL_SHOULD_ROLL_BACK"
|
||||
db_.flush()
|
||||
raise PlexError("read failed")
|
||||
|
||||
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_boom)
|
||||
try:
|
||||
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
|
||||
assert r.status_code == 422, r.text
|
||||
assert "reconcile" in r.json()["detail"].lower()
|
||||
assert "rename_collection" in stub.calls # the write DID happen
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
row = db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first()
|
||||
assert row.title == "My Coll" # the partial resync upsert was rolled back
|
||||
|
||||
|
||||
def test_show_collection_gets_montage_art_from_shows(db, monkeypatch):
|
||||
# A show-library collection must get its montage from PlexShow members (else it renders blank) — the
|
||||
# A3 fix that unions movie + show art. Runtime stays 0 (shows have no single duration).
|
||||
lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(PlexCollection(rating_key="S", library_id=lib.id, title="Show Coll", child_count=2))
|
||||
db.add_all([
|
||||
PlexShow(rating_key="s1", library_id=lib.id, title="S1", collection_keys=["S"], rating=7.0,
|
||||
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc)),
|
||||
PlexShow(rating_key="s2", library_id=lib.id, title="S2", collection_keys=["S"], rating=9.0,
|
||||
added_at=datetime(2024, 1, 2, tzinfo=timezone.utc)),
|
||||
])
|
||||
user = User(email="showcoll@example.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
card = next(c for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"]
|
||||
if c["id"] == "S")
|
||||
assert card["art"] == ["s2", "s1"] # show members, best-rated first
|
||||
assert card["total_runtime_s"] == 0
|
||||
@@ -6,6 +6,7 @@ without Postgres.
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
@@ -103,6 +104,16 @@ def test_discover_shape_and_recently_added_order(db, monkeypatch):
|
||||
assert genres.get("Drama") == 2 and genres.get("Comedy") == 1
|
||||
|
||||
|
||||
def test_genre_tiles_carry_poster_art(db, monkeypatch):
|
||||
# Each genre tile carries up to 4 poster rating_keys for the 2×2 collage (top_art_keys): the top
|
||||
# movies in that genre, best-rated then newest. No ratings here → newest-first; shows are excluded.
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
||||
art = {g["value"]: g["art"] for g in d["genres"]}
|
||||
assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first
|
||||
assert art["Comedy"] == ["m2"]
|
||||
|
||||
|
||||
def test_continue_watching_movies_and_shows(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
|
||||
@@ -194,9 +205,63 @@ def test_surprise_scope_and_bound(db, monkeypatch):
|
||||
assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows
|
||||
|
||||
|
||||
def test_recently_watched_movies_and_episodes(db, monkeypatch):
|
||||
# The rail lists last-fully-watched titles newest-first: a watched MOVIE as its own card, a show as
|
||||
# the specific watched EPISODE (with its show title). _seed watches m1 (movie); add a watched episode.
|
||||
user = _seed(db)
|
||||
ep2 = db.execute(
|
||||
select(PlexItem).where(PlexItem.rating_key == "sh1e2")
|
||||
).scalars().one()
|
||||
db.add(PlexState(user_id=user.id, item_id=ep2.id, status="watched", watched_at=NOW))
|
||||
db.commit()
|
||||
rw = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()["recently_watched"]
|
||||
by_id = {c["id"]: c for c in rw}
|
||||
assert "m1" in by_id and by_id["m1"]["type"] == "movie"
|
||||
assert "sh1e2" in by_id and by_id["sh1e2"]["type"] == "episode"
|
||||
assert by_id["sh1e2"]["show_title"] == "A Show" # episode carries its show's title
|
||||
# ep2 was watched at NOW (newest) → it leads over m1 (watched earlier in _seed... same NOW ok).
|
||||
assert set(_ids(rw)) == {"m1", "sh1e2"}
|
||||
|
||||
|
||||
def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch):
|
||||
# Surprise surfaces only UNSEEN titles: the watched movie (m1) is out; the unwatched (m2), the
|
||||
# in-progress movie (m3) and the partially-watched show (sh1) are eligible. Capped at 3 per kind.
|
||||
user = _seed(db)
|
||||
items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=both").json()["items"]
|
||||
ids = _ids(items)
|
||||
assert "m1" not in ids # watched → excluded
|
||||
assert set(ids) <= {"m2", "m3", "sh1"} # only unseen titles
|
||||
assert len([c for c in items if c["type"] == "movie"]) <= 3
|
||||
assert len([c for c in items if c["type"] == "show"]) <= 3
|
||||
|
||||
|
||||
def test_discover_scope_movie_excludes_shows(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()
|
||||
assert all(c["type"] == "movie" for c in d["recently_added"])
|
||||
# The show drops out of continue watching under the movie scope.
|
||||
assert _ids(d["continue_watching"]) == ["m3"]
|
||||
|
||||
|
||||
def test_filters_narrow_every_discover_rail(db, monkeypatch):
|
||||
# The sidebar filters constrain the Discover rails just like the Browse grid (ask 3). A genre=Drama
|
||||
# filter keeps only the Drama titles across every rail; the genre-less show and the Comedy rec drop.
|
||||
user = _seed(db)
|
||||
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both&genres=Drama").json()
|
||||
# Recently-added: m1 + m3 (Drama) survive; Comedy m2 and the genre-less show sh1 are filtered out.
|
||||
assert set(_ids(d["recently_added"])) == {"m1", "m3"}
|
||||
# Continue-watching: only the in-progress Drama movie remains (the show has no genres → excluded).
|
||||
assert _ids(d["continue_watching"]) == ["m3"]
|
||||
# Genre tiles reflect the filtered set — only Drama, count 2 (m1 + m3).
|
||||
assert {g["value"]: g["count"] for g in d["genres"]} == {"Drama": 2}
|
||||
# Because-you-watched: the anchor m1 is watched, but its only rec (m2) is Comedy → filtered → no rail.
|
||||
assert d["because_you_watched"] is None
|
||||
|
||||
|
||||
def test_filters_narrow_surprise(db, monkeypatch):
|
||||
user = _seed(db)
|
||||
items = _get(
|
||||
db, user, monkeypatch, "/api/plex/surprise?scope=movie&genres=Comedy"
|
||||
).json()["items"]
|
||||
# Only the one Comedy movie qualifies under the filter.
|
||||
assert _ids(items) == ["m2"]
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""ask 4: saved views are per-module (`source`). Feed and Plex views are separate lists, may share a
|
||||
name, and each has its own single default. DB lane (`db` fixture) — skipped without Postgres.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.state as state
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import User
|
||||
|
||||
|
||||
def _client(db, user, monkeypatch):
|
||||
monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
app.dependency_overrides[current_user] = lambda: user # require_human resolves through this
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _user(db):
|
||||
u = User(email="views@example.com")
|
||||
db.add(u)
|
||||
db.commit()
|
||||
return u
|
||||
|
||||
|
||||
def test_views_are_scoped_by_source(db, monkeypatch):
|
||||
user = _user(db)
|
||||
try:
|
||||
c = _client(db, user, monkeypatch)
|
||||
# Same NAME under two sources is allowed (unique is per user+source now).
|
||||
f = c.post("/api/saved-views", json={"name": "Fresh", "filters": {"a": 1}, "source": "feed"})
|
||||
p = c.post("/api/saved-views", json={"name": "Fresh", "filters": {"b": 2}, "source": "plex"})
|
||||
assert f.status_code == 200 and p.status_code == 200, (f.text, p.text)
|
||||
|
||||
feed = c.get("/api/saved-views?source=feed").json()
|
||||
plex = c.get("/api/saved-views?source=plex").json()
|
||||
assert [v["name"] for v in feed] == ["Fresh"]
|
||||
assert [v["name"] for v in plex] == ["Fresh"]
|
||||
assert feed[0]["filters"] == {"a": 1} and plex[0]["filters"] == {"b": 2}
|
||||
# A source-less GET keeps the old feed-only behaviour.
|
||||
assert [v["id"] for v in c.get("/api/saved-views").json()] == [v["id"] for v in feed]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_default_is_per_source(db, monkeypatch):
|
||||
user = _user(db)
|
||||
try:
|
||||
c = _client(db, user, monkeypatch)
|
||||
fid = c.post(
|
||||
"/api/saved-views", json={"name": "F", "filters": {}, "source": "feed"}
|
||||
).json()["id"]
|
||||
pid = c.post(
|
||||
"/api/saved-views", json={"name": "P", "filters": {}, "source": "plex"}
|
||||
).json()["id"]
|
||||
c.patch(f"/api/saved-views/{fid}", json={"is_default": True})
|
||||
c.patch(f"/api/saved-views/{pid}", json={"is_default": True})
|
||||
# Making the Plex view default must NOT clear the feed view's default (different source).
|
||||
assert c.get("/api/saved-views?source=feed").json()[0]["is_default"] is True
|
||||
assert c.get("/api/saved-views?source=plex").json()[0]["is_default"] is True
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_duplicate_name_within_a_source_conflicts(db, monkeypatch):
|
||||
user = _user(db)
|
||||
try:
|
||||
c = _client(db, user, monkeypatch)
|
||||
a = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
|
||||
b = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
|
||||
assert a.status_code == 200
|
||||
assert b.status_code == 409 # same (user, source, name)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { clickVisibleChannelLink, openApp } from "./helpers";
|
||||
import { clickVisibleChannelLink, expectContentType, openApp, toggleContentType } from "./helpers";
|
||||
|
||||
// Locks channel-view entry + exit: opening a channel from a feed card replaces the view, and
|
||||
// both the in-page Back and the browser Back return to the feed. (This orthogonal channel branch
|
||||
@@ -68,29 +68,22 @@ test.describe("channel view", () => {
|
||||
}) => {
|
||||
await openApp(page);
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
const feedLive = page.getByTestId("feed-content-includeLive");
|
||||
await expect(feedLive).toHaveAttribute("aria-pressed", "false");
|
||||
await expectContentType(page, "includeLive", false);
|
||||
|
||||
// Inherit OFF: the channel toolbar's Live/Upcoming stays off, not forced on.
|
||||
await page.getByTestId("video-card-channel").first().click();
|
||||
await expect(page.getByTestId("channel-title")).toBeVisible();
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expectContentType(page, "includeLive", false);
|
||||
await page.getByTestId("channel-back").click();
|
||||
await expect(page.getByTestId("channel-title")).toHaveCount(0);
|
||||
|
||||
// Inherit ON: flip the feed's Live/Upcoming on, reopen the channel → it inherits the on state.
|
||||
await feedLive.click();
|
||||
await expect(feedLive).toHaveAttribute("aria-pressed", "true");
|
||||
await toggleContentType(page, "includeLive");
|
||||
await expectContentType(page, "includeLive", true);
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
await page.getByTestId("video-card-channel").first().click();
|
||||
await expect(page.getByTestId("channel-title")).toBeVisible();
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expectContentType(page, "includeLive", true);
|
||||
});
|
||||
|
||||
// Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there
|
||||
@@ -99,27 +92,18 @@ test.describe("channel view", () => {
|
||||
test("a channel's content toggle does not leak into the feed", async ({ page }) => {
|
||||
await openApp(page);
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expectContentType(page, "includeLive", false);
|
||||
|
||||
// Turn Live/Upcoming ON inside the channel view.
|
||||
await page.getByTestId("video-card-channel").first().click();
|
||||
await expect(page.getByTestId("channel-title")).toBeVisible();
|
||||
await page.getByTestId("feed-content-includeLive").click();
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await toggleContentType(page, "includeLive");
|
||||
await expectContentType(page, "includeLive", true);
|
||||
|
||||
// Back on the feed, its Live/Upcoming is still off — the channel's change stayed local.
|
||||
await page.getByTestId("channel-back").click();
|
||||
await expect(page.getByTestId("channel-title")).toHaveCount(0);
|
||||
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expectContentType(page, "includeLive", false);
|
||||
});
|
||||
|
||||
// Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays
|
||||
@@ -127,7 +111,7 @@ test.describe("channel view", () => {
|
||||
test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => {
|
||||
await openApp(page);
|
||||
await page.getByTestId("video-card-channel").first().click();
|
||||
const count = page.getByTestId("feed-result-count");
|
||||
const count = page.getByTestId("item-count");
|
||||
const title = page.getByTestId("channel-title");
|
||||
await expect(count).toBeVisible();
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
|
||||
@@ -8,15 +8,18 @@ test.describe("feed", () => {
|
||||
await openApp(page);
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
|
||||
const count = page.getByTestId("feed-result-count");
|
||||
const count = page.getByTestId("item-count");
|
||||
await expect(count).toHaveText(/\d/);
|
||||
const baseline = await count.innerText();
|
||||
|
||||
// Clean baseline: default filters, so no chips and "unwatched" is the active show.
|
||||
// Clean baseline: default filters, so no chips and "unwatched" is the active show (the Watch-state
|
||||
// pill summarises the current pick).
|
||||
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
|
||||
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.getByTestId("feed-show")).toContainText("Unwatched");
|
||||
|
||||
// Narrow to "watched" — the seed guarantees exactly one, a real deterministic change.
|
||||
// Narrow to "watched" — open the Watch-state dropdown and pick it (single-select closes on pick).
|
||||
// The seed guarantees exactly one, a real deterministic change.
|
||||
await page.getByTestId("feed-show").click();
|
||||
await page.getByTestId("feed-show-watched").click();
|
||||
await expect(page.getByTestId("active-filter-chip")).toHaveCount(1);
|
||||
await expect(count).not.toHaveText(baseline);
|
||||
@@ -24,7 +27,7 @@ test.describe("feed", () => {
|
||||
// Remove the chip → default show restored, chips gone.
|
||||
await page.getByTestId("active-filter-chip").click();
|
||||
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
|
||||
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.getByTestId("feed-show")).toContainText("Unwatched");
|
||||
});
|
||||
|
||||
test("scrolling virtualizes in fresh cards", async ({ page }) => {
|
||||
@@ -52,7 +55,7 @@ test.describe("feed", () => {
|
||||
// pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll.
|
||||
test("the feed toolbar stays pinned while cards scroll", async ({ page }) => {
|
||||
await openApp(page);
|
||||
const count = page.getByTestId("feed-result-count");
|
||||
const count = page.getByTestId("item-count");
|
||||
await expect(count).toBeVisible();
|
||||
const topBefore = await count.evaluate((el) => el.getBoundingClientRect().top);
|
||||
|
||||
@@ -73,6 +76,7 @@ test.describe("feed", () => {
|
||||
expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
|
||||
|
||||
// Switch the show filter → a different result set → back to the top.
|
||||
await page.getByTestId("feed-show").click();
|
||||
await page.getByTestId("feed-show-all").click();
|
||||
await expect.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop)).toBeLessThan(50);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,30 @@ export async function clickVisibleChannelLink(page: Page): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a content-type (e.g. "includeLive") is on/off via the Content-types FilterSelect dropdown.
|
||||
* The options only exist while the dropdown is open, so this opens it, checks the option's
|
||||
* `aria-checked` (the multi FilterSelect renders its rows as menuitemcheckbox), then closes it
|
||||
* (Escape) so the floating panel can't block a later click.
|
||||
*/
|
||||
export async function expectContentType(page: Page, value: string, on: boolean): Promise<void> {
|
||||
await page.getByTestId("feed-content").click();
|
||||
await expect(page.getByTestId(`feed-content-${value}`)).toHaveAttribute(
|
||||
"aria-checked",
|
||||
on ? "true" : "false",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId(`feed-content-${value}`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Toggle a content-type via the Content-types FilterSelect dropdown, then close it. */
|
||||
export async function toggleContentType(page: Page, value: string): Promise<void> {
|
||||
await page.getByTestId("feed-content").click();
|
||||
await page.getByTestId(`feed-content-${value}`).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId(`feed-content-${value}`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Open the in-app player from the first feed card (clicks its thumbnail link). */
|
||||
export async function openFirstVideo(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
|
||||
+15
-1
@@ -136,6 +136,19 @@ export default function App() {
|
||||
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
|
||||
}, [meQuery.data?.id]);
|
||||
|
||||
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
|
||||
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
|
||||
// that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough.
|
||||
useEffect(() => {
|
||||
const probe = document.createElement("div");
|
||||
probe.style.cssText =
|
||||
"position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable";
|
||||
document.body.appendChild(probe);
|
||||
const w = probe.offsetWidth - probe.clientWidth;
|
||||
document.body.removeChild(probe);
|
||||
document.documentElement.style.setProperty("--sbw", `${w}px`);
|
||||
}, []);
|
||||
|
||||
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
|
||||
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
|
||||
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
|
||||
@@ -300,7 +313,7 @@ export default function App() {
|
||||
const me = meQuery.data!;
|
||||
const pageGate = moduleDef(page).gate;
|
||||
const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
|
||||
const { Component: PageContent, fadeTop } = PAGE_CONTENT[activePage];
|
||||
const { Component: PageContent, fadeTop, maxWidth } = PAGE_CONTENT[activePage];
|
||||
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
|
||||
|
||||
return (
|
||||
@@ -366,6 +379,7 @@ export default function App() {
|
||||
) : (
|
||||
<PageShell
|
||||
scrollKey={normalScrollKey}
|
||||
maxWidth={maxWidth}
|
||||
// Channels: the registry disables the top fade for its sticky-header table, but the card
|
||||
// layout has no sticky header, so re-enable it there.
|
||||
fadeTop={activePage === "channels" ? channelLayout === "cards" : (fadeTop ?? true)}
|
||||
|
||||
@@ -1,51 +1,258 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
import { Filter, X } from "lucide-react";
|
||||
import type { ActiveFilter } from "../lib/useActiveFilters";
|
||||
import { decideChipLayout } from "../lib/chipOverflow";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
|
||||
// open the panel just to see what you'd filtered by.
|
||||
//
|
||||
// The row is a single flex line that shares its width with the Sort/View controls pinned to its right,
|
||||
// so the chips must never push those off-screen. When they'd overflow it trims responsively:
|
||||
// - wide -> show as many chips as fit, then a "+N" pill whose popover holds the rest;
|
||||
// - narrow -> collapse every chip into one "N filters" pill whose popover holds them all.
|
||||
// Fit is measured off a hidden mirror of the full chip set (so slicing the visible lane can't feed
|
||||
// back into the measurement and oscillate).
|
||||
|
||||
// Below this lane width an overflow collapses all the way to a single count pill (mobile), instead of
|
||||
// the "fit some + N more" treatment used on wider lanes.
|
||||
const NARROW_PX = 420;
|
||||
|
||||
const chipCls =
|
||||
"chip group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-sm whitespace-nowrap hover:border-accent hover:bg-accent/20 transition";
|
||||
|
||||
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
data-testid={testId}
|
||||
data-filter-key={f.key}
|
||||
onClick={f.remove}
|
||||
title={t("feed.chips.remove", { name: f.label })}
|
||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||
className={chipCls}
|
||||
>
|
||||
<span className="max-w-56 truncate">{f.label}</span>
|
||||
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// The "+N" / "N filters" pill and its popover listing the (overflowed / all) chips, each removable.
|
||||
function OverflowPopover({
|
||||
label,
|
||||
ariaLabel,
|
||||
chips,
|
||||
onClearAll,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
/** Accessible name for the trigger + popover — describes what's inside (e.g. "3 more filters"),
|
||||
* since the visible label can be a bare "+3". Defaults to `label`. */
|
||||
ariaLabel?: string;
|
||||
chips: ActiveFilter[];
|
||||
onClearAll: () => void;
|
||||
icon?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const name = ariaLabel ?? label;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={name}
|
||||
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${
|
||||
open ? "border-accent text-accent" : "hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
|
||||
{label}
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={name}
|
||||
className="glass-menu min-w-48 max-w-72 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div role="group" aria-label={name} className="max-h-72 overflow-y-auto no-scrollbar">
|
||||
{chips.map((f) => (
|
||||
<PopoverItem
|
||||
key={f.key}
|
||||
role="menuitem"
|
||||
onSelect={f.remove}
|
||||
label={f.label}
|
||||
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-fg transition"
|
||||
>
|
||||
<span className="flex-1 truncate">{f.label}</span>
|
||||
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 border-t border-border px-1 pt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="active-filter-clear-all"
|
||||
onClick={() => {
|
||||
onClearAll();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs text-muted hover:text-fg underline underline-offset-2 transition"
|
||||
>
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ActiveFilterChips({
|
||||
filters,
|
||||
onClearAll,
|
||||
className,
|
||||
}: {
|
||||
filters: ActiveFilter[];
|
||||
onClearAll: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (filters.length === 0) return null;
|
||||
const laneRef = useRef<HTMLDivElement | null>(null);
|
||||
const labelRef = useRef<HTMLSpanElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLSpanElement | null>>([]);
|
||||
const plusRef = useRef<HTMLSpanElement | null>(null);
|
||||
const clearRef = useRef<HTMLSpanElement | null>(null);
|
||||
// How the row is currently laid out: all chips, a fit-count + "+N", or a single count pill.
|
||||
const [layout, setLayout] = useState<{ mode: "all" | "some" | "count"; visible: number }>({
|
||||
mode: "all",
|
||||
visible: filters.length,
|
||||
});
|
||||
|
||||
const n = filters.length;
|
||||
// Re-measure whenever the chip set changes (labels or count) — captures both in one dep.
|
||||
const chipsKey = filters.map((f) => f.key + f.label).join("|");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const lane = laneRef.current;
|
||||
const labelEl = labelRef.current;
|
||||
if (!lane || !labelEl || n === 0) return;
|
||||
|
||||
const measure = () => {
|
||||
const rightOf = (el: HTMLElement | null | undefined) =>
|
||||
el ? el.offsetLeft + el.offsetWidth : 0;
|
||||
const chips = chipRefs.current.slice(0, n);
|
||||
const plusEl = plusRef.current;
|
||||
// Measure the real rendered gap off the box between the last chip and the "+N" sample, so it
|
||||
// scales with the text-size slider (gap-1.5 is rem) rather than being frozen in px.
|
||||
const lastChip = chips[n - 1];
|
||||
const gap = plusEl && lastChip ? Math.max(0, plusEl.offsetLeft - rightOf(lastChip)) : 6;
|
||||
// "all" mode also renders a Clear all button after the chips (when n > 1), so reserve its width.
|
||||
const clearW = n > 1 ? gap + (clearRef.current?.offsetWidth ?? 0) : 0;
|
||||
|
||||
setLayout(
|
||||
decideChipLayout({
|
||||
n,
|
||||
labelRight: rightOf(labelEl),
|
||||
chipRights: chips.map((c) => rightOf(c)),
|
||||
plusW: plusEl?.offsetWidth ?? 0,
|
||||
clearW,
|
||||
avail: lane.clientWidth,
|
||||
gap,
|
||||
narrowPx: NARROW_PX,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(lane);
|
||||
return () => ro.disconnect();
|
||||
}, [n, chipsKey]);
|
||||
|
||||
if (n === 0) return null;
|
||||
|
||||
const hiddenCount = n - layout.visible;
|
||||
const overflowChips = filters.slice(layout.visible);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={laneRef}
|
||||
data-testid="active-filter-chips"
|
||||
className="flex flex-wrap items-center gap-1.5 pb-3"
|
||||
className={`relative flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
|
||||
aria-label={t("feed.chips.label")}
|
||||
>
|
||||
<span className="text-2xs uppercase tracking-wider text-muted mr-0.5">
|
||||
{t("feed.chips.label")}
|
||||
</span>
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
data-testid="active-filter-chip"
|
||||
data-filter-key={f.key}
|
||||
onClick={f.remove}
|
||||
title={t("feed.chips.remove", { name: f.label })}
|
||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||
className="chip group pl-2.5 pr-1.5 py-1 text-xs hover:border-accent hover:bg-accent/20 transition"
|
||||
>
|
||||
<span className="max-w-56 truncate">{f.label}</span>
|
||||
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||
</button>
|
||||
))}
|
||||
{filters.length > 1 && (
|
||||
<button
|
||||
data-testid="active-filter-clear-all"
|
||||
onClick={onClearAll}
|
||||
className="ml-0.5 text-xs text-muted hover:text-fg underline underline-offset-2 transition"
|
||||
>
|
||||
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree
|
||||
and without test ids so it never doubles the visible chip count. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5"
|
||||
>
|
||||
<span ref={labelRef} className="text-xs uppercase tracking-wider text-muted mr-0.5">
|
||||
{t("feed.chips.label")}
|
||||
</span>
|
||||
{filters.map((f, i) => (
|
||||
<span
|
||||
key={f.key}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className={chipCls}
|
||||
>
|
||||
<span className="max-w-56 truncate">{f.label}</span>
|
||||
<X className="w-3 h-3 shrink-0" />
|
||||
</span>
|
||||
))}
|
||||
<span ref={plusRef} className="chip inline-flex items-center gap-1 px-2.5 py-1 text-sm">
|
||||
+{n}
|
||||
</span>
|
||||
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Visible lane. */}
|
||||
{layout.mode === "count" ? (
|
||||
<OverflowPopover
|
||||
label={t("feed.chips.count", { count: n })}
|
||||
chips={filters}
|
||||
onClearAll={onClearAll}
|
||||
icon
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs uppercase tracking-wider text-muted mr-0.5 shrink-0">
|
||||
{t("feed.chips.label")}
|
||||
</span>
|
||||
{filters.slice(0, layout.visible).map((f) => (
|
||||
<ChipButton key={f.key} f={f} testId="active-filter-chip" />
|
||||
))}
|
||||
{layout.mode === "some" && hiddenCount > 0 && (
|
||||
<OverflowPopover
|
||||
label={`+${hiddenCount}`}
|
||||
ariaLabel={t("feed.chips.moreCount", { count: hiddenCount })}
|
||||
chips={overflowChips}
|
||||
onClearAll={onClearAll}
|
||||
/>
|
||||
)}
|
||||
{layout.mode === "all" && n > 1 && (
|
||||
<button
|
||||
data-testid="active-filter-clear-all"
|
||||
onClick={onClearAll}
|
||||
className="ml-0.5 shrink-0 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
|
||||
>
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function AdminUsers() {
|
||||
<>
|
||||
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
|
||||
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
|
||||
<Tabs tabs={tabs} active={active} onChange={setTab} />
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
@@ -200,7 +200,7 @@ export default function ChannelDiscovery({
|
||||
<>
|
||||
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 max-w-384 mx-auto flex items-start justify-between gap-4">
|
||||
<div className="px-4 pt-2 max-w-384 mx-auto flex items-start justify-between gap-4">
|
||||
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
|
||||
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
|
||||
{layout === "cards" && (
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useFeedFilters } from "./FeedFiltersProvider";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { api, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
|
||||
import type { FeedView } from "../lib/feedView";
|
||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
@@ -231,7 +231,7 @@ export default function ChannelPage({
|
||||
// the explored, per-user-private videos show — the backend gates them to this explorer).
|
||||
const [filters, setFilters] = useState<FeedFilters>(() => ({
|
||||
tags: [],
|
||||
tagMode: "or",
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "all",
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { X } from "lucide-react";
|
||||
import { api, type FeedFilters } from "../lib/api";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { inputCls } from "./ui/form";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
|
||||
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
|
||||
// across all of them. Until this existed the filter was settable only via a shared "?channel=" link:
|
||||
@@ -22,86 +19,29 @@ export default function ChannelPicker({
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
// Same edge-fade affordance the nav rail and the panel body use to hint at more content.
|
||||
const fade = useScrollFade();
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: qk.channels(),
|
||||
queryFn: api.channels,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = channelsQuery.data ?? [];
|
||||
const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all;
|
||||
const picked = filters.channelIds;
|
||||
const nameOf = (id: string) => all.find((c) => c.id === id)?.title ?? id;
|
||||
const toggle = (id: string) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
channelIds: picked.includes(id) ? picked.filter((x) => x !== id) : [...picked, id],
|
||||
});
|
||||
// The list order isn't alphabetical — it's the user's channel priority — and Combobox preserves the
|
||||
// options order, so it stays meaningful. selectedLabels keeps a picked channel named even if a later
|
||||
// narrowing ever drops it from the visible rows.
|
||||
const options: ComboboxOption[] = all.map((c) => ({ value: c.id, label: c.title ?? c.id }));
|
||||
const selectedLabels = Object.fromEntries(all.map((c) => [c.id, c.title ?? c.id]));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The picks sit above the list so they stay visible once the list scrolls away. */}
|
||||
{picked.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pb-2">
|
||||
{picked.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => toggle(id)}
|
||||
title={t("sidebar.channelRemove", { name: nameOf(id) })}
|
||||
className="chip-solid inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full text-xs shadow-xs hover:opacity-90 transition"
|
||||
>
|
||||
<span className="truncate">{nameOf(id)}</span>
|
||||
<X className="w-3 h-3 shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("sidebar.channelSearch")}
|
||||
aria-label={t("sidebar.channelSearch")}
|
||||
className={inputCls}
|
||||
/>
|
||||
{/* The order isn't arbitrary and isn't alphabetical — say so, or the top of the list looks
|
||||
random to anyone who hasn't set a priority. Only while unfiltered: a search reorders nothing
|
||||
but does drop the rows that explain it. */}
|
||||
{!q && all.length > 0 && (
|
||||
<div className="px-2 pt-1.5 text-2xs text-muted">{t("sidebar.channelOrder")}</div>
|
||||
)}
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
className="mt-1.5 max-h-44 overflow-y-auto no-scrollbar flex flex-col gap-0.5"
|
||||
>
|
||||
{matches.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted">
|
||||
{channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")}
|
||||
</div>
|
||||
) : (
|
||||
matches.map((c) => {
|
||||
const on = picked.includes(c.id);
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => toggle(c.id)}
|
||||
aria-pressed={on}
|
||||
// shrink-0 is load-bearing: these are flex children in a max-height box, so without
|
||||
// them they squash to ~12px each and clip their own text instead of the box scrolling.
|
||||
className={`w-full shrink-0 text-left text-sm px-2 py-1.5 rounded-lg truncate transition ${
|
||||
on ? "text-accent font-medium" : "text-muted hover:text-fg hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
{c.title ?? c.id}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<Combobox
|
||||
label={t("sidebar.channelSearch")}
|
||||
placeholder={t("sidebar.channelSearch")}
|
||||
options={options}
|
||||
selected={filters.channelIds}
|
||||
onChange={(channelIds) => setFilters({ ...filters, channelIds })}
|
||||
loading={channelsQuery.isLoading}
|
||||
emptyText={t("sidebar.noChannelMatch")}
|
||||
selectedLabels={selectedLabels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,19 @@ describe("Combobox", () => {
|
||||
expect(document.getElementById(controls!)?.getAttribute("role")).toBe("listbox");
|
||||
});
|
||||
|
||||
it("focuses the input when the field chrome is pressed (merged ref, not a dead inputRef)", () => {
|
||||
// Regression: the input carried only floating-ui's ref, so `inputRef` stayed null. The field's
|
||||
// onMouseDown then preventDefault()-ed every press (its `e.target !== inputRef.current` guard was
|
||||
// always true) and its compensating inputRef.current?.focus() was a no-op → the box couldn't be
|
||||
// typed into with the mouse. A merged ref feeds the node to both, so pressing the chrome focuses it.
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
const field = input.parentElement as HTMLElement;
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
fireEvent.mouseDown(field); // press the field padding, not the input itself
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it("keeps the option list unmounted until the field is focused", () => {
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
|
||||
@@ -192,6 +205,31 @@ describe("Combobox", () => {
|
||||
expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces
|
||||
});
|
||||
|
||||
it("shows the any/all match footer only at 2+ selections and reports a mode change", () => {
|
||||
// The per-category any/all toggle: meaningless below 2 selections (any === all), so it appears
|
||||
// only at >= 2, and clicking 'all' reports the mode change out.
|
||||
const onMatchModeChange = vi.fn();
|
||||
const props = {
|
||||
label: "Topic",
|
||||
options: OPTIONS,
|
||||
onChange: vi.fn(),
|
||||
matchMode: "or" as const,
|
||||
onMatchModeChange,
|
||||
};
|
||||
// One selection: no footer.
|
||||
const { rerender } = wrap(<Combobox {...props} selected={["Drama"]} />);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
expect(screen.queryByText("sidebar.all")).toBeNull();
|
||||
|
||||
// Two selections: footer appears with an any/all segment.
|
||||
rerender(<FloatingTree>{<Combobox {...props} selected={["Drama", "Action"]} />}</FloatingTree>);
|
||||
fireEvent.focus(screen.getByRole("combobox"));
|
||||
const all = screen.getByText("sidebar.all");
|
||||
expect(all).toBeTruthy();
|
||||
fireEvent.click(all);
|
||||
expect(onMatchModeChange).toHaveBeenCalledWith("and");
|
||||
});
|
||||
|
||||
it("single-select: picking replaces the value and closes the list", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronDown, X } from "lucide-react";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
@@ -55,6 +55,11 @@ export interface ComboboxProps {
|
||||
/** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed)
|
||||
* list — the collection picker passes the stored title here so a reloaded filter still names itself. */
|
||||
selectedLabels?: Record<string, string>;
|
||||
/** Optional "any/all" match segment in the popover footer (like SortControl's direction segment):
|
||||
* "or" = a match on ANY selected value, "and" = must match ALL. Provide BOTH to show it; it appears
|
||||
* only once 2+ values are selected (below that, any and all are the same). */
|
||||
matchMode?: "or" | "and";
|
||||
onMatchModeChange?: (mode: "or" | "and") => void;
|
||||
}
|
||||
|
||||
export default function Combobox({
|
||||
@@ -73,6 +78,8 @@ export default function Combobox({
|
||||
maxVisible = 50,
|
||||
renderOption,
|
||||
selectedLabels,
|
||||
matchMode,
|
||||
onMatchModeChange,
|
||||
}: ComboboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -83,7 +90,7 @@ export default function Combobox({
|
||||
if (queryProp === undefined) setQueryState(q);
|
||||
onQueryChange?.(q);
|
||||
};
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fade = useScrollFade();
|
||||
|
||||
// Remember each value's display label as options flow through, so a selected chip can name itself
|
||||
@@ -100,6 +107,19 @@ export default function Combobox({
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" });
|
||||
// The input is the combobox reference for floating-ui AND our own focus target (the field's
|
||||
// click-to-focus handler, and commit() re-focusing after a pick). A merged callback ref feeds the
|
||||
// node to BOTH — without it `inputRef` stays null, so the field's onMouseDown guard (`e.target !==
|
||||
// inputRef.current`) is always true and preventDefault() cancels the input's own click-focus, making
|
||||
// the field impossible to type into with the mouse. Memoised so floating-ui's ref isn't re-attached
|
||||
// every render.
|
||||
const setInputRef = useCallback(
|
||||
(node: HTMLInputElement | null) => {
|
||||
pop.refs.setReference(node);
|
||||
inputRef.current = node;
|
||||
},
|
||||
[pop.refs],
|
||||
);
|
||||
// `highlightIndex` = the row the user actually SEES highlighted (pointer hover, else keyboard); Enter
|
||||
// commits THAT, not the bare keyboard `activeIndex` — otherwise arrow-then-hover-then-Enter would toggle
|
||||
// a different option than the highlighted one, and a hover with no prior arrow (activeIndex still null)
|
||||
@@ -129,11 +149,14 @@ export default function Combobox({
|
||||
onChange(
|
||||
selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value],
|
||||
);
|
||||
// Keep the field focused so the user can keep picking / typing; the list stays open.
|
||||
inputRef.current?.focus();
|
||||
} else {
|
||||
// Single-select is final: close and DON'T re-focus — a refocus fires onFocus, which would
|
||||
// immediately re-open the list (now that the merged ref makes focus() actually work).
|
||||
onChange([value]);
|
||||
setOpenAnd(false);
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -154,7 +177,7 @@ export default function Combobox({
|
||||
className="glass-card flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-lg border border-border px-1.5 py-1 transition focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/30"
|
||||
>
|
||||
{selected.map((v) => (
|
||||
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-xs">
|
||||
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-sm">
|
||||
<span className="max-w-36 truncate">{labelFor(v)}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -172,7 +195,7 @@ export default function Combobox({
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={pop.refs.setReference}
|
||||
ref={setInputRef}
|
||||
{...pop.getReferenceProps({
|
||||
onChange: (e) => {
|
||||
setQuery((e.target as HTMLInputElement).value);
|
||||
@@ -197,7 +220,11 @@ export default function Combobox({
|
||||
? (placeholder ?? t("common.search", { defaultValue: "Search" }))
|
||||
: ""
|
||||
}
|
||||
className="min-w-16 flex-1 bg-transparent px-1 py-0.5 text-sm outline-hidden placeholder:text-muted/60"
|
||||
// min-w-8 (not -16): flex line-packing sizes the input at its MIN-width, so a wide 64px floor
|
||||
// made a single chip + the reserved input + the chevron overflow a narrow rail field — wrapping
|
||||
// the chevron to a second line while the (empty) input filled the first. A small floor keeps the
|
||||
// chip + input + chevron on one line; flex-1 still grows the input to fill whatever room is left.
|
||||
className="min-w-8 flex-1 bg-transparent px-1 py-0.5 text-sm outline-hidden placeholder:text-muted/60"
|
||||
/>
|
||||
<ChevronDown className="pointer-events-none mr-0.5 h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
</div>
|
||||
@@ -266,6 +293,32 @@ export default function Combobox({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Any/all match segment (like SortControl's direction footer) — only meaningful, so only shown,
|
||||
once 2+ values are picked. mousedown-preventDefault keeps the input focused so the list stays
|
||||
open in the combobox's focus model. */}
|
||||
{matchMode != null && onMatchModeChange && selected.length >= 2 && (
|
||||
<div className="mt-1 flex items-center gap-2 border-t border-border px-1 pt-2">
|
||||
<span className="text-2xs text-muted">{t("sidebar.match")}</span>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(["or", "and"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
aria-pressed={matchMode === m}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onMatchModeChange(m)}
|
||||
className={`rounded-md border px-2 py-0.5 text-2xs transition ${
|
||||
matchMode === m
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{t(m === "or" ? "sidebar.any" : "sidebar.all")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarDays, Check, ChevronDown } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { type FeedFilters } from "../lib/api";
|
||||
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
|
||||
|
||||
// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
|
||||
// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
|
||||
// reveals the from/to fields in the popover footer (reveal-on-select) and the trigger then reads the
|
||||
// chosen range; the presets and Any time close on pick. Selection logic is the pure lib/dateFilter so
|
||||
// it stays testable without mounting this. The popover hosts free-text (date) inputs, so it opens as a
|
||||
// `hostsInput` menu and the inputs stop non-Escape keys from reaching the list navigation.
|
||||
|
||||
// yyyy-mm-dd -> a local Date at midnight (split so it isn't parsed as UTC, which would shift a day).
|
||||
function parseDay(s: string): Date | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
||||
if (!m) return null;
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
}
|
||||
|
||||
export default function DateFilterSelect({
|
||||
filters,
|
||||
setFilters,
|
||||
presets,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
presets: readonly DatePreset[];
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [customOpen, setCustomOpen] = useState<boolean>(!!(filters.dateFrom || filters.dateTo));
|
||||
|
||||
// If the range is cleared elsewhere (chip-row Clear all / removing the date chip) while this popover
|
||||
// is closed, drop the custom reveal so the trigger reverts to "Any time" instead of lingering on
|
||||
// "Custom range" with empty fields. Guarded on !open so it never closes a freshly-picked Custom the
|
||||
// user hasn't filled in yet — that stays open until they close it (handled by onOpenChange below).
|
||||
// useLayoutEffect (not useEffect) so the reset commits BEFORE paint — otherwise the trigger flashes
|
||||
// one stale "Custom range" frame between the external clear and this reset.
|
||||
useLayoutEffect(() => {
|
||||
if (!open && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
|
||||
}, [open, customOpen, filters.dateFrom, filters.dateTo]);
|
||||
|
||||
const key = deriveDateKey(filters, customOpen, presets);
|
||||
|
||||
const fmt = (s: string) => {
|
||||
const d = parseDay(s);
|
||||
return d
|
||||
? new Intl.DateTimeFormat(i18n.language, { month: "short", day: "numeric" }).format(d)
|
||||
: s;
|
||||
};
|
||||
const rangeSummary = () => {
|
||||
const { dateFrom: f, dateTo: to } = filters;
|
||||
if (f && to) return `${fmt(f)} – ${fmt(to)}`;
|
||||
if (f) return t("sidebar.date.fromShort", { date: fmt(f) });
|
||||
if (to) return t("sidebar.date.untilShort", { date: fmt(to) });
|
||||
return t("sidebar.date.customRange");
|
||||
};
|
||||
const summary =
|
||||
key === ""
|
||||
? t("sidebar.date.anyTime")
|
||||
: key === "custom"
|
||||
? rangeSummary()
|
||||
: t("sidebar.datePreset." + presets.find((p) => String(p.days) === key)!.key);
|
||||
|
||||
const options = [
|
||||
{ value: "any", label: t("sidebar.date.anyTime") },
|
||||
...presets.map((p) => ({ value: String(p.days), label: t("sidebar.datePreset." + p.key) })),
|
||||
{ value: "custom", label: t("sidebar.date.customRange") },
|
||||
];
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: (next) => {
|
||||
setOpen(next);
|
||||
// Opened Custom but committed no range → drop the reveal on close so it doesn't stick.
|
||||
if (!next && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
|
||||
},
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
hostsInput: true,
|
||||
});
|
||||
|
||||
const pick = (value: string) => {
|
||||
const change = selectDateChip(value, filters);
|
||||
setCustomOpen(change.customOpen);
|
||||
setFilters(change.filters);
|
||||
if (value !== "custom") setOpen(false); // Custom stays open to reveal the range fields
|
||||
};
|
||||
|
||||
const dateInputCls =
|
||||
"bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40";
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
|
||||
className={`glass-card glass-hover flex w-full items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-sm transition ${
|
||||
open ? "border-accent" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span className="flex-1 truncate text-left text-fg">{summary}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("sidebar.datePresetLabel")}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div role="group" aria-label={t("sidebar.datePresetLabel")}>
|
||||
{options.map((o) => {
|
||||
const on = o.value === key || (o.value === "any" && key === "");
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.value}
|
||||
role="menuitemradio"
|
||||
checked={on}
|
||||
onSelect={() => pick(o.value)}
|
||||
label={o.label}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
|
||||
<span className="flex-1 truncate">{o.label}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. The inputs
|
||||
stop non-Escape keys so the date-segment arrows act on the field, not the menu's list nav. */}
|
||||
{key === "custom" && (
|
||||
<div className="mt-1 flex flex-col gap-1.5 border-t border-border px-1 pt-2">
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted">{t("sidebar.from")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateFrom ?? ""}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
dateFrom: e.target.value || undefined,
|
||||
maxAgeDays: undefined,
|
||||
})
|
||||
}
|
||||
className={dateInputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted">{t("sidebar.to")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateTo ?? ""}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
dateTo: e.target.value || undefined,
|
||||
maxAgeDays: undefined,
|
||||
})
|
||||
}
|
||||
className={dateInputCls}
|
||||
/>
|
||||
</label>
|
||||
{(filters.dateFrom || filters.dateTo) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
|
||||
className="text-2xs text-muted hover:text-accent self-end"
|
||||
>
|
||||
{t("sidebar.clearDates")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export default function DownloadCenter() {
|
||||
<>
|
||||
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 max-w-4xl w-full mx-auto">
|
||||
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
|
||||
<div className="flex items-center justify-between gap-3 mb-1">
|
||||
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
|
||||
<button
|
||||
|
||||
+124
-109
@@ -13,6 +13,7 @@ import {
|
||||
Grid3x3,
|
||||
Info,
|
||||
LayoutGrid,
|
||||
Library,
|
||||
List,
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
@@ -36,11 +37,13 @@ import { type SortOption } from "../lib/columnSort";
|
||||
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import SelectedViewChip from "./SelectedViewChip";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { useWizard } from "./WizardProvider";
|
||||
import SortControl from "./SortControl";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
import FilterSelect from "./FilterSelect";
|
||||
import ItemCount from "./ItemCount";
|
||||
import ViewSwitcher from "./ViewSwitcher";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||
@@ -567,124 +570,109 @@ export default function Feed({
|
||||
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
|
||||
}));
|
||||
|
||||
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
|
||||
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
|
||||
// same controls render on both the feed and channel-scoped rows.
|
||||
const sortView = (
|
||||
<>
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={{ key: sortKey, dir: sortDir }}
|
||||
onChange={(next) => {
|
||||
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: buildSort(next.key as SortKey, next.dir),
|
||||
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
||||
});
|
||||
}}
|
||||
label={t("feed.sortLabel")}
|
||||
ascLabel={t("feed.dirAsc")}
|
||||
descLabel={t("feed.dirDesc")}
|
||||
trailing={
|
||||
sortKey === "shuffle" ? (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||
title={t("sidebar.reshuffle")}
|
||||
aria-label={t("sidebar.reshuffle")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<ViewSwitcher
|
||||
value={view}
|
||||
options={FEED_VIEWS.map((id) => ({
|
||||
id,
|
||||
label: t("feed.view." + id),
|
||||
icon: VIEW_ICON[id],
|
||||
}))}
|
||||
onChange={setView}
|
||||
label={t("feed.viewLabel")}
|
||||
// The mode's name only earns its place once the window is wide.
|
||||
labelClassName="hidden xl:inline"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const toolbar = (
|
||||
<div className="pb-3">
|
||||
<div className="pb-1.5">
|
||||
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("feed.showLabel")}
|
||||
options={SHOW_IDS.map((id) => ({
|
||||
value: id,
|
||||
label: t("sidebar.show." + id),
|
||||
testId: `feed-show-${id}`,
|
||||
}))}
|
||||
value={filters.show}
|
||||
onChange={(show) => setFilters({ ...filters, show })}
|
||||
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
|
||||
never shoves the controls beside it (see ItemCount). */}
|
||||
<ItemCount count={summaryQuery.data?.count} />
|
||||
{/* Watch state is single-select (always exactly one); content types are an independent
|
||||
multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
|
||||
<FilterSelect
|
||||
label={t("feed.showLabel")}
|
||||
testId="feed-show"
|
||||
options={SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }))}
|
||||
selected={[filters.show]}
|
||||
onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })}
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("feed.contentLabel")}
|
||||
testId="feed-content"
|
||||
multiple
|
||||
options={CONTENT.map((c) => ({ value: c.key, label: t(c.label) }))}
|
||||
selected={CONTENT.filter((c) => filters[c.key]).map((c) => c.key)}
|
||||
onChange={(next) => {
|
||||
const patch = { ...filters };
|
||||
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
|
||||
setFilters(patch);
|
||||
}}
|
||||
/>
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
{/* Content-type toggles are MULTI-select (independent booleans) — same chip look via
|
||||
filterChipClass, but not a single-select group. */}
|
||||
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("feed.contentLabel")}>
|
||||
{CONTENT.map((c) => {
|
||||
const on = filters[c.key];
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
data-testid={`feed-content-${c.key}`}
|
||||
onClick={() => setFilters({ ...filters, [c.key]: !on })}
|
||||
aria-pressed={on}
|
||||
className={filterChipClass}
|
||||
>
|
||||
{t(c.label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept —
|
||||
it selects how videos ENTERED the library. On a channel-scoped view we already show all
|
||||
of this one channel's videos (librarySource is pinned to "all"), so the selector would
|
||||
only offer a near-empty, confusing slice — hide it here. */}
|
||||
{!channelScoped && (
|
||||
<>
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
|
||||
<span>{t("feed.source.label")}</span>
|
||||
<select
|
||||
value={filters.librarySource ?? "organic"}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
librarySource: e.target.value as FeedFilters["librarySource"],
|
||||
})
|
||||
}
|
||||
title={t("feed.source.tip")}
|
||||
className="bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
>
|
||||
<option value="organic">{t("feed.source.organic")}</option>
|
||||
<option value="all">{t("feed.source.all")}</option>
|
||||
<option value="search">{t("feed.source.only")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
|
||||
{summaryQuery.data
|
||||
? t("feed.videoCount", {
|
||||
count: summaryQuery.data.count,
|
||||
formattedCount: summaryQuery.data.count.toLocaleString(),
|
||||
})
|
||||
: " "}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={{ key: sortKey, dir: sortDir }}
|
||||
onChange={(next) => {
|
||||
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
||||
<FilterSelect
|
||||
label={t("feed.source.label")}
|
||||
testId="feed-source"
|
||||
options={[
|
||||
{ value: "organic", label: t("feed.source.organic") },
|
||||
{ value: "all", label: t("feed.source.all") },
|
||||
{ value: "search", label: t("feed.source.only") },
|
||||
]}
|
||||
selected={[filters.librarySource ?? "organic"]}
|
||||
onChange={(next) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: buildSort(next.key as SortKey, next.dir),
|
||||
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
||||
});
|
||||
}}
|
||||
label={t("feed.sortLabel")}
|
||||
ascLabel={t("feed.dirAsc")}
|
||||
descLabel={t("feed.dirDesc")}
|
||||
trailing={
|
||||
sortKey === "shuffle" ? (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||
title={t("sidebar.reshuffle")}
|
||||
aria-label={t("sidebar.reshuffle")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
) : undefined
|
||||
librarySource: next[0] as FeedFilters["librarySource"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<ViewSwitcher
|
||||
value={view}
|
||||
options={FEED_VIEWS.map((id) => ({
|
||||
id,
|
||||
label: t("feed.view." + id),
|
||||
icon: VIEW_ICON[id],
|
||||
}))}
|
||||
onChange={setView}
|
||||
label={t("feed.viewLabel")}
|
||||
// This toolbar is packed — show/content chips, source, count, sort — so the mode's name
|
||||
// only earns its place once the window is wide.
|
||||
labelClassName="hidden xl:inline"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-2"}>
|
||||
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-1"}>
|
||||
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
|
||||
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
|
||||
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
|
||||
@@ -692,19 +680,46 @@ export default function Feed({
|
||||
baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you
|
||||
applied" when it's just the page being itself. */}
|
||||
<PageToolbar>
|
||||
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-4 pb-1"}>
|
||||
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
|
||||
{toolbar}
|
||||
{!channelScoped && (
|
||||
<ActiveFilterChips
|
||||
filters={activeFilters}
|
||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||
/>
|
||||
)}
|
||||
{/* The controls row sits directly above the grid: the applied-filter chips flow from the left
|
||||
(overflowing to a "+N"/count pill so they never crowd the controls), while Sort + View stay
|
||||
pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you
|
||||
applied"), but still get the right-pinned Sort/View. */}
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
{!channelScoped && (
|
||||
<>
|
||||
{/* The active personal view (if any) leads the applied-filter row. */}
|
||||
<SelectedViewChip />
|
||||
<ActiveFilterChips
|
||||
filters={activeFilters}
|
||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||
className="flex-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
{items.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted">
|
||||
<p>{t("feed.noMatches")}</p>
|
||||
{/* A channel filter under the "Mine" scope returns nothing when that channel is hidden from
|
||||
your subscriptions (or you don't subscribe to it) — its videos only live in the shared
|
||||
Library. Point there rather than leaving a bare "no matches". */}
|
||||
{filters.channelIds.length > 0 && filters.scope === "my" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm">{t("feed.channelScopeHint")}</p>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="mt-3 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<Library className="w-4 h-4" />
|
||||
{t("feed.viewInLibrary")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{filters.q.trim() && !isDemo && (
|
||||
<button
|
||||
onClick={() => onYtSearch(filters.q.trim())}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// loadAccountFilters is the localStorage seed path for the feed filters. It must migrate a persisted
|
||||
// legacy global `tagMode` (older builds wrote it) into the per-category `tagModes` — the same coercion
|
||||
// the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost.
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { loadAccountFilters } from "./FeedFiltersProvider";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
|
||||
const ID = 7;
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
describe("loadAccountFilters — legacy tagMode migration on seed", () => {
|
||||
it("migrates a persisted legacy tagMode:'and' from the last-session blob into topic", () => {
|
||||
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ q: "hi", tagMode: "and" }));
|
||||
const f = loadAccountFilters(ID);
|
||||
expect(f.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
|
||||
expect(f.q).toBe("hi"); // other fields preserved through the merge
|
||||
});
|
||||
|
||||
it("migrates a legacy tagMode:'and' from the default-view mirror when no last-session blob exists", () => {
|
||||
localStorage.setItem(
|
||||
accountKey(LS.defaultViewFilters, ID)!,
|
||||
JSON.stringify({ tagMode: "and" }),
|
||||
);
|
||||
expect(loadAccountFilters(ID).tagModes.topic).toBe("and");
|
||||
});
|
||||
|
||||
it("leaves tagModes at defaults when nothing is stored", () => {
|
||||
expect(loadAccountFilters(ID).tagModes).toEqual({ language: "or", topic: "or", other: "or" });
|
||||
});
|
||||
|
||||
it("preserves an already-migrated per-category tagModes blob", () => {
|
||||
localStorage.setItem(
|
||||
accountKey(LS.filters, ID)!,
|
||||
JSON.stringify({ tagModes: { language: "and", topic: "or", other: "and" } }),
|
||||
);
|
||||
expect(loadAccountFilters(ID).tagModes).toEqual({
|
||||
language: "and",
|
||||
topic: "or",
|
||||
other: "and",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,10 +9,16 @@ import {
|
||||
} from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
|
||||
import {
|
||||
api,
|
||||
getActiveAccount,
|
||||
coerceTagModes,
|
||||
DEFAULT_TAG_MODES,
|
||||
type FeedFilters,
|
||||
} from "../lib/api";
|
||||
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage";
|
||||
import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
|
||||
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
|
||||
@@ -32,7 +38,7 @@ import { useNavigation } from "./NavigationProvider";
|
||||
|
||||
const DEFAULT_FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagMode: "or",
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
@@ -49,7 +55,7 @@ const FILTERS_KEY = LS.filters;
|
||||
// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When
|
||||
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
|
||||
// post-login effect loads the real ones once the id arrives.
|
||||
function loadAccountFilters(id: number | null): FeedFilters {
|
||||
export function loadAccountFilters(id: number | null): FeedFilters {
|
||||
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The
|
||||
// starred default view only SEEDS a fresh account that has never stored filters: it drives the
|
||||
@@ -62,10 +68,19 @@ function loadAccountFilters(id: number | null): FeedFilters {
|
||||
} catch {
|
||||
/* localStorage may be unavailable */
|
||||
}
|
||||
if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS);
|
||||
// Migrate a persisted legacy global `tagMode` into the per-category `tagModes` on every seed path,
|
||||
// matching what the saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes
|
||||
// MUST run on the RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects
|
||||
// DEFAULT_TAG_MODES (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
|
||||
if (hasStored && fKey) {
|
||||
const raw = readJSON<Partial<FeedFilters>>(fKey, {});
|
||||
return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) };
|
||||
}
|
||||
const dvKey = accountKey(LS.defaultViewFilters, id);
|
||||
const def = dvKey ? readJSON<FeedFilters | null>(dvKey, null) : null;
|
||||
if (def) return { ...DEFAULT_FILTERS, ...def };
|
||||
const def = dvKey ? readJSON<Partial<FeedFilters> | null>(dvKey, null) : null;
|
||||
if (def) {
|
||||
return { ...DEFAULT_FILTERS, ...def, tagModes: coerceTagModes(def) };
|
||||
}
|
||||
return { ...DEFAULT_FILTERS };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Library, User } from "lucide-react";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
|
||||
// The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header
|
||||
// search box — mirroring PlexScopeToggle's placement, so the primary "what am I browsing" control is
|
||||
// always in reach instead of buried in the (possibly collapsed) filter rail. Same `filters.scope`
|
||||
// field, so behaviour is unchanged. The `pointer-events-auto`/`shrink-0` wrapper matches the other
|
||||
// header pills: the header band is `pointer-events-none`, and the pill must not collapse in the flex row.
|
||||
export default function FeedScopeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
return (
|
||||
<div className="pointer-events-auto shrink-0">
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("header.scope.label")}
|
||||
options={[
|
||||
{
|
||||
value: "my",
|
||||
label: t("header.scope.my"),
|
||||
icon: <User className="w-3.5 h-3.5" />,
|
||||
title: t("header.scope.myTip"),
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
label: t("header.scope.all"),
|
||||
icon: <Library className="w-3.5 h-3.5" />,
|
||||
title: t("header.scope.allTip"),
|
||||
},
|
||||
]}
|
||||
value={filters.scope}
|
||||
onChange={(scope) => setFilters({ ...filters, scope })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,9 @@ import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
|
||||
// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
|
||||
// the layout default, edit hint); each rail supplies only what actually differs — its identity
|
||||
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content above the
|
||||
// groups (`top`, e.g. the feed's scope toggle) or below (`children`, e.g. the feed's tag manager).
|
||||
// `kind` is the PanelId (also the layout key), so `top`/`children` default to nothing for the rails
|
||||
// that need neither.
|
||||
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content below the
|
||||
// groups (`children`, e.g. the feed's tag manager). `kind` is the PanelId (also the layout key), so
|
||||
// `children` defaults to nothing for the rails that need none.
|
||||
export default function FilterRail({
|
||||
kind,
|
||||
title,
|
||||
@@ -23,7 +22,10 @@ export default function FilterRail({
|
||||
layout,
|
||||
setLayout,
|
||||
items,
|
||||
top,
|
||||
collapsible = true,
|
||||
iconExpanded,
|
||||
onTitleClick,
|
||||
titleActionLabel,
|
||||
children,
|
||||
}: {
|
||||
kind: PanelId;
|
||||
@@ -36,7 +38,11 @@ export default function FilterRail({
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
items: PanelItem[];
|
||||
top?: ReactNode;
|
||||
/** Passed to PanelGroups: false renders plain always-open label + control rows (no collapse). */
|
||||
collapsible?: boolean;
|
||||
iconExpanded?: ReactNode;
|
||||
onTitleClick?: () => void;
|
||||
titleActionLabel?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -53,9 +59,17 @@ export default function FilterRail({
|
||||
onReset={() => setLayout(defaultLayout(kind))}
|
||||
editHint={t("sidebar.editHint")}
|
||||
actions={actions}
|
||||
iconExpanded={iconExpanded}
|
||||
onTitleClick={onTitleClick}
|
||||
titleActionLabel={titleActionLabel}
|
||||
>
|
||||
{top}
|
||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||
<PanelGroups
|
||||
items={items}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
editing={editing}
|
||||
collapsible={collapsible}
|
||||
/>
|
||||
{children}
|
||||
</SidePanel>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the labeled-pill FilterSelect. Labels are plain props (no i18n). Built on the floating-ui
|
||||
// AnchoredPopover platform, so we stub the two observers autoUpdate wires and wrap in a FloatingTree.
|
||||
// floating-ui never positions in jsdom → the surface stays visibility:hidden, so the menuitem role
|
||||
// queries pass `{ hidden: true }`. Single-select rows are `menuitemradio`, multi-select `menuitemcheckbox`
|
||||
// (a menu surface with checkable items, not a listbox of orphaned options).
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import FilterSelect, { type FilterSelectOption } from "./FilterSelect";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const STATUS: FilterSelectOption[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "new", label: "New" },
|
||||
];
|
||||
const TYPES: FilterSelectOption[] = [
|
||||
{ value: "n", label: "Normal" },
|
||||
{ value: "s", label: "Shorts" },
|
||||
];
|
||||
const tree = (ui: React.ReactNode) => render(<FloatingTree>{ui}</FloatingTree>);
|
||||
|
||||
describe("FilterSelect", () => {
|
||||
it("renders a labeled trigger `Label: value` and keeps options unmounted until opened", () => {
|
||||
tree(<FilterSelect label="Status" options={STATUS} selected={["all"]} onChange={vi.fn()} />);
|
||||
const trigger = screen.getByRole("button", { name: /^Status/ });
|
||||
expect(trigger.textContent).toContain("Status:");
|
||||
expect(trigger.textContent).toContain("All");
|
||||
expect(screen.queryByRole("menuitemradio", { hidden: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("single: picking a row replaces the selection and closes", () => {
|
||||
const onChange = vi.fn();
|
||||
tree(<FilterSelect label="Status" options={STATUS} selected={["all"]} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Status/ }));
|
||||
const rows = screen.getAllByRole("menuitemradio", { hidden: true });
|
||||
expect(rows.map((o) => o.textContent)).toEqual(["All", "New"]);
|
||||
expect(rows[0]!.getAttribute("aria-checked")).toBe("true"); // "All" is the current selection
|
||||
fireEvent.click(rows[1]!); // New
|
||||
expect(onChange).toHaveBeenCalledWith(["new"]);
|
||||
expect(screen.queryByRole("menuitemradio", { hidden: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("multiple: toggling a row adds it and keeps the panel open", () => {
|
||||
const onChange = vi.fn();
|
||||
tree(
|
||||
<FilterSelect label="Type" multiple options={TYPES} selected={["n"]} onChange={onChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Type/ }));
|
||||
fireEvent.click(screen.getAllByRole("menuitemcheckbox", { hidden: true })[1]!); // Shorts
|
||||
expect(onChange).toHaveBeenCalledWith(["n", "s"]);
|
||||
expect(screen.queryAllByRole("menuitemcheckbox", { hidden: true }).length).toBeGreaterThan(0); // still open
|
||||
});
|
||||
|
||||
it("multiple + requireOne: the last remaining selection can't be turned off", () => {
|
||||
const onChange = vi.fn();
|
||||
tree(
|
||||
<FilterSelect label="Type" multiple options={TYPES} selected={["n"]} onChange={onChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Type/ }));
|
||||
fireEvent.click(screen.getAllByRole("menuitemcheckbox", { hidden: true })[0]!); // Normal — the only one selected
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("multiple: the trigger summarises extra picks as `First +N`", () => {
|
||||
tree(
|
||||
<FilterSelect
|
||||
label="Type"
|
||||
multiple
|
||||
options={TYPES}
|
||||
selected={["n", "s"]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /^Type/ }).textContent).toContain("Normal +1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// A compact "labeled pill → dropdown" filter control: the trigger reads `Label: value ▾`, and clicking
|
||||
// it opens a menu of options (facet counts optional). It replaces a wall of individual toolbar chips
|
||||
// with one pill per filter group, in two flavours:
|
||||
// - single (default): picking a row replaces the choice and closes — always exactly one active.
|
||||
// - multiple: rows toggle independently and the panel stays open; `requireOne` blocks clearing the last.
|
||||
// Built on the same AnchoredPopover platform as ActionMenu/Combobox (arrows/typeahead/Escape/anchor-flip);
|
||||
// reusable anywhere a small, known set of options currently renders as a chip group. See FilterSelect.dom.test.
|
||||
|
||||
export interface FilterSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** A facet count shown at the row's end. */
|
||||
count?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FilterSelectProps {
|
||||
/** Visible group name shown as the trigger prefix (`Label: value`) and the accessible name. */
|
||||
label: string;
|
||||
options: readonly FilterSelectOption[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
/** Toggle a subset (default false = pick exactly one). */
|
||||
multiple?: boolean;
|
||||
/** Multi only: keep at least one selected — the last active row can't be turned off. Default true. */
|
||||
requireOne?: boolean;
|
||||
/** Test hook: the trigger gets `data-testid={testId}`; each option gets `${testId}-${value}`. */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export default function FilterSelect({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
multiple = false,
|
||||
requireOne = true,
|
||||
testId,
|
||||
}: FilterSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? v;
|
||||
// Summarise the trigger value. Multi lists the first chosen (in options order, so it's stable) + "+N".
|
||||
const chosen = options.filter((o) => selected.includes(o.value));
|
||||
const first = chosen[0];
|
||||
const summary = multiple
|
||||
? first
|
||||
? `${first.label}${chosen.length > 1 ? ` +${chosen.length - 1}` : ""}`
|
||||
: "—"
|
||||
: labelOf(selected[0] ?? options[0]?.value ?? "");
|
||||
|
||||
const commit = (value: string) => {
|
||||
if (!multiple) {
|
||||
onChange([value]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const has = selected.includes(value);
|
||||
if (has) {
|
||||
if (requireOne && selected.length === 1) return; // don't let the last one go
|
||||
onChange(selected.filter((x) => x !== value));
|
||||
} else {
|
||||
onChange([...selected, value]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
data-testid={testId}
|
||||
// Carry the current value in the accessible name (not just the group label), matching
|
||||
// ViewSwitcher — a screen reader announces "Watch state: Unwatched", not a bare "Watch state".
|
||||
aria-label={`${label}: ${summary}`}
|
||||
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
|
||||
open ? "border-accent" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<span className="text-muted">{label}:</span>
|
||||
<span className="text-fg">{summary}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{/* Single = one exclusive choice (radios), multi = independent toggles (checkboxes). Both are
|
||||
menuitem-family roles scoped by a group, so AT announces the checked state — the surface is a
|
||||
menu (see the mode above), never a bare listbox with orphaned options. */}
|
||||
<div role="group" aria-label={label}>
|
||||
{options.map((o) => {
|
||||
const on = selected.includes(o.value);
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.value}
|
||||
role={multiple ? "menuitemcheckbox" : "menuitemradio"}
|
||||
checked={on}
|
||||
disabled={o.disabled}
|
||||
onSelect={() => commit(o.value)}
|
||||
label={o.label}
|
||||
data-testid={testId ? `${testId}-${o.value}` : undefined}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{multiple ? (
|
||||
<span
|
||||
className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border ${
|
||||
on ? "border-accent bg-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
) : (
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
|
||||
)}
|
||||
<span className="flex-1 truncate">{o.label}</span>
|
||||
{o.count != null && (
|
||||
<span className="shrink-0 text-2xs tabular-nums text-muted">{o.count}</span>
|
||||
)}
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,19 @@ import { forwardRef, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Youtube } from "lucide-react";
|
||||
import type { Me } from "../lib/api";
|
||||
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
||||
import ModuleName from "./ModuleName";
|
||||
import SearchBar from "./SearchBar";
|
||||
import PlexSearchBox from "./PlexSearchBox";
|
||||
import PlexScopeToggle from "./PlexScopeToggle";
|
||||
import PlexViewToggle from "./PlexViewToggle";
|
||||
import FeedScopeToggle from "./FeedScopeToggle";
|
||||
import SyncStatus from "./SyncStatus";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||
|
||||
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
|
||||
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
|
||||
// sync chip at the right end.
|
||||
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
|
||||
// has one, and the per-user sync chip at the right end.
|
||||
// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly
|
||||
// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which
|
||||
// owns both the header slot and the padded content wrapper (E5 S7).
|
||||
@@ -30,7 +29,6 @@ const Header = forwardRef<
|
||||
>(function Header({ me, onYtSearch }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const { page } = useNavigation();
|
||||
const { setPage } = useNavigationActions();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
// Each module's search term lives in its own provider (shared with that module's rail/content):
|
||||
@@ -40,12 +38,6 @@ const Header = forwardRef<
|
||||
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
|
||||
const { search: channelsSearch } = useChannels();
|
||||
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
|
||||
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
||||
// it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
|
||||
// dynamic width so the pill is sized to the widest reachable title in the current language.
|
||||
const order = moduleOrder(me);
|
||||
const moduleLabels = order.map((p) => t(moduleLabelKey[p]));
|
||||
const multiModule = order.length > 1;
|
||||
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
|
||||
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
|
||||
|
||||
@@ -99,20 +91,23 @@ const Header = forwardRef<
|
||||
// slim and/or no filter sidebar is present — no hard-coded offset.
|
||||
<div
|
||||
ref={ref}
|
||||
className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2"
|
||||
// left-4 / right = 1rem + the scrollbar gutter defines the full content column; max-w-[--content-mw]
|
||||
// + mx-auto then centres the header band on the SAME width the page's content uses, so on a max-width
|
||||
// page (Plex/Channels/Playlists) the band lines up with the centred grid, not the whole column. `none`
|
||||
// (feed) = full width. Matches the toolbar band below it. `justify-center` then centres the search-box
|
||||
// group (search + scope toggle / sync chip) within that band, so the box sits centred over the cards
|
||||
// once the column is wider than the box's max-w — not hugged to the left edge.
|
||||
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] mx-auto max-w-[var(--content-mw,none)] z-chrome flex flex-wrap items-center justify-center gap-x-3 gap-y-2"
|
||||
>
|
||||
<ModuleName
|
||||
label={t(moduleLabelKey[page])}
|
||||
labels={moduleLabels}
|
||||
onPrev={() => setPage(stepModule(me, page, -1))}
|
||||
onNext={() => setPage(stepModule(me, page, 1))}
|
||||
canPrev={multiModule}
|
||||
canNext={multiModule}
|
||||
/>
|
||||
{search}
|
||||
{/* Mine/Library scope sits right of the feed search box — relocated here from the filter sidebar
|
||||
so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
|
||||
{page === "feed" && <FeedScopeToggle />}
|
||||
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
|
||||
the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. */}
|
||||
the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. The
|
||||
Discover ⟷ Browse toggle sits right beside it (driven via the PlexViewBridge). */}
|
||||
{page === "plex" && me.plex_enabled && <PlexScopeToggle />}
|
||||
{page === "plex" && me.plex_enabled && <PlexViewToggle />}
|
||||
{/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other
|
||||
pills rather than pinned to the far right: on a wide window that just strands it. Only on
|
||||
the two modules it says anything about: the feed those subscriptions fill, and the manager
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the fixed-width count pill. Uses the REAL i18n instance (not a key-passthrough) so the
|
||||
// plural selection (itemCount_one/_other) and the "1M+" overflow interpolation are actually exercised —
|
||||
// those are the two branches worth guarding. English is forced so the singular/plural strings are stable.
|
||||
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import "../i18n"; // side-effect: initialise the shared i18next instance
|
||||
import i18n from "i18next";
|
||||
import ItemCount from "./ItemCount";
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const text = () => screen.getByTestId("item-count").textContent;
|
||||
|
||||
describe("ItemCount", () => {
|
||||
it("uses the singular string at 1 and the plural above it", () => {
|
||||
render(<ItemCount count={1} />);
|
||||
expect(text()).toBe("1 item");
|
||||
});
|
||||
|
||||
it("pluralises and thousands-separates a normal count", () => {
|
||||
render(<ItemCount count={192045} />);
|
||||
// Separators come from toLocaleString (runtime-locale dependent) — compare against it, not a fixed
|
||||
// en-US string, so the test asserts ItemCount's formatting path rather than the CI locale.
|
||||
expect(text()).toBe(`${(192045).toLocaleString()} items`);
|
||||
});
|
||||
|
||||
it("collapses a count of a million or more to 1M+, with the exact figure in the tooltip", () => {
|
||||
render(<ItemCount count={1_000_000} />);
|
||||
expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent
|
||||
expect(screen.getByTestId("item-count").getAttribute("title")).toBe(
|
||||
(1_000_000).toLocaleString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the icon-only fixed box (empty label) while the count is undefined", () => {
|
||||
render(<ItemCount />);
|
||||
expect(text()).toBe(""); // the Layers icon carries no text
|
||||
expect(screen.getByTestId("item-count").getAttribute("title")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
// At a million and above we stop printing the exact figure — six digits is the readable ceiling, so
|
||||
// the pill shows "1M+" and the tooltip carries the precise number.
|
||||
const OVERFLOW_AT = 1_000_000;
|
||||
|
||||
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count in the same glass pill as
|
||||
// the filter dropdowns, minus the chevron and the interactivity. The fixed width is the point — a
|
||||
// changing number must never shove the controls beside it, so the box is sized to the widest value
|
||||
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
|
||||
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
|
||||
// so any surface can reuse it for whatever it counts.
|
||||
export default function ItemCount({ count }: { count?: number }) {
|
||||
const { t } = useTranslation();
|
||||
// `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just
|
||||
// show the icon until the number arrives.
|
||||
const label =
|
||||
count == null
|
||||
? ""
|
||||
: t("common.itemCount", {
|
||||
count,
|
||||
formattedCount: count >= OVERFLOW_AT ? "1M+" : count.toLocaleString(),
|
||||
});
|
||||
return (
|
||||
<span
|
||||
data-testid="item-count"
|
||||
title={count?.toLocaleString()}
|
||||
className="glass-card inline-flex shrink-0 items-center justify-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-muted min-w-[9rem] whitespace-nowrap"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span className="tabular-nums">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
// The floating module-name pill in the top header. The label area is sized to the WIDEST
|
||||
// reachable module title (measured live, in the current language only — so dropping a locale or
|
||||
// gaining/losing a module just changes the input, nothing is hard-coded), which keeps the label
|
||||
// column a constant width: the name never shifts and the arrows stay put when paging between
|
||||
// modules. Carries the back/forward arrows that step cyclically through the active modules, plus
|
||||
// the accent dot (the former PageTitle "eyebrow" look, folded in here).
|
||||
export default function ModuleName({
|
||||
label,
|
||||
labels,
|
||||
onPrev,
|
||||
onNext,
|
||||
canPrev,
|
||||
canNext,
|
||||
}: {
|
||||
label: string;
|
||||
// Every reachable module's title in the current language — drives the fixed label width.
|
||||
labels: string[];
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
canPrev: boolean;
|
||||
canNext: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const labelRef = useRef<HTMLSpanElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [labelW, setLabelW] = useState<number>();
|
||||
|
||||
// Measure the widest label using the label span's own rendered font (respects font-scale,
|
||||
// tracking and the uppercase transform). Re-runs whenever the label set changes — i.e. on a
|
||||
// language switch or an account switch that changes which modules are reachable.
|
||||
useLayoutEffect(() => {
|
||||
const el = labelRef.current;
|
||||
if (!el || labels.length === 0) return;
|
||||
const cs = getComputedStyle(el);
|
||||
const canvas = canvasRef.current ?? (canvasRef.current = document.createElement("canvas"));
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
|
||||
const ls = parseFloat(cs.letterSpacing) || 0;
|
||||
const upper = cs.textTransform === "uppercase";
|
||||
let max = 0;
|
||||
for (const s of labels) {
|
||||
const txt = upper ? s.toUpperCase() : s;
|
||||
const w = ctx.measureText(txt).width + ls * txt.length;
|
||||
if (w > max) max = w;
|
||||
}
|
||||
setLabelW(Math.ceil(max) + 2);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [labels.join("")]);
|
||||
|
||||
const arrow =
|
||||
"p-1 rounded-lg text-muted transition hover:text-fg hover:bg-card disabled:opacity-25 disabled:pointer-events-none";
|
||||
return (
|
||||
<div className="pointer-events-auto shrink-0 max-w-[55vw] h-11 px-2.5 rounded-2xl glass-menu flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={!canPrev}
|
||||
title={t("header.prevModule")}
|
||||
aria-label={t("header.prevModule")}
|
||||
className={arrow}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!canNext}
|
||||
title={t("header.nextModule")}
|
||||
aria-label={t("header.nextModule")}
|
||||
className={arrow}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-px h-5 bg-border/70 mx-1 shrink-0" />
|
||||
<span className="min-w-0 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-fg">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" />
|
||||
<span
|
||||
ref={labelRef}
|
||||
className="min-w-0 truncate"
|
||||
style={labelW ? { width: labelW } : undefined}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { pageTitleKey } from "../lib/pageMeta";
|
||||
import { setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
import { setNavigator } from "../lib/nav";
|
||||
import { nextNavId, notifyNav } from "../lib/history";
|
||||
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
|
||||
|
||||
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
|
||||
@@ -128,10 +129,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
// (chan/v/pl) — a page switch resets to that module's root. Feed filters (owned by
|
||||
// FeedFiltersProvider) are left intact.
|
||||
window.history.pushState(
|
||||
{ sfPage: next },
|
||||
{ sfPage: next, sfScroll: nextNavId() },
|
||||
"",
|
||||
urlWithParams({ page: next === "feed" ? null : next, chan: null, v: null, pl: null }),
|
||||
);
|
||||
notifyNav();
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function NotificationsPanel() {
|
||||
<>
|
||||
{/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
|
||||
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
|
||||
<div className="glass rounded-2xl p-4 flex items-center gap-3">
|
||||
<Bell className="w-5 h-5 text-accent shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { ensureNavId, peekNavId, subscribeNav } from "../lib/history";
|
||||
|
||||
// Per-view saved scroll offsets. Module-level so a view's position survives its scroller
|
||||
// unmounting — opening a channel page swaps in a different shell/scroller — and is restored when
|
||||
// the same view returns. Keyed by the shell's `scrollKey`.
|
||||
// Saved scroll offsets, keyed by `${historyEntryId}:${scrollKey}`. The entry id ties an offset to the
|
||||
// exact history entry, so Back restores where you left it and a forward navigation (a new entry) opens
|
||||
// at the top; the scrollKey ties it to the logical content, so a new feed filter still starts at the
|
||||
// top WITHIN the same entry. Module-level so a view's position survives its scroller unmounting
|
||||
// (opening a channel page swaps in a different shell/scroller) and is restored when it returns.
|
||||
const savedScroll = new Map<string, number>();
|
||||
// The key embeds an ever-increasing history-entry id, so without a bound this map would grow one
|
||||
// entry per scrolled navigation for the life of the session. Cap it with a small LRU (Map preserves
|
||||
// insertion order): re-insert on write to mark most-recently-used, evict the oldest past the cap.
|
||||
const SCROLL_MEMORY_CAP = 200;
|
||||
function rememberScroll(key: string, top: number): void {
|
||||
savedScroll.delete(key);
|
||||
savedScroll.set(key, top);
|
||||
if (savedScroll.size > SCROLL_MEMORY_CAP) {
|
||||
const oldest = savedScroll.keys().next().value;
|
||||
if (oldest !== undefined) savedScroll.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame
|
||||
// or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise
|
||||
@@ -85,23 +100,50 @@ export default function PageScroller({
|
||||
[fadeRef],
|
||||
);
|
||||
|
||||
// Live scrollKey for the event/nav-bus callbacks (which are bound once, not per scrollKey).
|
||||
const scrollKeyRef = useRef(scrollKey);
|
||||
scrollKeyRef.current = scrollKey;
|
||||
const lastComposite = useRef<string | null>(null);
|
||||
const cancelRef = useRef<() => void>(() => {});
|
||||
|
||||
// Restore (or reset to top) whenever the (history entry + content) identity changes — a page/sub-view
|
||||
// navigation, a Back/Forward, or a feed filter change. Idempotent: a no-op when the composite is
|
||||
// unchanged, so it's safe to call from several triggers.
|
||||
const resolve = useCallback(() => {
|
||||
const el = elRef.current;
|
||||
if (!el || scrollKeyRef.current == null) return;
|
||||
const key = `${ensureNavId()}:${scrollKeyRef.current}`;
|
||||
if (key === lastComposite.current) return;
|
||||
lastComposite.current = key;
|
||||
cancelRef.current();
|
||||
cancelRef.current = restoreScroll(el, savedScroll.get(key) ?? 0);
|
||||
}, []);
|
||||
|
||||
// Trigger 1: mount + a content-key change (page switch, feed filter) — both re-render this component.
|
||||
useLayoutEffect(() => {
|
||||
resolve();
|
||||
}, [scrollKey, resolve]);
|
||||
|
||||
// Trigger 2: Back/Forward (popstate) and a Plex sub-view open (nav bus) — neither necessarily
|
||||
// re-renders this component. Also records the position continuously (not at unmount): the scroller's
|
||||
// scrollTop is unreliable by the time an unmount cleanup runs, so a save-on-scroll survives teardown.
|
||||
useLayoutEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el || scrollKey == null) return;
|
||||
const key = scrollKey;
|
||||
const want = savedScroll.get(key) ?? 0;
|
||||
const cancelRestore = restoreScroll(el, want);
|
||||
// Record position continuously (not at unmount): the scroller's scrollTop is unreliable by the
|
||||
// time an unmount cleanup runs, and a save-on-scroll survives whatever tears down last.
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
savedScroll.set(key, el.scrollTop);
|
||||
if (scrollKeyRef.current == null) return;
|
||||
rememberScroll(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop);
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("popstate", resolve);
|
||||
const unsubNav = subscribeNav(resolve);
|
||||
return () => {
|
||||
cancelRestore();
|
||||
cancelRef.current();
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("popstate", resolve);
|
||||
unsubNav();
|
||||
};
|
||||
}, [scrollKey]);
|
||||
}, [resolve]);
|
||||
|
||||
return (
|
||||
<main
|
||||
|
||||
@@ -43,11 +43,97 @@ describe("PageShell --hdr-h (E5 S7)", () => {
|
||||
Object.defineProperty(hdr, "offsetTop", { value: 12, configurable: true });
|
||||
Object.defineProperty(hdr, "offsetHeight", { value: 96, configurable: true });
|
||||
roCb?.();
|
||||
expect(content.style.getPropertyValue("--hdr-h")).toBe("116px"); // 12 + 96 + 8
|
||||
expect(content.style.getPropertyValue("--hdr-h")).toBe("112px"); // 12 + 96 + 4
|
||||
});
|
||||
|
||||
it("adds no `--hdr-h` pad when there is no header", () => {
|
||||
const { container } = render(<PageShell>content</PageShell>);
|
||||
expect(container.querySelector('[class*="pt-(--hdr-h)"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("re-measures when the header is removed then restored (feed→channel→back)", () => {
|
||||
// PageShell is REUSED across feed↔channel navigation (same component position in App.tsx) — it
|
||||
// never remounts, so the header DOM node is swapped under a stable PageShell. The measurement
|
||||
// must re-run on that swap; a one-shot effect kept observing the FIRST node and, when it detached,
|
||||
// rewrote --hdr-h to a stale 4px that stuck on Back. Regression for the channel-Back header overlap.
|
||||
const hdr = () => <div data-testid="hdr">H</div>;
|
||||
const { container, rerender } = render(<PageShell header={hdr()}>content</PageShell>);
|
||||
const measure = (top: number, h: number) => {
|
||||
const el = container.querySelector('[data-testid="hdr"]') as HTMLElement;
|
||||
Object.defineProperty(el, "offsetTop", { value: top, configurable: true });
|
||||
Object.defineProperty(el, "offsetHeight", { value: h, configurable: true });
|
||||
roCb?.();
|
||||
};
|
||||
measure(12, 52);
|
||||
const content = () => container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement;
|
||||
expect(content().style.getPropertyValue("--hdr-h")).toBe("68px"); // 12 + 52 + 4
|
||||
|
||||
rerender(<PageShell>content</PageShell>); // → channel page: header gone
|
||||
rerender(<PageShell header={hdr()}>content</PageShell>); // → Back: header returns as a NEW node
|
||||
measure(12, 52); // fires the NEW header's observer
|
||||
expect(content().style.getPropertyValue("--hdr-h")).toBe("68px"); // re-measured, not the stale 4px
|
||||
});
|
||||
});
|
||||
|
||||
// The pinned-rail overlay decision: a rail pins as OVERLAY (--rail-pinned-w 2.25rem, the toolbar keeps a
|
||||
// slim gutter and content stays put) once the window is wide enough that the centred content leaves room
|
||||
// for the rail in its left margin (maxWidth + ~36rem); below that it pins in-flow (16.75rem). The rem→px
|
||||
// conversion must use the LIVE root font-size, not a hardcoded 16, so it stays correct at every text size.
|
||||
describe("PageShell --rail-pinned-w / --content-mw", () => {
|
||||
const root = document.documentElement;
|
||||
const setWidth = (w: number) =>
|
||||
Object.defineProperty(window, "innerWidth", { value: w, configurable: true });
|
||||
afterEach(() => {
|
||||
root.style.removeProperty("font-size");
|
||||
root.style.removeProperty("--content-mw");
|
||||
root.style.removeProperty("--rail-pinned-w");
|
||||
});
|
||||
|
||||
it("overlays the rail once the window clears maxWidth + 36rem (at 16px/rem)", () => {
|
||||
setWidth(1600); // 64rem*16 + 36rem*16 = 1024 + 576 = 1600 → exactly roomy
|
||||
render(<PageShell maxWidth="64rem">content</PageShell>);
|
||||
expect(root.style.getPropertyValue("--content-mw")).toBe("64rem");
|
||||
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("2.25rem");
|
||||
});
|
||||
|
||||
it("keeps the rail in-flow just below the threshold", () => {
|
||||
setWidth(1599);
|
||||
render(<PageShell maxWidth="64rem">content</PageShell>);
|
||||
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
|
||||
});
|
||||
|
||||
it("never overlays on a full-width page (no maxWidth)", () => {
|
||||
setWidth(3000);
|
||||
render(<PageShell>content</PageShell>);
|
||||
expect(root.style.getPropertyValue("--content-mw")).toBe("none");
|
||||
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
|
||||
});
|
||||
|
||||
it("recomputes on resize", () => {
|
||||
setWidth(1599);
|
||||
render(<PageShell maxWidth="64rem">content</PageShell>);
|
||||
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
|
||||
setWidth(1600);
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("2.25rem");
|
||||
});
|
||||
|
||||
it("scales the rem threshold by the live root font-size, not a hardcoded 16", () => {
|
||||
root.style.fontSize = "17px"; // as the --font-scale 1.06 default renders it
|
||||
setWidth(1650); // roomy at 16px/rem (>1600) but NOT at 17px/rem (needs 100rem*17 = 1700)
|
||||
render(<PageShell maxWidth="64rem">content</PageShell>);
|
||||
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
|
||||
});
|
||||
|
||||
it("recomputes at runtime when --font-scale changes the root font, without looping", async () => {
|
||||
setWidth(1650); // roomy at 16px/rem (1650 >= 1600) → overlay
|
||||
render(<PageShell maxWidth="64rem">content</PageShell>);
|
||||
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("2.25rem");
|
||||
// The text-size slider changes the root font-size with NO resize event — the MutationObserver
|
||||
// must recompute. At 17px/rem the threshold is 100rem*17 = 1700 > 1650, so the rail goes in-flow.
|
||||
// (If the observer's self-write guard were broken this would loop forever and time out.)
|
||||
root.style.fontSize = "17px";
|
||||
await new Promise((r) => setTimeout(r, 0)); // flush the MutationObserver microtask
|
||||
expect(root.style.getPropertyValue("--rail-pinned-w")).toBe("16.75rem");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
isValidElement,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
@@ -53,6 +52,7 @@ export default function PageShell({
|
||||
children,
|
||||
scrollKey,
|
||||
fadeTop = true,
|
||||
maxWidth,
|
||||
}: {
|
||||
header?: ReactNode;
|
||||
banners?: ReactNode;
|
||||
@@ -61,43 +61,96 @@ export default function PageShell({
|
||||
scrollKey?: string;
|
||||
// Off when this page's scroll region starts with a sticky header — see PageScroller.
|
||||
fadeTop?: boolean;
|
||||
// The content column's max width (e.g. "1600px"); the header + toolbar centre on the same width via
|
||||
// --content-mw. Omit for a full-width page (feed). See index.css --content-mw.
|
||||
maxWidth?: string;
|
||||
}) {
|
||||
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
|
||||
// Publish this page's content max-width so the floating header (and toolbar band) can centre on the
|
||||
// same width the content column uses. Global (one page shell renders at a time). Also decide whether
|
||||
// a PINNED filter rail should stay OVERLAY (keep the toolbar's fixed leading gutter, don't push the
|
||||
// centred content) or go in-flow: overlay only when the centred content leaves a wide-enough left
|
||||
// margin for the rail to sit in — else pinning would overlap the content, so it pushes (in-flow).
|
||||
useLayoutEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--content-mw", maxWidth ?? "none");
|
||||
// Room the pinned rail needs: its own width (~16rem) on each side of the centred content, plus the
|
||||
// nav rail (~4rem) = ~36rem. Below that, or on a full-width page (no maxWidth), pin in-flow (w-67).
|
||||
let lastPxPerRem = 0;
|
||||
const apply = () => {
|
||||
// Convert the rem maxWidth (and the 36rem slack) against the LIVE root font-size, not a hardcoded
|
||||
// 16: index.css scales the root by --font-scale (default 1.06 ≈ 17px, slider 0.9–1.3), and the page's
|
||||
// own content wrapper is a rem `max-w-*`, so a fixed 16 mis-sizes the threshold at every text size.
|
||||
const pxPerRem = parseFloat(getComputedStyle(root).fontSize) || 16;
|
||||
lastPxPerRem = pxPerRem;
|
||||
const mwPx = maxWidth
|
||||
? maxWidth.endsWith("rem")
|
||||
? parseFloat(maxWidth) * pxPerRem
|
||||
: parseFloat(maxWidth)
|
||||
: null;
|
||||
const roomy = mwPx != null && window.innerWidth >= mwPx + 36 * pxPerRem;
|
||||
root.style.setProperty("--rail-pinned-w", roomy ? "2.25rem" : "16.75rem");
|
||||
};
|
||||
apply();
|
||||
window.addEventListener("resize", apply);
|
||||
// The text-size slider changes --font-scale (hence the root font-size) WITHOUT firing resize, so the
|
||||
// rem→px threshold would go stale. Re-run when the root font actually changes; the pxPerRem guard keeps
|
||||
// our own --rail-pinned-w write (a rem string, no font change) from re-triggering the observer.
|
||||
const fontObserver = new MutationObserver(() => {
|
||||
if ((parseFloat(getComputedStyle(root).fontSize) || 16) !== lastPxPerRem) apply();
|
||||
});
|
||||
fontObserver.observe(root, { attributes: true, attributeFilter: ["style"] });
|
||||
return () => {
|
||||
window.removeEventListener("resize", apply);
|
||||
fontObserver.disconnect();
|
||||
root.style.removeProperty("--content-mw");
|
||||
root.style.removeProperty("--rail-pinned-w");
|
||||
};
|
||||
}, [maxWidth]);
|
||||
// The floating header pads the content below it by `--hdr-h`. That must equal the header's REAL
|
||||
// height, which grows as the flex-wrap header wraps to 2–3 rows on a narrow window — a fixed pad
|
||||
// would let the content toolbar slide up under it (E5 S7). Measure the header (a ref forwarded onto
|
||||
// its root box) and write its height straight onto the content wrapper that consumes `--hdr-h`, so
|
||||
// the two live together with no assumption about the header's DOM parent.
|
||||
const headerRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
// would let the content toolbar slide up under it (E5 S7). Measure the header and write its height
|
||||
// straight onto the content wrapper that consumes `--hdr-h`.
|
||||
//
|
||||
// Both nodes are held in STATE (callback refs), NOT plain refs behind a one-shot `useLayoutEffect([])`:
|
||||
// PageShell is reused — never remounted — across feed↔channel navigation (same component position in
|
||||
// App.tsx), so the header DOM node is swapped out from under a stable PageShell. A one-shot effect keeps
|
||||
// observing the FIRST header node; when that node detaches on channel-open, its ResizeObserver fires with
|
||||
// a 0 box and rewrites --hdr-h to `0+0+4`=4px on the (still-mounted) content div. On Back the pad class
|
||||
// returns with that stale 4px → the content toolbar slides up under the header, stuck until reload. Keying
|
||||
// the measurement to the live node re-runs it every time the header mounts/unmounts, so it stays correct.
|
||||
const [headerEl, setHeaderEl] = useState<HTMLDivElement | null>(null);
|
||||
const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const h = headerRef.current;
|
||||
const c = contentRef.current;
|
||||
if (!h || !c) return;
|
||||
// `+ 8` = the header's own gap-y-2 row gap (0.5rem), reused as the gap below it.
|
||||
const apply = () => c.style.setProperty("--hdr-h", `${h.offsetTop + h.offsetHeight + 8}px`);
|
||||
if (!headerEl || !contentEl) {
|
||||
// No header (channel page) → drop any stale pad so a leftover value can't linger.
|
||||
contentEl?.style.removeProperty("--hdr-h");
|
||||
return;
|
||||
}
|
||||
// `+ 4` = a tight gap below the floating header before the content toolbar starts.
|
||||
const apply = () =>
|
||||
contentEl.style.setProperty("--hdr-h", `${headerEl.offsetTop + headerEl.offsetHeight + 4}px`);
|
||||
apply();
|
||||
const ro = new ResizeObserver(apply);
|
||||
ro.observe(h);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
c.style.removeProperty("--hdr-h");
|
||||
};
|
||||
}, []);
|
||||
ro.observe(headerEl);
|
||||
return () => ro.disconnect();
|
||||
}, [headerEl, contentEl]);
|
||||
const measuredHeader = isValidElement(header)
|
||||
? cloneElement(header as ReactElement<{ ref?: Ref<HTMLDivElement> }>, {
|
||||
ref: headerRef,
|
||||
ref: setHeaderEl,
|
||||
})
|
||||
: header;
|
||||
return (
|
||||
<PageToolbarSlot.Provider value={toolbarSlot}>
|
||||
{measuredHeader}
|
||||
<div
|
||||
ref={contentRef}
|
||||
ref={setContentEl}
|
||||
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
|
||||
>
|
||||
{banners}
|
||||
<div ref={setToolbarSlot} className="shrink-0" />
|
||||
{/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the
|
||||
scrollbar-gutter's width past the scrolled cards on the right. Pad it back to card width. */}
|
||||
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
|
||||
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
|
||||
{children}
|
||||
</PageScroller>
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
import { type ReactNode, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Eye, EyeOff, GripVertical } from "lucide-react";
|
||||
import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
// One collapsible "island" card inside a SidePanel: a titled glass-card with per-group collapse,
|
||||
// and — in the panel's edit mode — a drag handle (reorder) and a hide toggle. Generalized from
|
||||
// the feed sidebar's WidgetCard so all three panels share the same island look and behavior.
|
||||
// One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag
|
||||
// handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three
|
||||
// panels share the same island look and behavior. `collapsible` groups (the default) get a per-group
|
||||
// collapse chevron; non-collapsible groups render as a plain always-open label + control row, and
|
||||
// their title can instead act as a trigger via `onTitleClick` (e.g. feed "Your tags" -> tag manager).
|
||||
export default function PanelGroup({
|
||||
id,
|
||||
title,
|
||||
editing,
|
||||
collapsible = true,
|
||||
collapsed,
|
||||
hidden,
|
||||
onToggleCollapse,
|
||||
onToggleHidden,
|
||||
onTitleClick,
|
||||
titleActionLabel,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
editing: boolean;
|
||||
collapsible?: boolean;
|
||||
collapsed: boolean;
|
||||
hidden: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onToggleHidden: () => void;
|
||||
onTitleClick?: () => void;
|
||||
titleActionLabel?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -32,7 +40,13 @@ export default function PanelGroup({
|
||||
disabled: !editing,
|
||||
});
|
||||
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };
|
||||
const showBody = !editing && !collapsed;
|
||||
// Non-collapsible groups are always open (outside edit mode); collapsible ones honour `collapsed`.
|
||||
const showBody = !editing && (!collapsible || !collapsed);
|
||||
// A non-collapsible group's title acts as a trigger when one is provided.
|
||||
const titleIsTrigger = !editing && !collapsible && !!onTitleClick;
|
||||
|
||||
const titleCls =
|
||||
"flex-1 min-w-0 flex items-center gap-1 text-left select-none text-sm uppercase tracking-wide";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -53,13 +67,27 @@ export default function PanelGroup({
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={editing ? undefined : onToggleCollapse}
|
||||
disabled={editing}
|
||||
className="flex-1 min-w-0 flex items-center text-left select-none text-xs uppercase tracking-wide text-muted enabled:hover:text-fg transition"
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
</button>
|
||||
{collapsible && !editing ? (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className={`${titleCls} text-muted hover:text-fg transition`}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
</button>
|
||||
) : titleIsTrigger ? (
|
||||
<button
|
||||
onClick={onTitleClick}
|
||||
title={titleActionLabel}
|
||||
className={`${titleCls} group text-muted hover:text-accent transition`}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" />
|
||||
</button>
|
||||
) : (
|
||||
<span className={`${titleCls} text-muted`}>
|
||||
<span className="truncate">{title}</span>
|
||||
</span>
|
||||
)}
|
||||
{editing ? (
|
||||
<button
|
||||
onClick={onToggleHidden}
|
||||
@@ -69,15 +97,17 @@ export default function PanelGroup({
|
||||
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
||||
className="text-muted hover:text-fg"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
collapsible && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
||||
className="text-muted hover:text-fg"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{showBody && <div className="px-3 pb-3">{children}</div>}
|
||||
|
||||
@@ -17,7 +17,16 @@ import {
|
||||
import type { PanelLayout } from "../lib/panelLayout";
|
||||
import PanelGroup from "./PanelGroup";
|
||||
|
||||
export type PanelItem = { id: string; title: string; body: ReactNode };
|
||||
export type PanelItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: ReactNode;
|
||||
/** When set (and the panel is non-collapsible), the group's title becomes a button firing this —
|
||||
* e.g. the feed's "Your tags" opens the tag manager. */
|
||||
onTitleClick?: () => void;
|
||||
/** Accessible name / tooltip for the title action (what onTitleClick does). */
|
||||
titleActionLabel?: string;
|
||||
};
|
||||
|
||||
// Renders a panel's islands in the user's saved order, with drag-to-reorder + hide + per-group
|
||||
// collapse driven by `layout` (see lib/panelLayout). `items` holds only the currently AVAILABLE
|
||||
@@ -29,11 +38,15 @@ export default function PanelGroups({
|
||||
layout,
|
||||
setLayout,
|
||||
editing,
|
||||
collapsible = true,
|
||||
}: {
|
||||
items: PanelItem[];
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
editing: boolean;
|
||||
/** When false, groups render as plain (always-open) label + control rows — no collapse chevron.
|
||||
* Reorder + hide (in edit mode) stay. Default true (the collapsible-island look). */
|
||||
collapsible?: boolean;
|
||||
}) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
@@ -76,10 +89,13 @@ export default function PanelGroups({
|
||||
id={id}
|
||||
title={byId.get(id)!.title}
|
||||
editing={editing}
|
||||
collapsible={collapsible}
|
||||
collapsed={!!layout.collapsed[id]}
|
||||
hidden={!!layout.hidden[id]}
|
||||
onToggleCollapse={() => toggleCollapse(id)}
|
||||
onToggleHidden={() => toggleHidden(id)}
|
||||
onTitleClick={byId.get(id)!.onTitleClick}
|
||||
titleActionLabel={byId.get(id)!.titleActionLabel}
|
||||
>
|
||||
{byId.get(id)!.body}
|
||||
</PanelGroup>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BookmarkPlus, GripVertical, Play, Star, Trash2 } from "lucide-react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { coercePlexFilters, type FeedFilters, type SavedView } from "../lib/api";
|
||||
import Modal from "./Modal";
|
||||
import { useSavedViews, asFilters } from "./SavedViewsProvider";
|
||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||
|
||||
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
|
||||
// reorder) plus a "save the current filters" input; the right column shows the selected view's name,
|
||||
// what filters it captures, its default flag, apply, and delete. Opened from the filter panel's title
|
||||
// or the saved-views popover; all mutations flow through SavedViewsProvider.
|
||||
function ViewRow({
|
||||
view,
|
||||
selected,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
view: SavedView;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: view.id,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className={`flex items-center gap-1.5 rounded-lg pr-1.5 ${isDragging ? "opacity-60" : ""} ${
|
||||
selected ? "bg-accent/15" : "hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={t("views.reorder")}
|
||||
className="cursor-grab p-1.5 text-muted hover:text-fg"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className={`flex-1 min-w-0 truncate py-1.5 text-left text-sm ${
|
||||
selected ? "text-accent" : "text-fg"
|
||||
}`}
|
||||
>
|
||||
{view.name}
|
||||
</button>
|
||||
{view.is_default && <Star className="h-3 w-3 shrink-0 fill-current text-accent" />}
|
||||
{active && <span className="mr-1 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PersonalViewsModal() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
source,
|
||||
views,
|
||||
activeViewId,
|
||||
apply,
|
||||
create,
|
||||
rename,
|
||||
toggleDefault,
|
||||
remove,
|
||||
reorder,
|
||||
closeModal,
|
||||
} = useSavedViews();
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||
|
||||
const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null);
|
||||
const selected = views.find((v) => v.id === selectedId) ?? null;
|
||||
const [draftName, setDraftName] = useState("");
|
||||
|
||||
// The selected view's captured filters, as the same human labels its module's chip row shows. The
|
||||
// feed hook runs unconditionally (fed empty filters off-feed); Plex uses the pure builder (no-op
|
||||
// removes — this is display-only). A plex view captures its rail filters, not the watch-state.
|
||||
const noop = () => {};
|
||||
const feedCaptured = useActiveFilters(
|
||||
source === "feed" && selected
|
||||
? asFilters(selected.filters as unknown as FeedFilters)
|
||||
: ({ tags: [], channelIds: [] } as never),
|
||||
noop,
|
||||
);
|
||||
const captured =
|
||||
source === "plex" && selected
|
||||
? buildPlexActiveFilters(coercePlexFilters(selected.filters), "all", noop, noop, t)
|
||||
: feedCaptured;
|
||||
|
||||
async function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e;
|
||||
if (!over || active.id === over.id) return;
|
||||
const ids = views.map((v) => v.id);
|
||||
const next = arrayMove(ids, ids.indexOf(active.id as number), ids.indexOf(over.id as number));
|
||||
await reorder(next);
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
const name = draftName.trim();
|
||||
if (!name) return;
|
||||
if (await create(name)) setDraftName("");
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={t("views.manageTitle")} onClose={closeModal} maxWidth="max-w-2xl">
|
||||
<div className="grid min-h-[20rem] grid-cols-[minmax(0,14rem)_1fr] gap-4">
|
||||
{/* Left: the list + create */}
|
||||
<div className="flex flex-col border-r border-border/60 pr-4">
|
||||
<div className="flex-1 space-y-1 overflow-y-auto">
|
||||
{views.length === 0 && (
|
||||
<p className="px-1 py-2 text-2xs leading-snug text-muted">{t("views.empty")}</p>
|
||||
)}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext
|
||||
items={views.map((v) => v.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{views.map((v) => (
|
||||
<ViewRow
|
||||
key={v.id}
|
||||
view={v}
|
||||
selected={v.id === selectedId}
|
||||
active={v.id === activeViewId}
|
||||
onSelect={() => setSelectedId(v.id)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 pt-2">
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onCreate()}
|
||||
placeholder={t("views.namePlaceholder")}
|
||||
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
disabled={!draftName.trim()}
|
||||
title={t("views.save")}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-lg btn-primary px-2.5 py-1.5 text-sm font-semibold disabled:opacity-40"
|
||||
>
|
||||
<BookmarkPlus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: the selected view's detail */}
|
||||
{selected ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<input
|
||||
key={selected.id}
|
||||
defaultValue={selected.name}
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value.trim();
|
||||
if (next && next !== selected.name) void rename(selected.id, next);
|
||||
}}
|
||||
className="rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted">
|
||||
{t("views.capturedFilters")}
|
||||
</div>
|
||||
{captured.length === 0 ? (
|
||||
<p className="text-xs text-muted">{t("views.capturedNone")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{captured.map((f) => (
|
||||
<span
|
||||
key={f.key}
|
||||
className="rounded-full bg-accent/10 px-2 py-0.5 text-2xs text-accent"
|
||||
>
|
||||
{f.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-auto flex items-center gap-2 border-t border-border/60 pt-3">
|
||||
<button
|
||||
onClick={() => apply(selected)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg btn-primary px-3 py-1.5 text-sm font-semibold"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
{t("views.apply")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void toggleDefault(selected)}
|
||||
title={t("views.default")}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition ${
|
||||
selected.is_default
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${selected.is_default ? "fill-current" : ""}`} />
|
||||
{t("views.default")}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const next = views.find((v) => v.id !== selected.id);
|
||||
// Only move the selection if the delete actually happened (remove awaits a confirm
|
||||
// and resolves false on cancel) — otherwise cancelling would still switch the pane.
|
||||
if (await remove(selected)) setSelectedId(next?.id ?? null);
|
||||
}}
|
||||
title={t("views.delete")}
|
||||
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-danger transition hover:border-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("views.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center text-sm text-muted">
|
||||
{t("views.selectPrompt")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -571,7 +571,7 @@ export default function Playlists() {
|
||||
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
|
||||
fixed band, so only the video list scrolls. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div className="px-4 pt-2 pb-2 max-w-5xl mx-auto">
|
||||
<div className="flex gap-3 items-start mb-4">
|
||||
<div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0">
|
||||
{items[0]?.thumbnail_url && (
|
||||
@@ -757,7 +757,7 @@ export default function Playlists() {
|
||||
accessibility={{ screenReaderInstructions, announcements }}
|
||||
>
|
||||
{/* Not clipped, so the header's filter popovers can overflow the table box. */}
|
||||
<div className="max-w-5xl">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* table-fixed, not auto: every column but the title has a declared width, so the
|
||||
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
|
||||
min-content, and a nowrap title cell's min-content is the entire title. */}
|
||||
|
||||
@@ -25,19 +25,24 @@ import { useHistorySubview } from "../lib/history";
|
||||
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import SortControl from "./SortControl";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import FilterSelect from "./FilterSelect";
|
||||
import PlexDiscover from "./PlexDiscover";
|
||||
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import SelectedViewChip from "./SelectedViewChip";
|
||||
import ItemCount from "./ItemCount";
|
||||
import { StateMessage } from "./QueryState";
|
||||
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { usePublishPlexView } from "./PlexViewBridge";
|
||||
import { EpisodeCard, PlexPosterCard } from "./plexCards";
|
||||
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
|
||||
const PlexCollectionView = lazy(() => import("./PlexCollectionView"));
|
||||
|
||||
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
||||
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
||||
@@ -47,6 +52,10 @@ const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
|
||||
|
||||
const PAGE = 40;
|
||||
|
||||
// Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am
|
||||
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
|
||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||
|
||||
export default function PlexBrowse() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -54,7 +63,7 @@ export default function PlexBrowse() {
|
||||
// search term (q) comes from the shared header box; the rail hands off a playlist to open via the
|
||||
// shared `playlistOpen` slot, which we consume into a history subview then clear.
|
||||
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
|
||||
const { setScope, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
|
||||
const { setScope, setShow, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
|
||||
const dq = useDebounced(q.trim(), 350);
|
||||
// Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so
|
||||
// a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch.
|
||||
@@ -76,6 +85,16 @@ export default function PlexBrowse() {
|
||||
// or an added filter is a browse intent → flip to the grid (below); an explicit toggle returns.
|
||||
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
|
||||
|
||||
// Publish the top-level view (discover vs grid) + opener to the header's Discover/Browse toggle via
|
||||
// the bridge; clear it on unmount so the toggle disappears when you leave the plex page. Keyed on the
|
||||
// view KIND (open is behaviourally stable — it takes the next view as an arg, closing over nothing).
|
||||
const publishView = usePublishPlexView();
|
||||
useLayoutEffect(() => {
|
||||
publishView({ kind: sub.view.kind, open: sub.open });
|
||||
return () => publishView(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sub.view.kind]);
|
||||
|
||||
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
||||
// clear the signal so it doesn't re-open.
|
||||
useEffect(() => {
|
||||
@@ -99,14 +118,18 @@ export default function PlexBrowse() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [jumpTo]);
|
||||
|
||||
// Discover → grid on a browse intent: when a header search or a filter FIRST becomes active while on
|
||||
// the Discover landing, switch to the flat grid (which consumes q/filters). Only on the off→on edge,
|
||||
// so a pre-existing (persisted) filter doesn't yank a returning user off Discover on mount, and a
|
||||
// Back to Discover with the search still in the box doesn't immediately re-flip.
|
||||
const browseIntent = !!dq || plexFilterCount(filters) > 0;
|
||||
// Discover → grid on a SEARCH: a header search has no rail on Discover, so entering one FIRST while on
|
||||
// the Discover landing switches to the flat grid (which consumes q). Only on the off→on edge, so a Back
|
||||
// to Discover with the term still in the box doesn't immediately re-flip. Filters are NOT a flip
|
||||
// trigger anymore — Discover honours the active filters itself (its rails narrow like the grid), so
|
||||
// adding a filter leaves you on Discover with narrowed rails instead of yanking you to the grid.
|
||||
const browseIntent = !!dq;
|
||||
const prevIntent = useRef(browseIntent);
|
||||
useEffect(() => {
|
||||
if (sub.view.kind === "discover" && browseIntent && !prevIntent.current) {
|
||||
// A search targets titles, so jump to the title grid from any non-title landing (Discover or the
|
||||
// playlists/collections browse surfaces) when a search first appears.
|
||||
const onListing = ["discover", "playlists", "collections"].includes(sub.view.kind);
|
||||
if (onListing && browseIntent && !prevIntent.current) {
|
||||
sub.open({ kind: "grid" });
|
||||
}
|
||||
prevIntent.current = browseIntent;
|
||||
@@ -141,7 +164,19 @@ export default function PlexBrowse() {
|
||||
const tag = (el?.tagName || "").toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
|
||||
return;
|
||||
if (["grid", "show", "season", "info", "person", "playlist"].includes(sub.view.kind)) {
|
||||
if (
|
||||
[
|
||||
"grid",
|
||||
"playlists",
|
||||
"collections",
|
||||
"show",
|
||||
"season",
|
||||
"info",
|
||||
"person",
|
||||
"playlist",
|
||||
"collection",
|
||||
].includes(sub.view.kind)
|
||||
) {
|
||||
e.preventDefault();
|
||||
sub.back();
|
||||
}
|
||||
@@ -266,10 +301,15 @@ export default function PlexBrowse() {
|
||||
// (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed).
|
||||
function applyFilter(patch: Partial<PlexFilters>) {
|
||||
const merged = { ...filters } as unknown as Record<string, unknown>;
|
||||
const isPlainObject = (x: unknown): x is Record<string, unknown> =>
|
||||
typeof x === "object" && x !== null && !Array.isArray(x);
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
const cur = merged[k];
|
||||
if (Array.isArray(v) && Array.isArray(cur)) {
|
||||
merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
|
||||
} else if (isPlainObject(v) && isPlainObject(cur)) {
|
||||
// Merge label maps (collectionTitles / playlistTitles) so adding one keeps the others' titles.
|
||||
merged[k] = { ...cur, ...v };
|
||||
} else {
|
||||
merged[k] = v;
|
||||
}
|
||||
@@ -303,6 +343,20 @@ export default function PlexBrowse() {
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "collection") {
|
||||
const cid = sub.view.id;
|
||||
return (
|
||||
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
||||
<PlexCollectionView
|
||||
collectionId={cid}
|
||||
onBack={sub.back}
|
||||
onPlay={(rk, queue) => sub.open({ kind: "player", id: rk, queue })}
|
||||
onOpenCard={onCard}
|
||||
onInfoCard={onInfo}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "info") {
|
||||
const infoId = sub.view.id;
|
||||
return (
|
||||
@@ -354,24 +408,23 @@ export default function PlexBrowse() {
|
||||
);
|
||||
}
|
||||
|
||||
// Discover ⟷ Browse switch, shown on both surfaces so either can return to the other.
|
||||
const viewToggle = (
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("plex.view.label")}
|
||||
options={[
|
||||
{ value: "discover", label: t("plex.discover.tab") },
|
||||
{ value: "grid", label: t("plex.view.browse") },
|
||||
]}
|
||||
value={sub.view.kind === "discover" ? "discover" : "grid"}
|
||||
onChange={(v) => sub.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
|
||||
// Sort is tightly bound to the grid below, so on the grid view it lives pinned to the right of the
|
||||
// applied-filter chip row (directly above the cards), not on its own toolbar line — the same idiom as
|
||||
// the Feed's `sortView`. The Discover/Browse toggle moved up to the header (next to the scope pill).
|
||||
const sortView = (
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={sortState}
|
||||
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
|
||||
label={t("plex.filter.sort")}
|
||||
ascLabel={t("plex.filter.dir.asc")}
|
||||
descLabel={t("plex.filter.dir.desc")}
|
||||
/>
|
||||
);
|
||||
|
||||
if (sub.view.kind === "discover") {
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
<div className="mb-4 flex items-center">{viewToggle}</div>
|
||||
<PlexDiscover
|
||||
scope={scope}
|
||||
onOpenCard={onCard}
|
||||
@@ -382,38 +435,57 @@ export default function PlexBrowse() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// The header-pill "Playlist" / "Collection" browse surfaces: a grid of tiles into each area. A
|
||||
// playlist tile opens that playlist; a collection tile filters the title grid by that collection.
|
||||
if (sub.view.kind === "playlists") {
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
<PlexPlaylistsBrowse onOpen={(id) => sub.open({ kind: "playlist", id })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "collections") {
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
<PlexCollectionsBrowse onPick={(id) => sub.open({ kind: "collection", id })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
|
||||
while only the poster grid scrolls. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto flex items-center gap-2">
|
||||
{viewToggle}
|
||||
<p className="text-xs text-muted">
|
||||
{browseQ.isLoading
|
||||
? " "
|
||||
: dq
|
||||
? t("plex.searchCount", { count: total })
|
||||
: t("plex.count", { count: total })}
|
||||
</p>
|
||||
{/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="hidden sm:inline text-xs text-muted">{t("plex.filter.sort")}</span>
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={sortState}
|
||||
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
|
||||
label={t("plex.filter.sort")}
|
||||
ascLabel={t("plex.filter.dir.asc")}
|
||||
descLabel={t("plex.filter.dir.desc")}
|
||||
<div className="px-4 pt-2 pb-1 max-w-[1600px] mx-auto">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ItemCount count={browseQ.isLoading ? undefined : total} />
|
||||
{/* Watch state — a top-level toolbar pill next to the count (moved out of the rail),
|
||||
the same idiom as the feed's Watch-state FilterSelect. */}
|
||||
<FilterSelect
|
||||
label={t("plex.filter.show")}
|
||||
testId="plex-show"
|
||||
options={SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
|
||||
selected={[show]}
|
||||
onChange={(next) => setShow(next[0] ?? "all")}
|
||||
/>
|
||||
</div>
|
||||
{/* The controls row sits directly above the grid: the applied-filter chips flow from the
|
||||
left (overflowing to a "+N"/count pill so they never crowd the controls), while Sort +
|
||||
the Discover/Browse toggle stay pinned to the right — the same layout as the Feed. */}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{/* The active personal view (if any) leads the applied-filter row, like the feed. */}
|
||||
<SelectedViewChip />
|
||||
<ActiveFilterChips
|
||||
filters={plexChips}
|
||||
onClearAll={clearAllFilters}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
<ActiveFilterChips filters={plexChips} onClearAll={clearAllFilters} />
|
||||
|
||||
{personCards.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
{personCards.map((p) => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import Modal from "./Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
@@ -100,6 +101,9 @@ export default function PlexCollectionEditor({
|
||||
setNewName("");
|
||||
setMembers((prev) => new Set(prev).add(col.id));
|
||||
refresh();
|
||||
} catch (e) {
|
||||
// Surfaces the 409 when the name duplicates an existing collection (Plex conflates same names).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
|
||||
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
|
||||
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
|
||||
// admin-enabled collection — rename / delete / add / remove, all write-through to Plex (the mutation
|
||||
// endpoints round-trip to Plex, so each edit awaits and re-fetches; no optimism). Read-only ones (smart
|
||||
// / imdb / tmdb / … auto-lists) are marked with a lock.
|
||||
function shuffled(a: string[]): string[] {
|
||||
const r = [...a];
|
||||
for (let i = r.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const tmp = r[i]!;
|
||||
r[i] = r[j]!;
|
||||
r[j] = tmp;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
export default function PlexCollectionView({
|
||||
collectionId,
|
||||
onBack,
|
||||
onPlay,
|
||||
onOpenCard,
|
||||
onInfoCard,
|
||||
}: {
|
||||
collectionId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (rk: string, queue: string[]) => void;
|
||||
onOpenCard: (card: PlexCard) => void;
|
||||
onInfoCard: (card: PlexCard) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexCollections("detail", collectionId),
|
||||
queryFn: () => api.plexCollection(collectionId),
|
||||
});
|
||||
const d = q.data;
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexCollections() });
|
||||
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
|
||||
};
|
||||
|
||||
const items = d?.items ?? [];
|
||||
// The Play queue is the playable-leaf members (movies/episodes) in order — a show can't queue directly.
|
||||
const playable = items.filter((c) => c.type !== "show").map((c) => c.id);
|
||||
const canEdit = isAdmin && !!d?.can_edit;
|
||||
// A plain (non-smart, non-external) collection can be admin-enabled for editing. Shares
|
||||
// isIntrinsicReadOnly with the browse tile so the lock/edit affordance can't drift between them.
|
||||
const canEnableEdit = isAdmin && !d?.can_edit && !d?.editable && !!d && !isIntrinsicReadOnly(d);
|
||||
const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list
|
||||
|
||||
const toggleWatched = (card: PlexCard) =>
|
||||
toggleWatchState(card, t, {
|
||||
optimistic: (next) => {
|
||||
qc.setQueryData<PlexCollectionDetail>(qk.plexCollections("detail", collectionId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
items: old.items.map((c) => (c.id === card.id ? { ...c, status: next } : c)),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
},
|
||||
resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
|
||||
});
|
||||
|
||||
async function doRename() {
|
||||
const title = name.trim();
|
||||
if (!title || !d || title === d.title) {
|
||||
setRenaming(false);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexRenameCollection(collectionId, title);
|
||||
invalidate();
|
||||
setRenaming(false);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!d) return;
|
||||
if (!(await confirm({ message: t("plex.collection.deleteConfirm", { title: d.title }) })))
|
||||
return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexDeleteCollection(collectionId);
|
||||
invalidate();
|
||||
onBack();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemove(card: PlexCard) {
|
||||
// Write-through to the shared Plex library — confirm first, matching the delete-collection guard.
|
||||
if (!(await confirm({ message: t("plex.collection.removeConfirm", { title: card.title }) })))
|
||||
return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexCollectionRemoveItem(collectionId, card.id);
|
||||
invalidate();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableEdit() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.plexSetCollectionEditable(collectionId, true);
|
||||
invalidate();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const meta = [
|
||||
d?.year_min != null &&
|
||||
(d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`),
|
||||
d?.content_rating,
|
||||
d && t("plex.browse.items", { count: d.child_count ?? items.length }),
|
||||
formatRuntime(d?.total_runtime_s),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<div className="glass-media mx-auto max-w-[1200px] p-4">
|
||||
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
|
||||
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<>
|
||||
<DetailHero
|
||||
thumb={d.thumb ?? `/api/plex/image/${d.id}`}
|
||||
posterTitle={t("plex.play")}
|
||||
posterDisabled={playable.length === 0}
|
||||
showPlayOverlay={playable.length > 0}
|
||||
onPosterClick={() => playable.length > 0 && onPlay(playable[0]!, playable)}
|
||||
customizeMenu={null}
|
||||
>
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={doRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") doRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
aria-label={t("plex.collection.rename")}
|
||||
className="glass-card w-full rounded-lg px-2 py-1 text-2xl font-semibold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold leading-tight">{d.title}</h1>
|
||||
{readOnly && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full bg-surface px-2 py-0.5 text-2xs text-muted"
|
||||
title={t("plex.collection.readOnly")}
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
{t("plex.collection.readOnly")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-sm text-muted">
|
||||
{meta.map((m, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && "· "}
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.summary && (
|
||||
<div className="mt-3">
|
||||
<p
|
||||
className={`text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-3"}`}
|
||||
>
|
||||
{d.summary}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-1 text-xs font-medium text-accent hover:underline"
|
||||
>
|
||||
{expanded ? t("plex.collection.less") : t("plex.collection.more")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{playable.length > 0 && (
|
||||
<>
|
||||
<ActionBtn
|
||||
primary
|
||||
onClick={() => onPlay(playable[0]!, playable)}
|
||||
icon={Play}
|
||||
label={t("plex.collection.playAll")}
|
||||
/>
|
||||
<ActionBtn
|
||||
onClick={() => {
|
||||
const order = shuffled(playable);
|
||||
onPlay(order[0]!, order);
|
||||
}}
|
||||
icon={Shuffle}
|
||||
label={t("plex.collection.shuffle")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{canEdit && (
|
||||
<>
|
||||
<ActionBtn
|
||||
onClick={() => {
|
||||
setName(d.title);
|
||||
setRenaming(true);
|
||||
}}
|
||||
icon={Pencil}
|
||||
label={t("plex.collection.rename")}
|
||||
/>
|
||||
<ActionBtn
|
||||
onClick={() => setAdding((v) => !v)}
|
||||
icon={Plus}
|
||||
label={t("plex.collection.addItem")}
|
||||
/>
|
||||
<ActionBtn
|
||||
onClick={doDelete}
|
||||
icon={Trash2}
|
||||
label={t("plex.collection.delete")}
|
||||
disabled={busy}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{canEnableEdit && (
|
||||
<ActionBtn
|
||||
onClick={enableEdit}
|
||||
icon={Check}
|
||||
label={t("plex.collection.enableEdit")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DetailHero>
|
||||
|
||||
{adding && canEdit && (
|
||||
<AddItemPanel
|
||||
collectionId={collectionId}
|
||||
scope={items.some((c) => c.type === "show") ? "show" : "movie"}
|
||||
existing={new Set(items.map((c) => c.id))}
|
||||
onAdded={invalidate}
|
||||
onClose={() => setAdding(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.empty")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
||||
{items.map((c) => (
|
||||
<div key={c.id} className="relative">
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
/>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => doRemove(c)}
|
||||
disabled={busy}
|
||||
title={t("plex.collection.removeItem")}
|
||||
aria-label={t("plex.collection.removeItem")}
|
||||
className="glass-card glass-hover absolute right-1.5 top-1.5 z-base grid h-6 w-6 place-items-center rounded-full text-fg disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A compact search-and-add panel: search the collection's library, click a result to add it (write-
|
||||
// through to Plex). Stays open so several items can be added in a row; already-present items are marked.
|
||||
function AddItemPanel({
|
||||
collectionId,
|
||||
scope,
|
||||
existing,
|
||||
onAdded,
|
||||
onClose,
|
||||
}: {
|
||||
collectionId: string;
|
||||
scope: string;
|
||||
existing: Set<string>;
|
||||
onAdded: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [term, setTerm] = useState("");
|
||||
const debounced = useDebounced(term.trim(), 300);
|
||||
const [added, setAdded] = useState<Set<string>>(new Set());
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexLibrary("collection-add", scope, debounced),
|
||||
queryFn: () => api.plexLibrary({ scope, q: debounced, limit: 12 }),
|
||||
enabled: debounced.length > 0,
|
||||
});
|
||||
const results = q.data?.items ?? [];
|
||||
|
||||
async function add(card: PlexCard) {
|
||||
try {
|
||||
await api.plexCollectionAddItem(collectionId, card.id);
|
||||
setAdded((s) => new Set(s).add(card.id));
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card mb-6 rounded-2xl p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-muted" />
|
||||
<input
|
||||
autoFocus
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
placeholder={t("plex.collection.addSearch")}
|
||||
aria-label={t("plex.collection.addSearch")}
|
||||
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={t("common.close")}
|
||||
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{debounced.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : q.isError ? (
|
||||
<p className="text-muted text-sm">{t("common.loadError")}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{results.map((c) => {
|
||||
const inColl = existing.has(c.id) || added.has(c.id);
|
||||
return (
|
||||
<li key={c.id} className="flex items-center gap-3">
|
||||
<img
|
||||
src={c.thumb}
|
||||
alt=""
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{c.title}
|
||||
{c.year != null && <span className="text-muted"> · {c.year}</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => add(c)}
|
||||
disabled={inColl}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,17 @@ vi.mock("../lib/api", () => ({
|
||||
plexSurprise: (...a: unknown[]) => plexSurprise(...a),
|
||||
plexSetState: (...a: unknown[]) => plexSetState(...a),
|
||||
},
|
||||
// PlexDiscover now reads the active filters via usePlex (PlexProvider); its context default needs
|
||||
// EMPTY_PLEX_FILTERS at import time. No provider is rendered, so the default (empty) filters apply.
|
||||
EMPTY_PLEX_FILTERS: {
|
||||
genres: [],
|
||||
contentRatings: [],
|
||||
directors: [],
|
||||
actors: [],
|
||||
studios: [],
|
||||
keywords: [],
|
||||
collections: [],
|
||||
},
|
||||
}));
|
||||
|
||||
import PlexDiscover from "./PlexDiscover";
|
||||
@@ -66,8 +77,9 @@ const card = (id: string, title: string, type: "movie" | "show" = "movie") => ({
|
||||
const fullPayload = {
|
||||
scope: "both",
|
||||
continue_watching: [card("cw1", "Resume Me")],
|
||||
recently_watched: [card("rw1", "Watched Me")],
|
||||
recently_added: [card("ra1", "Just Added")],
|
||||
genres: [{ value: "Drama", count: 12 }],
|
||||
genres: [{ value: "Drama", count: 12, art: [] }],
|
||||
because_you_watched: { anchor: { id: "a1", title: "Anchor" }, items: [card("bw1", "Rec One")] },
|
||||
};
|
||||
|
||||
@@ -92,6 +104,7 @@ describe("PlexDiscover", () => {
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("plex.discover.continueWatching");
|
||||
expect(screen.getByText("plex.discover.recentlyWatched")).toBeTruthy();
|
||||
expect(screen.getByText("plex.discover.recentlyAdded")).toBeTruthy();
|
||||
expect(screen.getByText("plex.discover.surprise")).toBeTruthy();
|
||||
expect(screen.getByText("plex.discover.byGenre")).toBeTruthy();
|
||||
@@ -99,10 +112,53 @@ describe("PlexDiscover", () => {
|
||||
expect(screen.getByText("plex.discover.becauseWatched:Anchor")).toBeTruthy();
|
||||
// A card from each rail is present (title rendered in the poster label).
|
||||
expect(screen.getAllByText("Resume Me").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Watched Me").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Just Added").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Random Pick").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Rec One").length).toBeGreaterThan(0);
|
||||
expect(plexDiscover).toHaveBeenCalledWith("both", "en");
|
||||
// Now called with the active filters too (empty default here, no provider mounted).
|
||||
expect(plexDiscover).toHaveBeenCalledWith(
|
||||
"both",
|
||||
"en",
|
||||
expect.objectContaining({ genres: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers the discover + surprise queries keyed by the active lang", async () => {
|
||||
// The real cache-separation guard (the queryKeys factory test only pins that a lang ARG survives the
|
||||
// spread): inspect the actual keys the component registered. A regression dropping lang from either
|
||||
// qk call would collide HU/EN in the cache even while the queryFn still fetches the right language.
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("plex.discover.continueWatching");
|
||||
const keys = qc
|
||||
.getQueryCache()
|
||||
.getAll()
|
||||
.map((q) => q.queryKey);
|
||||
expect(keys).toContainEqual([
|
||||
"plex-discover",
|
||||
"both",
|
||||
"en",
|
||||
expect.objectContaining({ genres: [] }),
|
||||
]);
|
||||
expect(keys).toContainEqual([
|
||||
"plex-surprise",
|
||||
"both",
|
||||
"en",
|
||||
expect.objectContaining({ genres: [] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("fires onGenre with the tile's value", async () => {
|
||||
@@ -162,6 +218,7 @@ describe("PlexDiscover", () => {
|
||||
plexDiscover.mockResolvedValue({
|
||||
scope: "both",
|
||||
continue_watching: [],
|
||||
recently_watched: [],
|
||||
recently_added: [],
|
||||
genres: [],
|
||||
because_you_watched: null,
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../lib/api";
|
||||
import PosterTile from "./PosterTile";
|
||||
import { plexLang } from "../i18n";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { usePlex } from "./PlexProvider";
|
||||
import { Carousel } from "./Carousel";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
@@ -17,14 +19,6 @@ import { LoadingState, StateMessage } from "./QueryState";
|
||||
// info / watched-toggle handlers come from the shell (PlexBrowse) so a rail card behaves exactly like
|
||||
// a grid card; the watched-toggle optimistically flips THIS surface's cache too.
|
||||
|
||||
// A stable, pleasant hue per genre string (no art to collage yet — colored tiles stand in). Hashing
|
||||
// keeps a genre the same color across renders/sessions without storing anything.
|
||||
function genreHue(name: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
|
||||
return h;
|
||||
}
|
||||
|
||||
function CardRail({
|
||||
title,
|
||||
ariaLabel,
|
||||
@@ -89,14 +83,17 @@ export default function PlexDiscover({
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const lang = plexLang();
|
||||
// The active sidebar filters narrow every rail, so Discover matches the Browse grid. They're in the
|
||||
// query keys so a filter change refetches (and the watched-toggle setQueryData below keys the same).
|
||||
const { filters } = usePlex();
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexDiscover(scope, lang),
|
||||
queryFn: () => api.plexDiscover(scope, lang),
|
||||
queryKey: qk.plexDiscover(scope, lang, filters),
|
||||
queryFn: () => api.plexDiscover(scope, lang, filters),
|
||||
});
|
||||
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
|
||||
const surpriseQ = useQuery({
|
||||
queryKey: qk.plexSurprise(scope),
|
||||
queryFn: () => api.plexSurprise(scope),
|
||||
queryKey: qk.plexSurprise(scope, lang, filters),
|
||||
queryFn: () => api.plexSurprise(scope, undefined, filters, lang),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
@@ -107,7 +104,7 @@ export default function PlexDiscover({
|
||||
optimistic: (next) => {
|
||||
const flip = (cards: PlexCard[]) =>
|
||||
cards.map((c) => (c.id === card.id ? { ...c, status: next } : c));
|
||||
qc.setQueryData<PlexDiscoverData>(qk.plexDiscover(scope, lang), (old) =>
|
||||
qc.setQueryData<PlexDiscoverData>(qk.plexDiscover(scope, lang, filters), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
@@ -119,8 +116,9 @@ export default function PlexDiscover({
|
||||
}
|
||||
: old,
|
||||
);
|
||||
qc.setQueryData<{ scope: string; items: PlexCard[] }>(qk.plexSurprise(scope), (old) =>
|
||||
old ? { ...old, items: flip(old.items) } : old,
|
||||
qc.setQueryData<{ scope: string; items: PlexCard[] }>(
|
||||
qk.plexSurprise(scope, lang, filters),
|
||||
(old) => (old ? { ...old, items: flip(old.items) } : old),
|
||||
);
|
||||
},
|
||||
resync: () => {
|
||||
@@ -145,6 +143,7 @@ export default function PlexDiscover({
|
||||
const bw = d.because_you_watched;
|
||||
const nothing =
|
||||
d.continue_watching.length === 0 &&
|
||||
d.recently_watched.length === 0 &&
|
||||
d.recently_added.length === 0 &&
|
||||
d.genres.length === 0 &&
|
||||
surprise.length === 0 &&
|
||||
@@ -167,6 +166,15 @@ export default function PlexDiscover({
|
||||
/>
|
||||
)}
|
||||
|
||||
{d.recently_watched.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.recentlyWatched")}
|
||||
ariaLabel={t("plex.discover.recentlyWatched")}
|
||||
cards={d.recently_watched}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{d.recently_added.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.recentlyAdded")}
|
||||
@@ -201,23 +209,15 @@ export default function PlexDiscover({
|
||||
<section className="mb-5">
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{d.genres.map((g) => {
|
||||
const hue = genreHue(g.value);
|
||||
return (
|
||||
<button
|
||||
key={g.value}
|
||||
type="button"
|
||||
onClick={() => onGenre(g.value)}
|
||||
className="glass-hover relative flex h-20 items-end overflow-hidden rounded-xl p-3 text-left transition"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, hsl(${hue} 55% 32%), hsl(${(hue + 40) % 360} 60% 22%))`,
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-semibold text-white drop-shadow">{g.value}</span>
|
||||
<span className="absolute right-2 top-2 text-2xs text-white/70">{g.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{d.genres.map((g) => (
|
||||
<PosterTile
|
||||
key={g.value}
|
||||
title={g.value}
|
||||
art={g.art}
|
||||
onClick={() => onGenre(g.value)}
|
||||
className="h-24"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -693,7 +693,12 @@ export default function PlexInfo({
|
||||
</h2>
|
||||
{onFilter && (
|
||||
<button
|
||||
onClick={() => onFilter({ collection: col.id, collectionTitle: col.title })}
|
||||
onClick={() =>
|
||||
onFilter({
|
||||
collections: [col.id],
|
||||
collectionTitles: { [col.id]: col.title },
|
||||
})
|
||||
}
|
||||
className="shrink-0 text-xs text-muted hover:text-accent"
|
||||
>
|
||||
{t("plex.info.browseCollection")}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
||||
import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
|
||||
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||
@@ -122,10 +122,11 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
const [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw);
|
||||
const filters = useMemo<PlexFilters>(() => {
|
||||
try {
|
||||
// Drop the legacy `sortDir` — direction now lives in the sort SortState. Left here it would leak
|
||||
// into the facets query key and be re-persisted on every filter change.
|
||||
const { sortDir: _drop, ...rest } = JSON.parse(filtersRaw);
|
||||
return { ...EMPTY_PLEX_FILTERS, ...rest };
|
||||
// coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort
|
||||
// SortState — left here it would leak into the facets key and be re-persisted), and migrates the
|
||||
// legacy single `collection`/`collectionTitle` into `collections`/`collectionTitles`. Shared with
|
||||
// the saved-views provider so the localStorage seed and a DB view blob coerce identically.
|
||||
return coercePlexFilters(JSON.parse(filtersRaw));
|
||||
} catch {
|
||||
return EMPTY_PLEX_FILTERS;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the header library-scope toggle (S3 relocation): it renders the three scopes, marks
|
||||
// the current one, and writes the picked scope back to the provider. i18n is key-passthrough and the
|
||||
// provider is mocked so the test drives scope directly; SegmentedToggle is a plain button group (no
|
||||
// floating-ui), so no observer stubs are needed.
|
||||
// Contract of the header library-scope pill (S3 relocation + Piece 2 browse modes): it renders the
|
||||
// three library scopes PLUS the Playlist/Collection browse-mode entries, marks the active one, writes a
|
||||
// picked scope to the provider, and drives the playlists/collections views through the PlexViewBridge.
|
||||
// i18n is key-passthrough and both the provider and the bridge are mocked; SegmentedToggle is a plain
|
||||
// button group (no floating-ui), so no observer stubs are needed.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
@@ -19,32 +20,70 @@ vi.mock("./PlexProvider", () => ({
|
||||
usePlexActions: () => ({ setScope }),
|
||||
}));
|
||||
|
||||
// The bridge: a controllable current view kind + an open spy.
|
||||
const controls = { kind: "discover" as string, open: vi.fn() };
|
||||
vi.mock("./PlexViewBridge", () => ({
|
||||
usePlexViewControls: () => controls,
|
||||
}));
|
||||
|
||||
import PlexScopeToggle from "./PlexScopeToggle";
|
||||
|
||||
beforeEach(() => {
|
||||
plexState.scope = "both";
|
||||
controls.kind = "discover";
|
||||
setScope.mockReset();
|
||||
controls.open.mockReset();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const buttons = () => [
|
||||
...screen.getByRole("group", { name: "plex.filter.scope" }).querySelectorAll("button"),
|
||||
];
|
||||
const labels = () => buttons().map((b) => b.textContent);
|
||||
const pressed = () =>
|
||||
buttons()
|
||||
.filter((b) => b.getAttribute("aria-pressed") === "true")
|
||||
.map((b) => b.textContent);
|
||||
|
||||
describe("PlexScopeToggle", () => {
|
||||
it("renders the three library scopes, current one pressed", () => {
|
||||
it("renders the three scopes plus the two browse modes, the current scope pressed", () => {
|
||||
plexState.scope = "movie";
|
||||
render(<PlexScopeToggle />);
|
||||
const group = screen.getByRole("group", { name: "plex.filter.scope" });
|
||||
const btns = [...group.querySelectorAll("button")];
|
||||
expect(btns.map((b) => b.textContent)).toEqual([
|
||||
expect(labels()).toEqual([
|
||||
"plex.filter.scopeOpt.both",
|
||||
"plex.filter.scopeOpt.movie",
|
||||
"plex.filter.scopeOpt.show",
|
||||
"plex.filter.scopeOpt.playlists",
|
||||
"plex.filter.scopeOpt.collections",
|
||||
]);
|
||||
const pressed = btns.filter((b) => b.getAttribute("aria-pressed") === "true");
|
||||
expect(pressed.map((b) => b.textContent)).toEqual(["plex.filter.scopeOpt.movie"]);
|
||||
expect(pressed()).toEqual(["plex.filter.scopeOpt.movie"]);
|
||||
});
|
||||
|
||||
it("writes the picked scope back to the provider", () => {
|
||||
it("writes a picked scope back to the provider (no view switch when already on a title view)", () => {
|
||||
render(<PlexScopeToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.show" }));
|
||||
expect(setScope).toHaveBeenCalledWith("show");
|
||||
expect(controls.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the playlists view (not a scope change) for the Playlist mode", () => {
|
||||
render(<PlexScopeToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.playlists" }));
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "playlists" });
|
||||
expect(setScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the Collection mode active when the current view is collections", () => {
|
||||
controls.kind = "collections";
|
||||
render(<PlexScopeToggle />);
|
||||
expect(pressed()).toEqual(["plex.filter.scopeOpt.collections"]);
|
||||
});
|
||||
|
||||
it("returns to the title grid when a scope is picked from a browse mode", () => {
|
||||
controls.kind = "playlists";
|
||||
render(<PlexScopeToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.movie" }));
|
||||
expect(setScope).toHaveBeenCalledWith("movie");
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,48 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { usePlexViewControls } from "./PlexViewBridge";
|
||||
|
||||
// The Plex library scope (both | movie | show) as a compact header control, sitting next to the
|
||||
// search box (S3 toolbar relocation — it used to be a sidebar island). Reads/writes the same
|
||||
// provider state the sidebar did, so scope stays in sync everywhere; reuses the shared
|
||||
// SegmentedToggle and the existing plex.filter.scopeOpt.* labels.
|
||||
// The Plex "browse target" as a compact header control next to the search box: the three library
|
||||
// scopes (both | movie | show, which browse TITLES) plus two extra entry points — Playlist and
|
||||
// Collection — that switch the content area to a grid of the user's playlists / of the collections.
|
||||
// The scopes read/write the shared provider state (so scope stays in sync everywhere); the two extra
|
||||
// modes drive PlexBrowse's sub-view through the PlexViewBridge. Picking a scope while in a
|
||||
// playlists/collections surface returns to the title grid at that scope.
|
||||
const SCOPES = ["both", "movie", "show"] as const;
|
||||
|
||||
export default function PlexScopeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const { scope } = usePlex();
|
||||
const { setScope } = usePlexActions();
|
||||
const ctl = usePlexViewControls();
|
||||
const inPlaylists = ctl?.kind === "playlists";
|
||||
const inCollections = ctl?.kind === "collections";
|
||||
// The active segment: the browse-mode when in one, else the library scope.
|
||||
const value = inPlaylists ? "playlists" : inCollections ? "collections" : scope;
|
||||
|
||||
const onChange = (v: string) => {
|
||||
if (v === "playlists" || v === "collections") {
|
||||
ctl?.open({ kind: v });
|
||||
return;
|
||||
}
|
||||
setScope(v);
|
||||
// Leaving the playlists/collections surface for a title scope → show the title grid.
|
||||
if (inPlaylists || inCollections) ctl?.open({ kind: "grid" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto shrink-0">
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("plex.filter.scope")}
|
||||
options={SCOPES.map((s) => ({ value: s, label: t(`plex.filter.scopeOpt.${s}`) }))}
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
options={[
|
||||
...SCOPES.map((s) => ({ value: s, label: t(`plex.filter.scopeOpt.${s}`) })),
|
||||
{ value: "playlists", label: t("plex.filter.scopeOpt.playlists") },
|
||||
{ value: "collections", label: t("plex.filter.scopeOpt.collections") },
|
||||
]}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,8 +13,11 @@ import { type PanelItem } from "./PanelGroups";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
||||
import SavedViewsPopover from "./SavedViewsPopover";
|
||||
import PersonalViewsModal from "./PersonalViewsModal";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
|
||||
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
||||
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
|
||||
@@ -28,7 +31,6 @@ type Props = {
|
||||
onToggleCollapse: () => void;
|
||||
};
|
||||
|
||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||
const RATING_STEPS = [5, 6, 7, 8, 9];
|
||||
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
||||
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
|
||||
@@ -67,22 +69,17 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
// the header search box); this rail is the primary editor of them. Opening a playlist is a
|
||||
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
|
||||
const { scope, show, filters } = usePlex();
|
||||
const { setShow, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions();
|
||||
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
||||
const [newPlaylist, setNewPlaylist] = useState("");
|
||||
const { setFilters } = usePlexActions();
|
||||
const isDemo = useMe().is_demo;
|
||||
// Personal ("saved") views for the Plex module: the rail header icon opens a quick-pick popover, the
|
||||
// title opens the full manager modal (same shared components as the feed). Hidden for the demo account.
|
||||
const { openModal, modalOpen } = useSavedViews();
|
||||
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
|
||||
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||
const source: MediaSourceKind = "plex";
|
||||
const qc = useQueryClient();
|
||||
async function createPlaylist() {
|
||||
const title = newPlaylist.trim();
|
||||
if (!title) return;
|
||||
const pl = await api.plexCreatePlaylist(title);
|
||||
setNewPlaylist("");
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
onOpenPlaylist(pl.id);
|
||||
}
|
||||
const { collectionTitle: _ct, ...facetKey } = filters;
|
||||
// Titles are display-only (chip labels) — keep them OUT of the facet query key so renaming/relabelling
|
||||
// never refetches. collectionMode stays IN the key: any/all changes the result set.
|
||||
const { collectionTitles: _cts, ...facetKey } = filters;
|
||||
const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch
|
||||
const facetsQ = useQuery({
|
||||
queryKey: qk.plexFacets(scope, show, facetKey, lang),
|
||||
@@ -96,10 +93,8 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
// Scope and sort moved to the header / above-the-grid toolbar (S3); the panel badge now counts only
|
||||
// the filters that still live in this panel (expanded filters + watch-state).
|
||||
const activeCount = fCount + (show !== "all" ? 1 : 0);
|
||||
const anyActive = activeCount > 0;
|
||||
|
||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||
const clearAll = usePlexClearFilters();
|
||||
|
||||
const durBucketKey = DURATION_BUCKETS.find(
|
||||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||||
@@ -115,56 +110,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
{
|
||||
id: "playlists",
|
||||
title: t("plex.playlist.section"),
|
||||
body: (
|
||||
<>
|
||||
<div className="mb-1.5 flex gap-1.5">
|
||||
<input
|
||||
value={newPlaylist}
|
||||
onChange={(e) => setNewPlaylist(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
|
||||
placeholder={t("plex.playlist.newPlaceholder")}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
onClick={createPlaylist}
|
||||
disabled={!newPlaylist.trim()}
|
||||
title={t("plex.playlist.create")}
|
||||
className="glass-card glass-hover shrink-0 rounded-lg p-1.5 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{(playlistsQ.data?.playlists ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted">{t("plex.playlist.none")}</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{playlistsQ.data!.playlists.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onOpenPlaylist(p.id)}
|
||||
className="glass-hover flex w-full items-center gap-2 rounded-sm px-1.5 py-1 text-left text-sm"
|
||||
>
|
||||
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="min-w-0 flex-1 truncate">{p.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted">{p.item_count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "watch",
|
||||
title: t("plex.filter.show"),
|
||||
body: (
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("plex.filter.show")}
|
||||
options={SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
|
||||
value={show}
|
||||
onChange={setShow}
|
||||
/>
|
||||
),
|
||||
body: <PlaylistPicker />,
|
||||
},
|
||||
...(peopleActive
|
||||
? [
|
||||
@@ -205,9 +151,11 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
title: t("plex.filter.collection"),
|
||||
body: (
|
||||
<CollectionPicker
|
||||
value={filters.collection}
|
||||
title={filters.collectionTitle}
|
||||
onPick={(id, title) => patch({ collection: id, collectionTitle: title })}
|
||||
values={filters.collections}
|
||||
titles={filters.collectionTitles}
|
||||
mode={filters.collectionMode ?? "any"}
|
||||
onChange={(collections, collectionTitles) => patch({ collections, collectionTitles })}
|
||||
onModeChange={(m) => patch({ collectionMode: m })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -351,44 +299,122 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
];
|
||||
|
||||
return (
|
||||
<FilterRail
|
||||
kind={source}
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
items={items}
|
||||
actions={
|
||||
anyActive ? (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted hover:text-accent transition px-1 whitespace-nowrap"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t("plex.filter.clearAll")}
|
||||
</button>
|
||||
) : undefined
|
||||
<>
|
||||
<FilterRail
|
||||
kind={source}
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
items={items}
|
||||
// The Plex widgets are simple label + control rows now, not collapsible islands (matches the
|
||||
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
|
||||
collapsible={false}
|
||||
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal. Hidden
|
||||
// for the demo account. Same shared components as the feed rail (SavedViewsProvider switches source).
|
||||
iconExpanded={
|
||||
isDemo ? undefined : (
|
||||
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
|
||||
)
|
||||
}
|
||||
onTitleClick={isDemo ? undefined : openModal}
|
||||
titleActionLabel={isDemo ? undefined : t("views.manageTitle")}
|
||||
/>
|
||||
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
|
||||
{modalOpen && <PersonalViewsModal />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The playlists section: a combobox that stays compact (just a field + chevron) until opened, instead
|
||||
// of an always-expanded list that grew the rail with every playlist. Picking a playlist opens it (the
|
||||
// PlexBrowse `playlistOpen` hand-off); typing a name that doesn't exist yet offers a "Create …" row so
|
||||
// you can still add one — the same reuse-the-Combobox idea as the feed's "Your tags" widget. `selected`
|
||||
// stays empty: this picks an action (open / create), it doesn't hold a persistent selection.
|
||||
const CREATE_PLAYLIST = "__create__";
|
||||
function PlaylistPicker() {
|
||||
const { t } = useTranslation();
|
||||
const { setPlaylistOpen: onOpenPlaylist } = usePlexActions();
|
||||
const qc = useQueryClient();
|
||||
const [query, setQuery] = useState("");
|
||||
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
||||
const playlists = playlistsQ.data?.playlists ?? [];
|
||||
|
||||
const q = query.trim();
|
||||
const ql = q.toLowerCase();
|
||||
const matches = ql ? playlists.filter((p) => p.title.toLowerCase().includes(ql)) : playlists;
|
||||
const exact = playlists.some((p) => p.title.toLowerCase() === ql);
|
||||
const options: ComboboxOption[] = [
|
||||
...matches.map((p) => ({ value: `pl:${p.id}`, label: p.title, count: p.item_count })),
|
||||
// Offer to create only when the typed name isn't already a playlist.
|
||||
...(q && !exact ? [{ value: CREATE_PLAYLIST, label: q }] : []),
|
||||
];
|
||||
|
||||
async function createPlaylist(title: string) {
|
||||
const pl = await api.plexCreatePlaylist(title);
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
onOpenPlaylist(pl.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
label={t("plex.playlist.section")}
|
||||
multiple={false}
|
||||
clientFilter={false} // we build `options` (incl. the create row) against the query ourselves
|
||||
options={options}
|
||||
selected={[]}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
placeholder={t("plex.playlist.pickerPlaceholder")}
|
||||
emptyText={t("plex.playlist.none")}
|
||||
renderOption={(o) =>
|
||||
o.value === CREATE_PLAYLIST ? (
|
||||
<>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="flex-1 truncate">
|
||||
{t("plex.playlist.createNamed", { name: o.label })}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="flex-1 truncate">{o.label}</span>
|
||||
{o.count != null && (
|
||||
<span className="shrink-0 text-2xs tabular-nums text-muted">{o.count}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
onChange={(next) => {
|
||||
const v = next[0];
|
||||
if (!v) return;
|
||||
if (v === CREATE_PLAYLIST) createPlaylist(q);
|
||||
else onOpenPlaylist(Number(v.slice(3)));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The collection filter (E5 S7 → combobox rework): a single-select combi-input matching the
|
||||
// genre/studio pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The
|
||||
// generic <Combobox> owns the field/keyboard/ARIA; this wrapper only owns the query→fetch (gated on
|
||||
// `open`) and maps collections to options. Picking sets the filter (id + title for the chip); clearing
|
||||
// the chip drops it.
|
||||
// The collection filter (E5 S7 → combobox; now multi-select): a combi-input matching the genre/studio
|
||||
// pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The generic <Combobox>
|
||||
// owns the field/keyboard/ARIA + the any/all match footer; this wrapper owns the query→fetch (gated on
|
||||
// `open`) and maps collections to options. A title can belong to several collections, so multiple can
|
||||
// be picked and combined with Any/All. Titles are carried alongside the ids for the active-filter chips.
|
||||
function CollectionPicker({
|
||||
value,
|
||||
title,
|
||||
onPick,
|
||||
values,
|
||||
titles,
|
||||
mode,
|
||||
onChange,
|
||||
onModeChange,
|
||||
}: {
|
||||
value: string | null | undefined;
|
||||
title: string | null | undefined;
|
||||
onPick: (id: string | null, title: string | null) => void;
|
||||
values: string[];
|
||||
titles: Record<string, string> | undefined;
|
||||
mode: "any" | "all";
|
||||
onChange: (values: string[], titles: Record<string, string> | undefined) => void;
|
||||
onModeChange: (mode: "any" | "all") => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -404,25 +430,33 @@ function CollectionPicker({
|
||||
label: c.title,
|
||||
count: c.child_count ?? undefined,
|
||||
}));
|
||||
// Combine the fetched labels with the ones already stored so the trip through onChange keeps every
|
||||
// selected collection's title (the fetched list is narrowed by the current search).
|
||||
const labelMap: Record<string, string> = { ...titles };
|
||||
for (const o of options) if (o.label) labelMap[o.value] = o.label;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
label={t("plex.filter.collection")}
|
||||
multiple={false}
|
||||
clientFilter={false} // the server already narrows to `search`
|
||||
options={options}
|
||||
selected={value ? [value] : []}
|
||||
selectedLabels={value && title ? { [value]: title } : undefined}
|
||||
selected={values}
|
||||
selectedLabels={titles}
|
||||
query={search}
|
||||
onQueryChange={setSearch}
|
||||
onOpenChange={setOpen}
|
||||
loading={q.isFetching}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
emptyText={t("plex.filter.collectionEmpty")}
|
||||
matchMode={mode === "all" ? "and" : "or"}
|
||||
onMatchModeChange={(m) => onModeChange(m === "and" ? "all" : "any")}
|
||||
onChange={(next) => {
|
||||
const id = next[0] ?? null;
|
||||
const picked = options.find((o) => o.value === id);
|
||||
onPick(id, id ? (picked?.label ?? title ?? id) : null);
|
||||
// Carry only the selected ids' titles forward (drop titles for de-selected ones). Collapse an
|
||||
// empty map to undefined so a clear-all key-matches the pristine state (an empty {} still hashes
|
||||
// differently from an absent key), mirroring the chip-row remove handler.
|
||||
const nextTitles: Record<string, string> = {};
|
||||
for (const id of next) nextTitles[id] = labelMap[id] ?? titles?.[id] ?? id;
|
||||
onChange(next, next.length ? nextTitles : undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { type Sub } from "./plexSubUrl";
|
||||
|
||||
// A one-way bridge so the always-mounted Header can host the Plex Discover ⟷ Browse toggle even though
|
||||
// the sub-view state lives in (and is owned by) the plex-only PlexBrowse. PlexBrowse PUBLISHES its
|
||||
// current top-level view + opener here (on mount and on view change) and clears it on unmount; the
|
||||
// Header READS it to render the toggle beside the library-scope pill.
|
||||
//
|
||||
// Why a bridge and not a global sub-view provider: PlexBrowse's `useHistorySubview` REMOUNTS every time
|
||||
// you enter the plex page, re-seeding the view from history (a page switch clears ?v + drops _sub, so
|
||||
// each plex entry starts at the Discover root). Lifting that hook to an always-mounted provider would
|
||||
// lose the remount and let the view go stale across navigation. Keeping it in PlexBrowse preserves the
|
||||
// reseed; the bridge only mirrors the small slice the header needs.
|
||||
export type PlexViewControls = {
|
||||
kind: Sub["kind"]; // the current top-level plex view — the header controls derive their state from it
|
||||
open: (v: Sub) => void;
|
||||
};
|
||||
|
||||
const ReadCtx = createContext<PlexViewControls | null>(null);
|
||||
const PublishCtx = createContext<(c: PlexViewControls | null) => void>(() => {});
|
||||
|
||||
export function PlexViewBridge({ children }: { children: ReactNode }) {
|
||||
const [controls, setControls] = useState<PlexViewControls | null>(null);
|
||||
return (
|
||||
<PublishCtx.Provider value={setControls}>
|
||||
<ReadCtx.Provider value={controls}>{children}</ReadCtx.Provider>
|
||||
</PublishCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const usePlexViewControls = () => useContext(ReadCtx);
|
||||
export const usePublishPlexView = () => useContext(PublishCtx);
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the header Discover ⟷ Browse toggle. The floating header wrapper is pointer-events-none,
|
||||
// so the toggle MUST re-enable pointer events on its root or it is click-dead (the bug this test guards
|
||||
// against). Also: it only renders on the two title-browse views, marks the active one, and drives
|
||||
// PlexBrowse's sub-view through the bridge. i18n is key-passthrough; the bridge is mocked.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } 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 }) };
|
||||
});
|
||||
|
||||
const controls = { kind: "discover" as string, open: vi.fn() };
|
||||
vi.mock("./PlexViewBridge", () => ({
|
||||
usePlexViewControls: () => controls,
|
||||
}));
|
||||
|
||||
import PlexViewToggle from "./PlexViewToggle";
|
||||
|
||||
beforeEach(() => {
|
||||
controls.kind = "discover";
|
||||
controls.open.mockReset();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("PlexViewToggle", () => {
|
||||
it("re-enables pointer events on its root (the header wrapper is pointer-events-none)", () => {
|
||||
const { container } = render(<PlexViewToggle />);
|
||||
// Without this class the whole toggle is click-dead inside the pointer-events-none header.
|
||||
expect(container.querySelector(".pointer-events-auto")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the two options with the current view pressed", () => {
|
||||
render(<PlexViewToggle />);
|
||||
const btns = [
|
||||
...screen.getByRole("group", { name: "plex.view.label" }).querySelectorAll("button"),
|
||||
];
|
||||
expect(btns.map((b) => b.textContent)).toEqual(["plex.discover.tab", "plex.view.browse"]);
|
||||
const pressed = btns
|
||||
.filter((b) => b.getAttribute("aria-pressed") === "true")
|
||||
.map((b) => b.textContent);
|
||||
expect(pressed).toEqual(["plex.discover.tab"]);
|
||||
});
|
||||
|
||||
it("opens the grid view when Browse is picked", () => {
|
||||
render(<PlexViewToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.view.browse" }));
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
|
||||
});
|
||||
|
||||
it("opens the discover view when Discover is picked from the grid", () => {
|
||||
controls.kind = "grid";
|
||||
render(<PlexViewToggle />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "plex.discover.tab" }));
|
||||
expect(controls.open).toHaveBeenCalledWith({ kind: "discover" });
|
||||
});
|
||||
|
||||
it("renders nothing on a deeper view (no Discover/Browse choice there)", () => {
|
||||
controls.kind = "playlists";
|
||||
const { container } = render(<PlexViewToggle />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import { usePlexViewControls } from "./PlexViewBridge";
|
||||
|
||||
// The Discover ⟷ Browse switch, hosted in the header next to the library-scope pill (All/Movies/Shows).
|
||||
// It reflects + drives PlexBrowse's top-level sub-view through the PlexViewBridge, and hides itself on
|
||||
// the deeper views (a show/info/person page has no Discover/Browse choice).
|
||||
export default function PlexViewToggle() {
|
||||
const { t } = useTranslation();
|
||||
const ctl = usePlexViewControls();
|
||||
// Only the two title-browse views host the Discover/Browse choice; deeper views (show/info/person/…)
|
||||
// and the playlists/collections browse surfaces hide it.
|
||||
if (!ctl || (ctl.kind !== "discover" && ctl.kind !== "grid")) return null;
|
||||
// The floating header wrapper is pointer-events-none (so clicks fall through its empty area to the
|
||||
// content below); every interactive child must re-enable pointer events or it is click-dead — the
|
||||
// sibling PlexScopeToggle does the same.
|
||||
return (
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
className="pointer-events-auto shrink-0"
|
||||
ariaLabel={t("plex.view.label")}
|
||||
options={[
|
||||
{ value: "discover", label: t("plex.discover.tab") },
|
||||
{ value: "grid", label: t("plex.view.browse") },
|
||||
]}
|
||||
value={ctl.kind === "discover" ? "discover" : "grid"}
|
||||
onChange={(v) => ctl.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
||||
|
||||
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
|
||||
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
|
||||
// (a full-bleed poster collage drowned it). A group with no art falls back to an all-colour tile, never
|
||||
// blank. Shared by the Discover genre tiles and the Playlist/Collection browse tiles.
|
||||
export default function PosterTile({
|
||||
title,
|
||||
subtitle,
|
||||
topRight,
|
||||
art,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items · 4h 30m")
|
||||
topRight?: ReactNode; // top-right corner content on the colour band (e.g. an icon)
|
||||
art?: string[]; // up to 2 poster rating_keys; fewer/none falls back to the colour hash
|
||||
onClick: () => void;
|
||||
className?: string; // caller sets the height (e.g. "h-20" / "h-28")
|
||||
}) {
|
||||
const posters = (art ?? []).slice(0, 2);
|
||||
const band = hueTileGradient(hueOf(title));
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
|
||||
>
|
||||
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
|
||||
<div
|
||||
className="grid h-1/2 min-h-0 grid-cols-2"
|
||||
aria-hidden
|
||||
style={posters.length === 0 ? { background: band } : undefined}
|
||||
>
|
||||
{posters.length > 0 &&
|
||||
[0, 1].map((i) =>
|
||||
posters[i] ? (
|
||||
<img
|
||||
key={i}
|
||||
src={`/api/plex/image/${posters[i]}`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div key={i} style={{ background: hueTileGradient(hueOf(title + i)) }} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{/* Bottom half: solid colour band — the label lives here so it stays readable over any art. */}
|
||||
<div className="relative flex flex-1 flex-col justify-end p-2.5" style={{ background: band }}>
|
||||
{topRight != null && <span className="absolute right-2 top-2">{topRight}</span>}
|
||||
<div className="truncate text-sm font-semibold text-white drop-shadow">{title}</div>
|
||||
{subtitle != null && <div className="truncate text-2xs text-white/80">{subtitle}</div>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bookmark, Check, Settings2 } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
|
||||
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
|
||||
// their trigger: click → a popover list of the user's saved views (apply on click, the active one
|
||||
// checked) + a "Manage views…" entry that opens the Personal Views modal, where the editing lives.
|
||||
export default function SavedViewsPopover({
|
||||
trigger,
|
||||
className,
|
||||
ariaLabel,
|
||||
placement = "bottom-start",
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
placement?: "bottom-start" | "bottom-end";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { views, activeViewId, apply, openModal } = useSavedViews();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement });
|
||||
const label = ariaLabel ?? t("views.saved");
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={label}
|
||||
className={className ?? "shrink-0 flex text-accent transition hover:text-fg"}
|
||||
>
|
||||
{trigger}
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className="glass-menu min-w-56 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{views.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-2xs leading-snug text-muted">{t("views.empty")}</p>
|
||||
) : (
|
||||
<div role="group" aria-label={label}>
|
||||
{views.map((v) => (
|
||||
<PopoverItem
|
||||
key={v.id}
|
||||
role="menuitemradio"
|
||||
checked={v.id === activeViewId}
|
||||
onSelect={() => {
|
||||
apply(v);
|
||||
setOpen(false);
|
||||
}}
|
||||
label={v.name}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
v.id === activeViewId ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<Bookmark className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span className="flex-1 truncate">{v.name}</span>
|
||||
{v.id === activeViewId && <Check className="h-3.5 w-3.5 shrink-0 text-accent" />}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 border-t border-border pt-1">
|
||||
<PopoverItem
|
||||
role="menuitem"
|
||||
onSelect={() => {
|
||||
openModal();
|
||||
setOpen(false);
|
||||
}}
|
||||
label={t("views.manage")}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted transition hover:text-fg"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="flex-1">{t("views.manage")}</span>
|
||||
</PopoverItem>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import {
|
||||
api,
|
||||
coercePlexFilters,
|
||||
coerceTagModes,
|
||||
getActiveAccount,
|
||||
plexFiltersSignature,
|
||||
type FeedFilters,
|
||||
type PlexFilters,
|
||||
type SavedView,
|
||||
} from "../lib/api";
|
||||
import { filtersToParams } from "../lib/urlState";
|
||||
import { preferSticky } from "../lib/savedViews";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { accountKey, LS, writeJSON } from "../lib/storage";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
|
||||
// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider
|
||||
// switches which module it operates on by the active page (both filter providers are global, so it can
|
||||
// read either), so the shared UI (popover / modal / chip) works on the feed page against feed views and
|
||||
// on the plex page against plex views without a second provider or an App-layout change.
|
||||
|
||||
// A saved view's `filters` is an opaque JSON blob from the DB; coerce it to the module's shape before it
|
||||
// reaches app state or a serializer (an older build's dropped field just falls away — NOT a migration).
|
||||
export const asFilters = (raw: FeedFilters): FeedFilters => ({
|
||||
...raw,
|
||||
tags: raw.tags ?? [],
|
||||
channelIds: raw.channelIds ?? [],
|
||||
tagModes: coerceTagModes(raw),
|
||||
});
|
||||
|
||||
type ViewSource = "feed" | "plex";
|
||||
|
||||
// The per-source wiring the generic logic runs on: the live filters, how to apply a view's, a stable
|
||||
// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed).
|
||||
interface ViewConfig {
|
||||
source: ViewSource;
|
||||
filters: Record<string, unknown>;
|
||||
setFilters: (f: Record<string, unknown>) => void;
|
||||
keyOf: (f: Record<string, unknown>) => string;
|
||||
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
|
||||
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
|
||||
/** Feed only: mirror the default view's filters to localStorage so it seeds before the network returns. */
|
||||
syncDefaultMirror?: (views: SavedView[]) => void;
|
||||
}
|
||||
|
||||
const feedKeyOf = (f: Record<string, unknown>) =>
|
||||
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
|
||||
const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
|
||||
|
||||
// Mirror the feed default view's filters to localStorage (read by FeedFiltersProvider on load).
|
||||
function syncFeedDefaultMirror(views: SavedView[]): void {
|
||||
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
||||
if (!key) return;
|
||||
const def = views.find((v) => v.is_default);
|
||||
writeJSON(key, def ? def.filters : null);
|
||||
}
|
||||
|
||||
export interface SavedViewsContext {
|
||||
/** Which module these views belong to — the shared UI branches its filter-describer on it. */
|
||||
source: ViewSource;
|
||||
views: SavedView[];
|
||||
loading: boolean;
|
||||
activeViewId: number | null;
|
||||
displayView: SavedView | null;
|
||||
isDirty: boolean;
|
||||
modalOpen: boolean;
|
||||
openModal: () => void;
|
||||
closeModal: () => void;
|
||||
apply: (v: SavedView) => void;
|
||||
clearActive: () => void;
|
||||
create: (name: string) => Promise<boolean>;
|
||||
rename: (id: number, name: string) => Promise<boolean>;
|
||||
toggleDefault: (v: SavedView) => Promise<void>;
|
||||
remove: (v: SavedView) => Promise<boolean>;
|
||||
reorder: (nextIds: number[]) => Promise<void>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<SavedViewsContext | null>(null);
|
||||
|
||||
export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const { page } = useNavigation();
|
||||
|
||||
// Both filter providers are global — read both, then use the one the active page owns.
|
||||
const feedFilters = useFeedFilters();
|
||||
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
||||
const { filters: plexFilters } = usePlex();
|
||||
const { setFilters: setPlexFilters } = usePlexActions();
|
||||
|
||||
const cfg: ViewConfig = useMemo(() => {
|
||||
if (page === "plex") {
|
||||
return {
|
||||
source: "plex",
|
||||
filters: plexFilters as unknown as Record<string, unknown>,
|
||||
setFilters: (f) => setPlexFilters(f as unknown as PlexFilters),
|
||||
keyOf: plexKeyOf,
|
||||
asFilters: (raw) => coercePlexFilters(raw) as unknown as Record<string, unknown>,
|
||||
stripForSave: (f) => f, // Plex has no transient field to drop
|
||||
};
|
||||
}
|
||||
return {
|
||||
source: "feed",
|
||||
filters: feedFilters as unknown as Record<string, unknown>,
|
||||
setFilters: (f) => setFeedFilters(f as unknown as FeedFilters),
|
||||
keyOf: feedKeyOf,
|
||||
asFilters: (raw) =>
|
||||
asFilters(raw as unknown as FeedFilters) as unknown as Record<string, unknown>,
|
||||
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
|
||||
stripForSave: (f) => {
|
||||
const { seed: _seed, ...rest } = f;
|
||||
return rest;
|
||||
},
|
||||
syncDefaultMirror: syncFeedDefaultMirror,
|
||||
};
|
||||
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
|
||||
|
||||
const source = cfg.source;
|
||||
const viewsQuery = useQuery({
|
||||
queryKey: qk.savedViews(source),
|
||||
queryFn: () => api.savedViews(source),
|
||||
});
|
||||
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
|
||||
|
||||
const [stickyId, setStickyId] = useState<number | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// Switching modules (feed ⟷ plex) starts fresh: a feed view can't be the sticky/active one on plex,
|
||||
// and a feed modal shouldn't linger open on plex.
|
||||
useEffect(() => {
|
||||
setStickyId(null);
|
||||
setModalOpen(false);
|
||||
}, [source]);
|
||||
|
||||
// Feed only: keep the default-view mirror (read on load) in sync with the server state. Depend on the
|
||||
// mirror fn (a module-stable ref, or undefined for plex) NOT the whole `cfg` object — cfg re-memoizes
|
||||
// on every live filter edit, which would fire this synchronous localStorage write far beyond its intent.
|
||||
const syncMirror = cfg.syncDefaultMirror;
|
||||
useEffect(() => {
|
||||
if (viewsQuery.data) syncMirror?.(viewsQuery.data);
|
||||
}, [viewsQuery.data, syncMirror]);
|
||||
|
||||
const currentKey = cfg.keyOf(cfg.filters);
|
||||
const activeView = useMemo(
|
||||
() =>
|
||||
preferSticky(
|
||||
views.filter((v) => cfg.keyOf(v.filters) === currentKey),
|
||||
stickyId,
|
||||
),
|
||||
[views, currentKey, stickyId, cfg],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (activeView) setStickyId(activeView.id);
|
||||
}, [activeView]);
|
||||
|
||||
const stickyView = useMemo(
|
||||
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
|
||||
[views, stickyId],
|
||||
);
|
||||
const displayView = activeView ?? stickyView;
|
||||
const isDirty = displayView != null && activeView == null;
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews(source) });
|
||||
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
||||
try {
|
||||
await fn();
|
||||
return true;
|
||||
} catch (e) {
|
||||
notifyActionError(e, failMsg);
|
||||
return false;
|
||||
} finally {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const value: SavedViewsContext = {
|
||||
source,
|
||||
views,
|
||||
loading: viewsQuery.isLoading,
|
||||
activeViewId: activeView?.id ?? null,
|
||||
displayView,
|
||||
isDirty,
|
||||
modalOpen,
|
||||
openModal: () => setModalOpen(true),
|
||||
closeModal: () => setModalOpen(false),
|
||||
apply: (v) => {
|
||||
cfg.setFilters(cfg.asFilters(v.filters));
|
||||
setStickyId(v.id);
|
||||
},
|
||||
clearActive: () => setStickyId(null),
|
||||
create: async (name) => {
|
||||
const clean = name.trim();
|
||||
if (!clean) return false;
|
||||
return runViewMutation(
|
||||
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
|
||||
t("views.actionFailed"),
|
||||
);
|
||||
},
|
||||
rename: async (id, name) => {
|
||||
const next = name.trim();
|
||||
if (!next) return false;
|
||||
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
|
||||
},
|
||||
toggleDefault: (v) =>
|
||||
runViewMutation(
|
||||
() => api.updateView(v.id, { is_default: !v.is_default }),
|
||||
t("views.actionFailed"),
|
||||
).then(() => undefined),
|
||||
remove: async (v) => {
|
||||
const ok = await confirm({
|
||||
message: t("views.deleteConfirm", { name: v.name }),
|
||||
confirmLabel: t("views.delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return false;
|
||||
if (stickyId === v.id) setStickyId(null);
|
||||
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
||||
},
|
||||
reorder: async (nextIds) => {
|
||||
qc.setQueryData<SavedView[]>(
|
||||
qk.savedViews(source),
|
||||
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
|
||||
);
|
||||
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
||||
},
|
||||
};
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useSavedViews(): SavedViewsContext {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { BookmarkPlus, Check, GripVertical, Pencil, Star, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
|
||||
import { filtersToParams } from "../lib/urlState";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { accountKey, LS, writeJSON } from "../lib/storage";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
// Compact, order-independent signature of a filter set (reuses the share serializer, which
|
||||
// emits only non-default values and ignores the transient shuffle seed). Used to highlight the
|
||||
// saved view that matches the feed's current filters.
|
||||
const keyOf = (f: FeedFilters) => filtersToParams(asFilters(f)).toString();
|
||||
|
||||
// A saved view's `filters` is a JSON blob straight from the DB — the FeedFilters type says nothing
|
||||
// about what an older build actually wrote there, so coerce the array fields before the blob reaches
|
||||
// app state or a serializer. Deliberately NOT a migration: a view saved against a dropped field just
|
||||
// loses that filter.
|
||||
const asFilters = (raw: FeedFilters): FeedFilters => ({
|
||||
...raw,
|
||||
tags: raw.tags ?? [],
|
||||
channelIds: raw.channelIds ?? [],
|
||||
});
|
||||
|
||||
function syncDefaultMirror(views: SavedView[]): void {
|
||||
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
||||
if (!key) return;
|
||||
const def = views.find((v) => v.is_default);
|
||||
writeJSON(key, def ? def.filters : null);
|
||||
}
|
||||
|
||||
export default function SavedViewsWidget({
|
||||
filters,
|
||||
onApply,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
onApply: (f: FeedFilters) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const viewsQuery = useQuery({ queryKey: qk.savedViews(), queryFn: api.savedViews });
|
||||
const views = viewsQuery.data ?? [];
|
||||
const [naming, setNaming] = useState(false);
|
||||
const [draftName, setDraftName] = useState("");
|
||||
const [renameId, setRenameId] = useState<number | null>(null);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||
|
||||
// Keep the default-view mirror (read on load) in sync with the server state.
|
||||
useEffect(() => {
|
||||
if (viewsQuery.data) syncDefaultMirror(viewsQuery.data);
|
||||
}, [viewsQuery.data]);
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() });
|
||||
|
||||
// Run a saved-view mutation with uniform feedback: on failure surface the server's reason instead
|
||||
// of a silent swallow, and always refetch afterwards (which also reverts an optimistic change to
|
||||
// the server's truth). Returns whether it succeeded, so callers can gate success-only side effects
|
||||
// (e.g. closing an editor) and keep the input open — with the user's text — on failure.
|
||||
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
||||
try {
|
||||
await fn();
|
||||
return true;
|
||||
} catch (e) {
|
||||
notifyActionError(e, failMsg);
|
||||
return false;
|
||||
} finally {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
const currentKey = keyOf(filters);
|
||||
|
||||
async function save() {
|
||||
const name = draftName.trim();
|
||||
if (!name) return;
|
||||
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
|
||||
const { seed: _seed, ...clean } = filters;
|
||||
// Close the naming input only on success, so a failed create keeps the user's typed name.
|
||||
if (await runViewMutation(() => api.createView(name, clean), t("views.actionFailed"))) {
|
||||
setNaming(false);
|
||||
setDraftName("");
|
||||
}
|
||||
}
|
||||
|
||||
async function rename(id: number, name: string) {
|
||||
const next = name.trim();
|
||||
if (!next) {
|
||||
setRenameId(null);
|
||||
return;
|
||||
}
|
||||
// Close the rename editor only on success, so a failed rename stays open for a retry.
|
||||
if (await runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"))) {
|
||||
setRenameId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDefault(v: SavedView) {
|
||||
await runViewMutation(
|
||||
() => api.updateView(v.id, { is_default: !v.is_default }),
|
||||
t("views.actionFailed"),
|
||||
);
|
||||
}
|
||||
|
||||
async function remove(v: SavedView) {
|
||||
const ok = await confirm({
|
||||
message: t("views.deleteConfirm", { name: v.name }),
|
||||
confirmLabel: t("views.delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
||||
}
|
||||
|
||||
async function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e;
|
||||
if (!over || active.id === over.id) return;
|
||||
const ids = views.map((v) => v.id);
|
||||
const oldIndex = ids.indexOf(active.id as number);
|
||||
const newIndex = ids.indexOf(over.id as number);
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
const next = arrayMove(ids, oldIndex, newIndex);
|
||||
// Optimistic reorder so the drop sticks visually before the round-trip.
|
||||
qc.setQueryData<SavedView[]>(
|
||||
qk.savedViews(),
|
||||
next.map((id) => views.find((v) => v.id === id)!),
|
||||
);
|
||||
// On failure runViewMutation's refetch reverts the optimistic order to the server's truth;
|
||||
// without the toast the drop would silently snap back with no explanation.
|
||||
await runViewMutation(() => api.reorderViews(next), t("views.reorderFailed"));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{views.length === 0 && !naming && (
|
||||
<p className="text-2xs text-muted leading-snug">{t("views.empty")}</p>
|
||||
)}
|
||||
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={views.map((v) => v.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1">
|
||||
{views.map((v) => (
|
||||
<ViewRow
|
||||
key={v.id}
|
||||
view={v}
|
||||
active={keyOf(v.filters) === currentKey}
|
||||
renaming={renameId === v.id}
|
||||
onApply={() => onApply(asFilters(v.filters))}
|
||||
onStartRename={() => setRenameId(v.id)}
|
||||
onRename={(name) => rename(v.id, name)}
|
||||
onCancelRename={() => setRenameId(null)}
|
||||
onToggleDefault={() => toggleDefault(v)}
|
||||
onDelete={() => remove(v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{naming ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void save();
|
||||
if (e.key === "Escape") setNaming(false);
|
||||
}}
|
||||
placeholder={t("views.namePlaceholder")}
|
||||
maxLength={80}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void save()}
|
||||
title={t("views.saveAction")}
|
||||
className="p-1 rounded-lg text-muted hover:text-accent hover:bg-card transition"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setNaming(false)}
|
||||
title={t("views.cancel")}
|
||||
className="p-1 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraftName("");
|
||||
setNaming(true);
|
||||
}}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
>
|
||||
<BookmarkPlus className="w-3.5 h-3.5" />
|
||||
{t("views.save")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewRow({
|
||||
view,
|
||||
active,
|
||||
renaming,
|
||||
onApply,
|
||||
onStartRename,
|
||||
onRename,
|
||||
onCancelRename,
|
||||
onToggleDefault,
|
||||
onDelete,
|
||||
}: {
|
||||
view: SavedView;
|
||||
active: boolean;
|
||||
renaming: boolean;
|
||||
onApply: () => void;
|
||||
onStartRename: () => void;
|
||||
onRename: (name: string) => void;
|
||||
onCancelRename: () => void;
|
||||
onToggleDefault: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: view.id,
|
||||
});
|
||||
const [draft, setDraft] = useState(view.name);
|
||||
// Reset the edit draft to the current name each time rename mode opens (so a second rename
|
||||
// doesn't start from a stale value).
|
||||
useEffect(() => {
|
||||
if (renaming) setDraft(view.name);
|
||||
}, [renaming, view.name]);
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
if (renaming) {
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="flex items-center gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onRename(draft);
|
||||
if (e.key === "Escape") onCancelRename();
|
||||
}}
|
||||
maxLength={80}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
onClick={() => onRename(draft)}
|
||||
title={t("views.saveAction")}
|
||||
className="p-1 rounded-lg text-muted hover:text-accent hover:bg-card transition"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelRename}
|
||||
title={t("views.cancel")}
|
||||
className="p-1 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`group flex items-center gap-0.5 rounded-lg pr-1 ${
|
||||
active ? "bg-accent/10" : "hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title={t("views.reorder")}
|
||||
className="p-1 text-muted/60 hover:text-muted cursor-grab touch-none shrink-0"
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onApply}
|
||||
title={t("views.apply")}
|
||||
aria-current={active ? "true" : undefined}
|
||||
className={`flex-1 min-w-0 text-left text-xs py-1 truncate ${
|
||||
active ? "text-accent font-medium" : "text-fg"
|
||||
}`}
|
||||
>
|
||||
{view.name}
|
||||
</button>
|
||||
<div className="flex items-center shrink-0">
|
||||
<button
|
||||
onClick={onToggleDefault}
|
||||
title={view.is_default ? t("views.isDefault") : t("views.makeDefault")}
|
||||
className={`p-1 rounded transition ${
|
||||
view.is_default ? "text-accent" : "text-muted reveal hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onStartRename}
|
||||
title={t("views.rename")}
|
||||
className="p-1 rounded-sm text-muted reveal hover:text-fg transition"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
title={t("views.delete")}
|
||||
className="p-1 rounded-sm text-muted reveal hover:text-danger transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bookmark, ChevronDown } from "lucide-react";
|
||||
import SavedViewsPopover from "./SavedViewsPopover";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
|
||||
// The active personal view, shown as the leading pill before the active-filter chip row. Styled like
|
||||
// the toolbar's FilterSelect pills; a warn-coloured dot appears when the live filters have diverged
|
||||
// from the saved view. Clicking it opens the saved-views picker to switch views. Renders nothing when
|
||||
// no view is active (nothing applied and the filters match no saved view).
|
||||
export default function SelectedViewChip() {
|
||||
const { t } = useTranslation();
|
||||
const { displayView, isDirty } = useSavedViews();
|
||||
if (!displayView) return null;
|
||||
return (
|
||||
<SavedViewsPopover
|
||||
ariaLabel={t("views.saved")}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-fg transition hover:border-accent/60"
|
||||
trigger={
|
||||
<>
|
||||
<Bookmark className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />
|
||||
<span className="max-w-40 truncate">{displayView.name}</span>
|
||||
{isDirty && (
|
||||
<span
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full bg-warn"
|
||||
title={t("views.modified")}
|
||||
aria-label={t("views.modified")}
|
||||
/>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export default function SidePanel({
|
||||
onReset,
|
||||
editHint,
|
||||
actions,
|
||||
iconExpanded,
|
||||
onTitleClick,
|
||||
titleActionLabel,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
@@ -34,6 +37,13 @@ export default function SidePanel({
|
||||
editHint?: string;
|
||||
// Extra header controls shown when NOT editing (e.g. the feed's Clear all + Share view).
|
||||
actions?: ReactNode;
|
||||
// Optional override for the icon in the EXPANDED header only (the collapsed tab keeps the plain
|
||||
// `icon`) — the feed passes its saved-views popover trigger here so the header icon opens the picker.
|
||||
iconExpanded?: ReactNode;
|
||||
// When set, the expanded-header title becomes a button (the feed opens its Personal Views modal).
|
||||
onTitleClick?: () => void;
|
||||
// Accessible name / tooltip describing what onTitleClick does (the plain title text alone doesn't).
|
||||
titleActionLabel?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -44,14 +54,16 @@ export default function SidePanel({
|
||||
|
||||
return (
|
||||
// Layout slot: reserves only the tab's gutter when unpinned, so the expanded card overlays
|
||||
// content instead of pushing it. Pinned reserves the full width (in-flow), as before.
|
||||
// content instead of pushing it. Pinned reserves --rail-pinned-w: 16.75rem (in-flow, pushes) on a
|
||||
// full-width page or a narrow window, but 2.25rem (overlay, doesn't push) when the centred content
|
||||
// leaves room for the rail to sit in its left margin — see PageShell. Default 16.75rem = old w-67.
|
||||
<div
|
||||
// Focus is tracked container-wide (keyboard: tab anywhere inside holds it open). HOVER is NOT
|
||||
// on this full-height slot — that made the whole column next to the nav rail a live target, so
|
||||
// merely approaching the rail popped the panel open. Hover now lives only on the visible tab
|
||||
// handle + the expanded card below, so only pointing AT the tab opens it.
|
||||
{...peek.focusHandlers}
|
||||
className={`hidden md:block relative shrink-0 transition-[width] ${pinned ? "w-67" : "w-9"}`}
|
||||
className={`hidden md:block relative shrink-0 transition-[width] ${pinned ? "w-[var(--rail-pinned-w,16.75rem)]" : "w-9"}`}
|
||||
>
|
||||
{/* The tab: a small handle flush against the nav rail, flat on the docked edge. It stays
|
||||
mounted while the card is open — swapping it out from under the pointer would drop the very
|
||||
@@ -74,7 +86,7 @@ export default function SidePanel({
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-2xs font-semibold uppercase tracking-[0.18em] text-muted"
|
||||
className="text-xs font-semibold uppercase tracking-[0.18em] text-muted"
|
||||
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
|
||||
>
|
||||
{title}
|
||||
@@ -97,10 +109,21 @@ export default function SidePanel({
|
||||
>
|
||||
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
||||
</button>
|
||||
<span className="shrink-0 flex text-accent">{icon}</span>
|
||||
<span className="flex-1 min-w-0 truncate text-xs font-bold uppercase tracking-[0.12em]">
|
||||
{title}
|
||||
</span>
|
||||
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
|
||||
{onTitleClick ? (
|
||||
<button
|
||||
onClick={onTitleClick}
|
||||
title={titleActionLabel}
|
||||
aria-label={titleActionLabel}
|
||||
className="flex-1 min-w-0 truncate text-left text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
{count > 0 && (
|
||||
<span
|
||||
title={t("sidebar.activeCount", { count })}
|
||||
|
||||
@@ -2,20 +2,20 @@ import { type ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { Library, Pencil, SlidersHorizontal, User } from "lucide-react";
|
||||
import { api, type FeedFilters, type Tag } from "../lib/api";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
|
||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
|
||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||
import TagManager from "./TagManager";
|
||||
import SavedViewsWidget from "./SavedViewsWidget";
|
||||
import SavedViewsPopover from "./SavedViewsPopover";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||
import ChannelPicker from "./ChannelPicker";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
import DateFilterSelect from "./DateFilterSelect";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
import { useChannelsActions } from "./ChannelsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
@@ -24,44 +24,15 @@ import { useMe } from "../lib/useMe";
|
||||
const DATE_PRESETS: { days: number; key: string }[] = [
|
||||
{ days: 1, key: "24h" },
|
||||
{ days: 7, key: "1week" },
|
||||
{ days: 14, key: "2weeks" },
|
||||
{ days: 30, key: "1month" },
|
||||
{ days: 90, key: "3months" },
|
||||
{ days: 180, key: "6months" },
|
||||
{ days: 365, key: "1year" },
|
||||
{ days: 730, key: "2years" },
|
||||
{ days: 1825, key: "5years" },
|
||||
];
|
||||
|
||||
function TagChip({
|
||||
tag,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
tag: Tag;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
data-testid="filter-tag-chip"
|
||||
data-tag={tag.name}
|
||||
onClick={onClick}
|
||||
title={t("sidebar.channelCount", { count })}
|
||||
aria-pressed={active}
|
||||
className={filterChipClass}
|
||||
>
|
||||
<span>{tag.name}</span>
|
||||
<span
|
||||
className={`tabular-nums text-3xs leading-none px-1 py-0.5 rounded-full ${
|
||||
active ? "bg-accent-fg/20 text-accent-fg" : "bg-muted/15 text-muted"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
layout,
|
||||
setLayout,
|
||||
@@ -75,6 +46,7 @@ export default function Sidebar({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isDemo = useMe().is_demo;
|
||||
const { openModal } = useSavedViews();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
|
||||
@@ -107,32 +79,36 @@ export default function Sidebar({
|
||||
const languages = tags.filter((t) => t.category === "language");
|
||||
const topics = tags.filter((t) => t.category === "topic");
|
||||
const userTags = tags.filter((t) => !t.system);
|
||||
const [customDates, setCustomDates] = useState(false);
|
||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives
|
||||
// the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||
const source: MediaSourceKind = "feed";
|
||||
|
||||
function toggleTag(id: number) {
|
||||
const has = filters.tags.includes(id);
|
||||
// The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns
|
||||
// only its own slice: its selection replaces the ids that belong to its list, leaving the others.
|
||||
const tagOption = (tg: Tag): ComboboxOption => ({
|
||||
value: String(tg.id),
|
||||
label: tg.name,
|
||||
count: chipCount(tg),
|
||||
});
|
||||
const selectedIn = (list: Tag[]): string[] =>
|
||||
filters.tags.filter((id) => list.some((tg) => tg.id === id)).map(String);
|
||||
function setTagSelection(list: Tag[], next: string[]) {
|
||||
const inList = new Set(list.map((tg) => tg.id));
|
||||
setFilters({
|
||||
...filters,
|
||||
tags: has ? filters.tags.filter((t) => t !== id) : [...filters.tags, id],
|
||||
tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)],
|
||||
});
|
||||
}
|
||||
// Each tag category (language/topic/other) has its OWN any/all match, set from its combobox footer.
|
||||
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
|
||||
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
|
||||
|
||||
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
|
||||
// disagree about what's applied.
|
||||
const activeCount = useActiveFilters(filters, setFilters).length;
|
||||
const active = activeCount > 0;
|
||||
|
||||
function clearAll() {
|
||||
setCustomDates(false);
|
||||
setFilters(clearedFilters(filters));
|
||||
}
|
||||
|
||||
const available: Record<string, boolean> = {
|
||||
savedviews: !isDemo,
|
||||
date: true,
|
||||
channel: true,
|
||||
language: languages.length > 0,
|
||||
@@ -142,155 +118,51 @@ export default function Sidebar({
|
||||
|
||||
function widgetBody(id: string): ReactNode {
|
||||
switch (id) {
|
||||
case "savedviews":
|
||||
return <SavedViewsWidget filters={filters} onApply={setFilters} />;
|
||||
case "channel":
|
||||
return <ChannelPicker filters={filters} setFilters={setFilters} />;
|
||||
case "date": {
|
||||
// Single-select across the presets + Custom; pure logic lives in lib/dateFilter.
|
||||
const dateKey = deriveDateKey(filters, customDates, DATE_PRESETS);
|
||||
case "date":
|
||||
return (
|
||||
<>
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("sidebar.datePresetLabel")}
|
||||
value={dateKey}
|
||||
options={[
|
||||
...DATE_PRESETS.map((p) => ({
|
||||
value: String(p.days),
|
||||
label: t("sidebar.datePreset." + p.key),
|
||||
})),
|
||||
{ value: "custom", label: t("sidebar.custom") },
|
||||
]}
|
||||
onChange={(key) => {
|
||||
const change = applyDateChip(key, filters, customDates);
|
||||
if (change.customDates !== undefined) setCustomDates(change.customDates);
|
||||
if (change.filters) setFilters(change.filters);
|
||||
}}
|
||||
/>
|
||||
|
||||
{(customDates || filters.dateFrom || filters.dateTo) && (
|
||||
<div className="flex flex-col gap-1.5 mt-2">
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted">{t("sidebar.from")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateFrom ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
dateFrom: e.target.value || undefined,
|
||||
maxAgeDays: undefined,
|
||||
})
|
||||
}
|
||||
className="bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted">{t("sidebar.to")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateTo ?? ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
dateTo: e.target.value || undefined,
|
||||
maxAgeDays: undefined,
|
||||
})
|
||||
}
|
||||
className="bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
</label>
|
||||
{(filters.dateFrom || filters.dateTo) && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })
|
||||
}
|
||||
className="text-2xs text-muted hover:text-accent self-end"
|
||||
>
|
||||
{t("sidebar.clearDates")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
|
||||
);
|
||||
}
|
||||
case "language": {
|
||||
const chips = visibleChips(languages);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{chips.map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
count={chipCount(tg)}
|
||||
active={filters.tags.includes(tg.id)}
|
||||
onClick={() => toggleTag(tg.id)}
|
||||
/>
|
||||
))}
|
||||
{facetsReady && chips.length === 0 && (
|
||||
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
|
||||
)}
|
||||
</div>
|
||||
<Combobox
|
||||
label={t("sidebar.widget.language")}
|
||||
options={visibleChips(languages).map(tagOption)}
|
||||
selected={selectedIn(languages)}
|
||||
onChange={(next) => setTagSelection(languages, next)}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
matchMode={filters.tagModes.language}
|
||||
onMatchModeChange={(m) => setTagMode("language", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "topic": {
|
||||
const chips = visibleChips(topics);
|
||||
return (
|
||||
<>
|
||||
<SegmentedToggle
|
||||
label={t("sidebar.match")}
|
||||
title={t("sidebar.tagModeTooltip")}
|
||||
ariaLabel={t("sidebar.match")}
|
||||
options={[
|
||||
{ value: "or", label: t("sidebar.any") },
|
||||
{ value: "and", label: t("sidebar.all") },
|
||||
]}
|
||||
value={filters.tagMode}
|
||||
onChange={(tagMode) => setFilters({ ...filters, tagMode })}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{chips.map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
count={chipCount(tg)}
|
||||
active={filters.tags.includes(tg.id)}
|
||||
onClick={() => toggleTag(tg.id)}
|
||||
/>
|
||||
))}
|
||||
{facetsReady && chips.length === 0 && (
|
||||
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<Combobox
|
||||
label={t("sidebar.widget.topic")}
|
||||
options={visibleChips(topics).map(tagOption)}
|
||||
selected={selectedIn(topics)}
|
||||
onChange={(next) => setTagSelection(topics, next)}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
matchMode={filters.tagModes.topic}
|
||||
onMatchModeChange={(m) => setTagMode("topic", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "tags": {
|
||||
const tagChips = visibleChips(userTags);
|
||||
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
|
||||
// body is just the picker.
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{tagChips.map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
count={chipCount(tg)}
|
||||
active={filters.tags.includes(tg.id)}
|
||||
onClick={() => toggleTag(tg.id)}
|
||||
/>
|
||||
))}
|
||||
{facetsReady && tagChips.length === 0 && (
|
||||
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
title={t("sidebar.manageTags")}
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
{t("sidebar.manageTags")}
|
||||
</button>
|
||||
</div>
|
||||
<Combobox
|
||||
label={t("sidebar.widget.tags")}
|
||||
options={visibleChips(userTags).map(tagOption)}
|
||||
selected={selectedIn(userTags)}
|
||||
onChange={(next) => setTagSelection(userTags, next)}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
matchMode={filters.tagModes.other}
|
||||
onMatchModeChange={(m) => setTagMode("other", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
@@ -302,19 +174,15 @@ export default function Sidebar({
|
||||
// the actual render order is the user's saved layout (PanelGroups orders by it).
|
||||
const items: PanelItem[] = defaultLayout(source)
|
||||
.order.filter((id) => available[id])
|
||||
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
|
||||
|
||||
const actions = (
|
||||
<>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
disabled={!active}
|
||||
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1 whitespace-nowrap"
|
||||
>
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
.map((id) => ({
|
||||
id,
|
||||
title: t("sidebar.widget." + id),
|
||||
body: widgetBody(id),
|
||||
// The widgets aren't collapsible, so a widget's title can act as its own trigger: "Your tags"
|
||||
// opens the tag manager (replacing the old inline Manage button).
|
||||
onTitleClick: id === "tags" ? () => setTagManagerOpen(true) : undefined,
|
||||
titleActionLabel: id === "tags" ? t("sidebar.manageTags") : undefined,
|
||||
}));
|
||||
|
||||
return (
|
||||
<FilterRail
|
||||
@@ -327,31 +195,17 @@ export default function Sidebar({
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
items={items}
|
||||
actions={actions}
|
||||
top={
|
||||
/* Scope: your own subscriptions (Mine) vs the shared Library. */
|
||||
<SegmentedToggle
|
||||
ariaLabel={t("header.scope.label")}
|
||||
fullWidth
|
||||
size="md"
|
||||
options={[
|
||||
{
|
||||
value: "my",
|
||||
label: t("header.scope.my"),
|
||||
icon: <User className="w-3.5 h-3.5" />,
|
||||
title: t("header.scope.myTip"),
|
||||
},
|
||||
{
|
||||
value: "all",
|
||||
label: t("header.scope.all"),
|
||||
icon: <Library className="w-3.5 h-3.5" />,
|
||||
title: t("header.scope.allTip"),
|
||||
},
|
||||
]}
|
||||
value={filters.scope}
|
||||
onChange={(scope) => setFilters({ ...filters, scope })}
|
||||
/>
|
||||
// The feed widgets are simple label + control rows, not collapsible islands.
|
||||
collapsible={false}
|
||||
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
|
||||
// full manager modal. Hidden for the demo account (no per-user views). Feed rail only.
|
||||
iconExpanded={
|
||||
!isDemo && source === "feed" ? (
|
||||
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
|
||||
) : undefined
|
||||
}
|
||||
onTitleClick={!isDemo && source === "feed" ? openModal : undefined}
|
||||
titleActionLabel={!isDemo && source === "feed" ? t("views.manageTitle") : undefined}
|
||||
>
|
||||
{tagManagerOpen && (
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The rendering integration of SortControl (its pickSort/heal decision logic is covered in
|
||||
// columnSort.test.ts). Labels are plain props here — no i18n needed.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
// columnSort.test.ts). Labels are plain props here — no i18n needed. It's a floating-ui popover now
|
||||
// (a labeled pill + a menu of `menuitemradio` sort keys with a footer direction segment), so we stub
|
||||
// the two autoUpdate observers and wrap in a FloatingTree; rows only exist while open → role queries
|
||||
// pass { hidden: true }.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import SortControl from "./SortControl";
|
||||
import type { SortOption, SortState } from "../lib/columnSort";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
const OPTS: SortOption[] = [
|
||||
{ key: "date", label: "Date", defaultDir: "desc" },
|
||||
{ key: "title", label: "Title" },
|
||||
@@ -16,67 +33,71 @@ const OPTS: SortOption[] = [
|
||||
function renderControl(sort: SortState, extra: Record<string, unknown> = {}) {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<SortControl
|
||||
options={OPTS}
|
||||
sort={sort}
|
||||
onChange={onChange}
|
||||
label="Sort"
|
||||
ascLabel="Ascending"
|
||||
descLabel="Descending"
|
||||
{...extra}
|
||||
/>,
|
||||
<FloatingTree>
|
||||
<SortControl
|
||||
options={OPTS}
|
||||
sort={sort}
|
||||
onChange={onChange}
|
||||
label="Sort"
|
||||
ascLabel="Ascending"
|
||||
descLabel="Descending"
|
||||
{...extra}
|
||||
/>
|
||||
</FloatingTree>,
|
||||
);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
const openPanel = () => fireEvent.click(screen.getByRole("button", { name: /^Sort/ }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("SortControl", () => {
|
||||
it("renders one option per SortOption, no empty option without defaultLabel", () => {
|
||||
it("renders one option per SortOption, no no-sort row without defaultLabel", () => {
|
||||
renderControl({ key: "title", dir: "asc" });
|
||||
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
|
||||
"Date",
|
||||
"Title",
|
||||
"Shuffle",
|
||||
]);
|
||||
openPanel();
|
||||
expect(
|
||||
screen.getAllByRole("menuitemradio", { hidden: true }).map((o) => o.textContent),
|
||||
).toEqual(["Date", "Title", "Shuffle"]);
|
||||
});
|
||||
|
||||
it("adds a leading no-sort option when defaultLabel is set (tables)", () => {
|
||||
renderControl(null, { defaultLabel: "None" });
|
||||
const none = screen.getByRole("option", { name: "None" }) as HTMLOptionElement;
|
||||
expect(none.value).toBe("");
|
||||
openPanel();
|
||||
expect(
|
||||
screen.getAllByRole("menuitemradio", { hidden: true }).map((o) => o.textContent),
|
||||
).toEqual(["None", "Date", "Title", "Shuffle"]);
|
||||
});
|
||||
|
||||
it("falls back to the first option (not a blank select) for an unavailable key without defaultLabel", () => {
|
||||
it("summarises the trigger with the first option (not blank) for an unavailable key without defaultLabel", () => {
|
||||
renderControl({ key: "ghost", dir: "asc" });
|
||||
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe("date");
|
||||
expect(screen.getByRole("button", { name: /^Sort/ }).textContent).toContain("Date");
|
||||
});
|
||||
|
||||
it("picking a new option fires onChange at that option's defaultDir", () => {
|
||||
const { onChange } = renderControl({ key: "title", dir: "asc" });
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: "date" } });
|
||||
openPanel();
|
||||
fireEvent.click(screen.getAllByRole("menuitemradio", { hidden: true })[0]!); // Date
|
||||
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "desc" });
|
||||
});
|
||||
|
||||
it("shows the direction toggle for a directional sort and flips it on click", () => {
|
||||
it("shows the direction segment for a directional sort and sets asc on click", () => {
|
||||
const { onChange } = renderControl({ key: "date", dir: "desc" });
|
||||
const toggle = screen.getByRole("button", { name: "Descending" });
|
||||
fireEvent.click(toggle);
|
||||
openPanel();
|
||||
fireEvent.click(screen.getByText("Ascending"));
|
||||
expect(onChange).toHaveBeenCalledWith({ key: "date", dir: "asc" });
|
||||
});
|
||||
|
||||
it("hides the direction toggle for a directionless sort", () => {
|
||||
it("hides the direction segment for a directionless sort", () => {
|
||||
renderControl({ key: "shuffle", dir: "desc" });
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
openPanel();
|
||||
expect(screen.queryByText("Ascending")).toBeNull();
|
||||
expect(screen.queryByText("Descending")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the trailing adornment", () => {
|
||||
renderControl(
|
||||
{ key: "shuffle", dir: "desc" },
|
||||
{
|
||||
trailing: <button>Reshuffle</button>,
|
||||
},
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Reshuffle" })).toBeTruthy();
|
||||
it("renders the trailing adornment in the footer", () => {
|
||||
renderControl({ key: "shuffle", dir: "desc" }, { trailing: <button>Reshuffle</button> });
|
||||
openPanel();
|
||||
expect(screen.getByText("Reshuffle")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ArrowDown, ArrowUp, Check, ChevronDown } from "lucide-react";
|
||||
import { pickSort, type SortOption, type SortState } from "../lib/columnSort";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// The one sort control for every content surface (YT feed, Plex library, channel manager + its card
|
||||
// view, channel discovery): a dropdown of `SortOption`s + an asc/desc toggle, driven by the shared
|
||||
// `SortState`. Presentation-agnostic to its container — a table feeds column-derived options via
|
||||
// `ColumnSortControl`, the feed/Plex declare their own vocabulary. `defaultLabel` opts a view into a
|
||||
// "no sort" choice (tables); omit it for always-sorted views (feed/Plex). `trailing` is an adornment
|
||||
// slot (the feed's reshuffle button) so the shuffle affordance stays out of the generic control.
|
||||
// view, channel discovery, playlists): a labeled dropdown pill matching FilterSelect — `Label: key ↓ ▾`
|
||||
// — that opens a listbox of `SortOption`s with an asc/desc segment in the panel footer. Two-dimensional
|
||||
// (key + direction) unlike FilterSelect, so it stays its own component. Driven by the shared `SortState`.
|
||||
// `defaultLabel` opts a view into a "no sort" choice (tables); omit it for always-sorted views
|
||||
// (feed/Plex). `trailing` is an adornment slot (the feed's reshuffle, playlists' dirty-first toggle)
|
||||
// rendered in the footer beside the direction segment.
|
||||
//
|
||||
// Direction lives in the footer, not on the trigger, on purpose: the trigger stays a single plain
|
||||
// <button> (the popover reference) with no nested button — picking a key keeps the panel open so key
|
||||
// AND direction can be set in one visit; picking "no sort" closes it (nothing left to direct).
|
||||
export default function SortControl({
|
||||
options,
|
||||
sort,
|
||||
@@ -21,55 +27,146 @@ export default function SortControl({
|
||||
options: SortOption[];
|
||||
sort: SortState;
|
||||
onChange: (next: SortState) => void;
|
||||
/** Accessible name for the dropdown (e.g. "Sort"). */
|
||||
/** Accessible name + visible prefix for the trigger (e.g. "Sort"). */
|
||||
label: string;
|
||||
ascLabel: string;
|
||||
descLabel: string;
|
||||
/** When set, a leading "no sort / original order" option is offered (tables). Omit for
|
||||
* always-sorted views (feed/Plex), which never surface a null sort. */
|
||||
defaultLabel?: string;
|
||||
/** Rendered after the direction toggle (e.g. the feed's reshuffle button). */
|
||||
/** Rendered in the panel footer beside the direction segment (feed reshuffle, playlists dirty-first). */
|
||||
trailing?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
// A stored sort can name an option this surface no longer offers; show it as unsorted rather than
|
||||
// letting an arrow claim a sort that `sortRows`/the server won't actually apply.
|
||||
const active = sort && options.some((o) => o.key === sort.key) ? sort : null;
|
||||
const activeOpt = active ? options.find((o) => o.key === active.key) : undefined;
|
||||
const showArrow = !!activeOpt && !activeOpt.directionless;
|
||||
// An always-sorted view (no `defaultLabel`) must still show a real option when the current key isn't
|
||||
// offered (a stale/out-of-scope sort) — fall back to the first option rather than a blank select
|
||||
// (which also trips React's controlled-value warning). Views with a "no sort" option keep it.
|
||||
const selectValue = active?.key ?? (defaultLabel === undefined ? (options[0]?.key ?? "") : "");
|
||||
// Trigger summary: the active option's label, or the "no sort" text, or (a stale/out-of-scope sort on
|
||||
// an always-sorted view) the first option's label rather than a blank pill.
|
||||
const summary = activeOpt?.label ?? defaultLabel ?? options[0]?.label ?? "";
|
||||
|
||||
const segCls = (on: boolean) =>
|
||||
`flex flex-1 items-center justify-center gap-1 rounded-md border px-2 py-1 text-2xs transition ${
|
||||
on
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={selectValue}
|
||||
onChange={(e) => onChange(pickSort(options, active, e.target.value))}
|
||||
aria-label={label}
|
||||
className="bg-surface border border-border rounded-lg px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// Carry the active sort key AND direction in the accessible name (the visible arrow is
|
||||
// aria-hidden), matching ViewSwitcher — a screen reader announces "Sort: Date, Descending",
|
||||
// not a bare "Sort".
|
||||
aria-label={`${label}: ${summary}${
|
||||
showArrow && active ? `, ${active.dir === "asc" ? ascLabel : descLabel}` : ""
|
||||
}`}
|
||||
className={`glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
|
||||
open ? "border-accent" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{defaultLabel !== undefined && <option value="">{defaultLabel}</option>}
|
||||
{options.map((o) => (
|
||||
<option key={o.key} value={o.key}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{showArrow && active && (
|
||||
<button
|
||||
onClick={() => onChange({ key: active.key, dir: active.dir === "asc" ? "desc" : "asc" })}
|
||||
title={active.dir === "asc" ? ascLabel : descLabel}
|
||||
aria-label={active.dir === "asc" ? ascLabel : descLabel}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
{active.dir === "asc" ? (
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
<span className="text-muted">{label}:</span>
|
||||
<span className="text-fg">{summary}</span>
|
||||
{showArrow &&
|
||||
active &&
|
||||
(active.dir === "asc" ? (
|
||||
<ArrowUp className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
) : (
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
<ArrowDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
))}
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{/* One exclusive choice of sort key (radios), scoped by a group so AT announces which is
|
||||
checked — the surface is a menu (see the mode above), never a listbox with orphaned options. */}
|
||||
<div role="group" aria-label={label}>
|
||||
{defaultLabel !== undefined && (
|
||||
<PopoverItem
|
||||
role="menuitemradio"
|
||||
checked={active === null}
|
||||
onSelect={() => {
|
||||
onChange(null);
|
||||
setOpen(false); // "no sort" is a final choice — nothing left to direct
|
||||
}}
|
||||
label={defaultLabel}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
active === null ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<Check
|
||||
className={`h-3.5 w-3.5 shrink-0 ${active === null ? "text-accent" : "opacity-0"}`}
|
||||
/>
|
||||
<span className="flex-1 truncate">{defaultLabel}</span>
|
||||
</PopoverItem>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{trailing}
|
||||
</div>
|
||||
{options.map((o) => {
|
||||
const on = active?.key === o.key;
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.key}
|
||||
role="menuitemradio"
|
||||
checked={on}
|
||||
onSelect={() => onChange(pickSort(options, active, o.key))} // keep open to set direction
|
||||
label={o.label}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
|
||||
<span className="flex-1 truncate">{o.label}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{((showArrow && active) || trailing) && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className="mt-1 flex items-center gap-1.5 border-t border-border px-1 pt-2"
|
||||
>
|
||||
{showArrow && active && (
|
||||
<div className="flex flex-1 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active.dir === "asc"}
|
||||
onClick={() => onChange({ key: active.key, dir: "asc" })}
|
||||
className={segCls(active.dir === "asc")}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" aria-hidden="true" />
|
||||
{ascLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active.dir === "desc"}
|
||||
onClick={() => onChange({ key: active.key, dir: "desc" })}
|
||||
className={segCls(active.dir === "desc")}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" aria-hidden="true" />
|
||||
{descLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{trailing}
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,12 +65,14 @@ export default function ViewSwitcher<T extends string>({
|
||||
// nicety for wide windows, not the accessible name.
|
||||
aria-label={`${label}: ${current.label}`}
|
||||
title={`${label}: ${current.label}`}
|
||||
className="shrink-0 inline-flex items-center gap-1 pl-2 pr-1 py-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
className={`shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm transition ${
|
||||
open ? "border-accent" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<CurrentIcon className="w-4 h-4 shrink-0" />
|
||||
<span className={clsx("text-sm whitespace-nowrap", labelClassName)}>{current.label}</span>
|
||||
<span className={clsx("whitespace-nowrap", labelClassName)}>{current.label}</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
className={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<PopoverSurface
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useFeedView, useFeedViewActions } from "./FeedViewProvider";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import Sidebar from "./Sidebar";
|
||||
import PlaylistsRail from "./PlaylistsRail";
|
||||
import PersonalViewsModal from "./PersonalViewsModal";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
|
||||
// The RENDER half of the module registry: which component (and side rail) each page renders. The
|
||||
// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar /
|
||||
@@ -45,18 +47,25 @@ function FeedPage() {
|
||||
const { setView } = useFeedViewActions();
|
||||
const { ytSearch } = useNavigation();
|
||||
const { enterYtSearch, exitYtSearch } = useNavigationActions();
|
||||
// The Personal Views modal lives at the feed-page level (always mounted while on the feed, unmounts
|
||||
// on navigation) rather than inside the collapsible filter rail — otherwise opening it from the
|
||||
// always-visible toolbar view chip while the rail is unpinned would render nothing and latch open.
|
||||
const { modalOpen } = useSavedViews();
|
||||
return (
|
||||
<Feed
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
view={view}
|
||||
setView={setView}
|
||||
canRead={me.can_read}
|
||||
isDemo={me.is_demo}
|
||||
ytSearch={ytSearch}
|
||||
onYtSearch={enterYtSearch}
|
||||
onExitYtSearch={exitYtSearch}
|
||||
/>
|
||||
<>
|
||||
<Feed
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
view={view}
|
||||
setView={setView}
|
||||
canRead={me.can_read}
|
||||
isDemo={me.is_demo}
|
||||
ytSearch={ytSearch}
|
||||
onYtSearch={enterYtSearch}
|
||||
onExitYtSearch={exitYtSearch}
|
||||
/>
|
||||
{modalOpen && !me.is_demo && <PersonalViewsModal />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,21 +84,25 @@ export type PageDef = {
|
||||
// Channels + Audit render a table with a sticky header at the scroller top; the shell's top fade
|
||||
// would sit on it and flip on with the first scroll, so they turn it off. Defaults to on.
|
||||
fadeTop?: boolean;
|
||||
// The content column's max width. The header + toolbar centre on this same width (via --content-mw)
|
||||
// so the floating chrome lines up with the centred content instead of spanning the whole column.
|
||||
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
|
||||
maxWidth?: string;
|
||||
};
|
||||
|
||||
// page id → the component App renders in the content column (+ its shell chrome config).
|
||||
export const PAGE_CONTENT: Record<Page, PageDef> = {
|
||||
feed: { Component: FeedPage },
|
||||
channels: { Component: Channels, fadeTop: false },
|
||||
playlists: { Component: Playlists },
|
||||
plex: { Component: PlexBrowse },
|
||||
notifications: { Component: NotificationsPanel },
|
||||
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
|
||||
playlists: { Component: Playlists, maxWidth: "64rem" },
|
||||
plex: { Component: PlexBrowse, maxWidth: "1600px" },
|
||||
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
||||
messages: { Component: MessagesPage },
|
||||
downloads: { Component: DownloadCenter },
|
||||
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
|
||||
stats: { Component: Stats },
|
||||
scheduler: { Component: Scheduler },
|
||||
config: { Component: ConfigPanel },
|
||||
users: { Component: AdminUsers },
|
||||
users: { Component: AdminUsers, maxWidth: "48rem" },
|
||||
audit: { Component: AuditLog, fadeTop: false },
|
||||
settings: { Component: SettingsPanel },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import {
|
||||
api,
|
||||
HttpError,
|
||||
isIntrinsicReadOnly,
|
||||
type PlexCard,
|
||||
type PlexCollection,
|
||||
} from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import PosterTile from "./PosterTile";
|
||||
|
||||
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
||||
// collections have no movie runtime).
|
||||
function collectionMeta(c: PlexCollection, t: TFunction): string {
|
||||
const parts: string[] = [];
|
||||
if (c.child_count != null) parts.push(t("plex.browse.items", { count: c.child_count }));
|
||||
const rt = formatRuntime(c.total_runtime_s);
|
||||
if (rt) parts.push(rt);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles,
|
||||
// each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the
|
||||
// title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections
|
||||
// union), so this is a pure front-end surface. Collection tiles show a poster collage of their top
|
||||
// films (PosterTile), falling back to a title-hash colour; playlists have no server art yet, so they
|
||||
// take the colour fallback.
|
||||
|
||||
const GRID = "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6";
|
||||
|
||||
// A grid of the user's Plex playlists; a tile opens that playlist's view.
|
||||
export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
||||
const playlists = q.data?.playlists ?? [];
|
||||
|
||||
if (q.isError && !q.data) {
|
||||
return (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
);
|
||||
}
|
||||
if (q.isLoading) return <LoadingState label={t("plex.loading")} />;
|
||||
if (playlists.length === 0)
|
||||
return <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
|
||||
|
||||
return (
|
||||
<div className={GRID}>
|
||||
{playlists.map((p) => (
|
||||
<PosterTile
|
||||
key={p.id}
|
||||
title={p.title}
|
||||
subtitle={t("plex.browse.items", { count: p.item_count })}
|
||||
topRight={<ListMusic className="h-4 w-4 text-white/80 drop-shadow" />}
|
||||
onClick={() => onOpen(p.id)}
|
||||
className="h-28"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The "New collection" panel (admin, ask B-route): name (rejected if it duplicates an existing one —
|
||||
// Plex conflates same-named collections) + a search to pick the FIRST title, since Plex can't create an
|
||||
// empty collection. Creating opens the new collection's detail page (onCreated → onPick).
|
||||
function NewCollectionPanel({
|
||||
existingNames,
|
||||
onCreated,
|
||||
onClose,
|
||||
}: {
|
||||
existingNames: Set<string>;
|
||||
onCreated: (rk: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [term, setTerm] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const debounced = useDebounced(term.trim(), 300);
|
||||
const trimmed = name.trim();
|
||||
// Advisory only: `existingNames` is the search-scoped, all-library collection union, whereas the
|
||||
// backend enforces uniqueness PER library (Plex only conflates same-named collections within one
|
||||
// section). So a match here is a hint, not a hard block — it must not gate creation, or a valid
|
||||
// same-name collection in another library would be un-creatable. The backend's 409 is authoritative.
|
||||
const dupHint = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase());
|
||||
const nameOk = trimmed.length > 0;
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexLibrary("collection-new", debounced),
|
||||
queryFn: () => api.plexLibrary({ scope: "both", q: debounced, limit: 12 }),
|
||||
enabled: nameOk && debounced.length > 0,
|
||||
});
|
||||
const results = q.data?.items ?? [];
|
||||
|
||||
async function create(card: PlexCard) {
|
||||
if (!nameOk || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
|
||||
onCreated(col.id);
|
||||
} catch (e) {
|
||||
// The backend is authoritative on per-library name uniqueness — localize its 409 to nameTaken
|
||||
// (notifyActionError would otherwise surface the server's raw English detail).
|
||||
if (e instanceof HttpError && e.status === 409) {
|
||||
notify({ level: "error", message: t("plex.collection.nameTaken") });
|
||||
} else {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card mb-6 rounded-2xl p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={t("common.close")}
|
||||
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("plex.collection.namePlaceholder")}
|
||||
aria-label={t("plex.collection.newCollection")}
|
||||
className="glass-card w-full rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
{dupHint ? (
|
||||
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.nameMaybeTaken")}</p>
|
||||
) : (
|
||||
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.newHint")}</p>
|
||||
)}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted" />
|
||||
<input
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
disabled={!nameOk}
|
||||
placeholder={t("plex.collection.addSearch")}
|
||||
aria-label={t("plex.collection.addSearch")}
|
||||
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
{!nameOk || debounced.length === 0 ? null : q.isLoading ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : q.isError ? (
|
||||
<p className="text-muted text-sm">{t("common.loadError")}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{results.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-3">
|
||||
<img
|
||||
src={c.thumb}
|
||||
alt=""
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{c.title}
|
||||
{c.year != null && <span className="text-muted"> · {c.year}</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => create(c)}
|
||||
disabled={busy}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("plex.collection.createWith")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A grid of Plex collections (searchable — the union can run to hundreds); a tile opens its info page.
|
||||
export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||
const [search, setSearch] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const searchDeb = useDebounced(search.trim(), 300);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexCollections("union", searchDeb),
|
||||
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
|
||||
});
|
||||
const collections = useMemo(() => q.data?.collections ?? [], [q.data?.collections]);
|
||||
const existingNames = useMemo(
|
||||
() => new Set(collections.map((c) => c.title.trim().toLowerCase())),
|
||||
[collections],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
aria-label={t("plex.filter.collectionSearch")}
|
||||
className="glass-card w-full max-w-sm rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
{isAdmin && !creating && (
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("plex.collection.newCollection")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{creating && (
|
||||
<NewCollectionPanel
|
||||
existingNames={existingNames}
|
||||
onCreated={(rk) => {
|
||||
setCreating(false);
|
||||
onPick(rk, "");
|
||||
}}
|
||||
onClose={() => setCreating(false)}
|
||||
/>
|
||||
)}
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : collections.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
|
||||
) : (
|
||||
<div className={GRID}>
|
||||
{collections.map((c) => (
|
||||
<PosterTile
|
||||
key={c.id}
|
||||
title={c.title}
|
||||
art={c.art}
|
||||
subtitle={collectionMeta(c, t)}
|
||||
topRight={
|
||||
isIntrinsicReadOnly(c) ? (
|
||||
<Lock
|
||||
className="h-3.5 w-3.5 text-white/80 drop-shadow"
|
||||
aria-label={t("plex.collection.readOnly")}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => onPick(c.id, c.title)}
|
||||
className="h-28"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,14 @@ import { PLEX_SUB_URL as U, type Sub } from "./plexSubUrl";
|
||||
describe("PLEX_SUB_URL encode", () => {
|
||||
it("mirrors each bookmarkable sub-view to ?v=<kind>:<args>", () => {
|
||||
expect(U.encode({ kind: "grid" })).toBe("browse"); // the flat "Browse all" grid (E5 S4)
|
||||
expect(U.encode({ kind: "playlists" })).toBe("playlists"); // browse-all-playlists (Piece 2)
|
||||
expect(U.encode({ kind: "collections" })).toBe("collections"); // browse-all-collections (Piece 2)
|
||||
expect(U.encode({ kind: "show", id: "s1" })).toBe("show:s1");
|
||||
expect(U.encode({ kind: "season", showId: "s1", seasonId: "se2" })).toBe("season:s1:se2");
|
||||
expect(U.encode({ kind: "info", id: "m9" })).toBe("info:m9");
|
||||
expect(U.encode({ kind: "person", name: "Jane Doe" })).toBe("person:Jane Doe");
|
||||
expect(U.encode({ kind: "playlist", id: 12 })).toBe("playlist:12");
|
||||
expect(U.encode({ kind: "collection", id: "c5" })).toBe("collection:c5");
|
||||
});
|
||||
|
||||
it("does NOT mirror the root Discover landing or the ephemeral player (encode → null)", () => {
|
||||
@@ -22,11 +25,14 @@ describe("PLEX_SUB_URL encode", () => {
|
||||
describe("PLEX_SUB_URL round-trip", () => {
|
||||
const cases: Sub[] = [
|
||||
{ kind: "grid" },
|
||||
{ kind: "playlists" },
|
||||
{ kind: "collections" },
|
||||
{ kind: "show", id: "s1" },
|
||||
{ kind: "season", showId: "s1", seasonId: "se2" },
|
||||
{ kind: "info", id: "m9" },
|
||||
{ kind: "person", name: "Jane Doe" },
|
||||
{ kind: "playlist", id: 12 },
|
||||
{ kind: "collection", id: "c5" },
|
||||
];
|
||||
it.each(cases)("encode→decode is identity for %o", (view) => {
|
||||
const raw = U.encode(view);
|
||||
@@ -56,10 +62,11 @@ describe("PLEX_SUB_URL decode guards", () => {
|
||||
expect(U.decode("season:s1:se2")).toEqual({ kind: "season", showId: "s1", seasonId: "se2" });
|
||||
});
|
||||
|
||||
it("requires a non-empty arg for show / info / person", () => {
|
||||
it("requires a non-empty arg for show / info / person / collection", () => {
|
||||
expect(U.decode("show:")).toBeNull();
|
||||
expect(U.decode("info:")).toBeNull();
|
||||
expect(U.decode("person:")).toBeNull();
|
||||
expect(U.decode("collection:")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an unknown kind or garbage", () => {
|
||||
|
||||
@@ -7,12 +7,15 @@ import { parsePositiveIntId } from "../lib/parseId";
|
||||
export type Sub =
|
||||
| { kind: "discover" }
|
||||
| { kind: "grid" }
|
||||
| { kind: "playlists" } // browse-all-playlists surface (header pill "Playlist" mode)
|
||||
| { kind: "collections" } // browse-all-collections surface (header pill "Collection" mode)
|
||||
| { kind: "show"; id: string }
|
||||
| { kind: "season"; showId: string; seasonId: string }
|
||||
| { kind: "player"; id: string; queue?: string[] }
|
||||
| { kind: "info"; id: string }
|
||||
| { kind: "person"; name: string }
|
||||
| { kind: "playlist"; id: number };
|
||||
| { kind: "playlist"; id: number }
|
||||
| { kind: "collection"; id: string }; // the Collection info page (id = collection rating_key)
|
||||
|
||||
// Mirror the Plex sub-view to ?v=<kind>:<args> so a show / season / movie-info / person / playlist page
|
||||
// — and the flat browse grid — are bookmarkable. Discover is the ROOT landing (E5 S4), so it (and the
|
||||
@@ -25,6 +28,10 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
|
||||
switch (v.kind) {
|
||||
case "grid":
|
||||
return "browse";
|
||||
case "playlists":
|
||||
return "playlists";
|
||||
case "collections":
|
||||
return "collections";
|
||||
case "show":
|
||||
return `show:${v.id}`;
|
||||
case "season":
|
||||
@@ -35,6 +42,8 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
|
||||
return `person:${v.name}`;
|
||||
case "playlist":
|
||||
return `playlist:${v.id}`;
|
||||
case "collection":
|
||||
return `collection:${v.id}`;
|
||||
default:
|
||||
return null; // discover (root) + the ephemeral player are not URL-mirrored
|
||||
}
|
||||
@@ -46,6 +55,10 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
|
||||
switch (kind) {
|
||||
case "browse":
|
||||
return { kind: "grid" };
|
||||
case "playlists":
|
||||
return { kind: "playlists" };
|
||||
case "collections":
|
||||
return { kind: "collections" };
|
||||
case "show":
|
||||
return arg ? { kind: "show", id: arg } : null;
|
||||
case "info":
|
||||
@@ -58,6 +71,8 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
|
||||
const id = parsePositiveIntId(arg);
|
||||
return id != null ? { kind: "playlist", id } : null;
|
||||
}
|
||||
case "collection":
|
||||
return arg ? { kind: "collection", id: arg } : null;
|
||||
case "season": {
|
||||
const [showId, seasonId] = arg.split(":");
|
||||
return showId && seasonId ? { kind: "season", showId, seasonId } : null;
|
||||
|
||||
@@ -18,7 +18,12 @@ const plexState = {
|
||||
show: "watched",
|
||||
// A non-default sort is present in the shared state — the hook must IGNORE it (no sort chip).
|
||||
sort: { key: "year", dir: "asc" },
|
||||
filters: { ...EMPTY_PLEX_FILTERS, genres: ["Action"] },
|
||||
filters: {
|
||||
...EMPTY_PLEX_FILTERS,
|
||||
genres: ["Action"],
|
||||
collections: ["A"],
|
||||
collectionTitles: { A: "Marvel" },
|
||||
},
|
||||
};
|
||||
const setShow = vi.fn();
|
||||
const setFilters = vi.fn();
|
||||
@@ -40,4 +45,21 @@ describe("usePlexActiveFilters", () => {
|
||||
expect(keys).not.toContain("sort");
|
||||
expect(result.current.every((c) => !c.key.startsWith("sort"))).toBe(true);
|
||||
});
|
||||
|
||||
it("removing the last collection chip prunes its title and collapses collectionTitles to undefined", () => {
|
||||
setFilters.mockClear();
|
||||
const { result } = renderHook(() => usePlexActiveFilters());
|
||||
const chip = result.current.find((c) => c.key === "collection:A");
|
||||
expect(chip).toBeDefined();
|
||||
chip!.remove();
|
||||
// patch() merges into the full filters object; assert the two touched fields directly. No residual
|
||||
// { A: "Marvel" } and no empty {} (which would still split the query-key cache from the pristine
|
||||
// no-collection state) — collectionTitles must be undefined.
|
||||
const arg = setFilters.mock.calls[0]![0] as {
|
||||
collections: string[];
|
||||
collectionTitles?: unknown;
|
||||
};
|
||||
expect(arg.collections).toEqual([]);
|
||||
expect(arg.collectionTitles).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,11 +12,26 @@ import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
// removing one keeps the rest (same shape as the feed's tag/channel chips). Sort is NOT a chip here —
|
||||
// it's an always-visible control above the grid (S3), like the feed. Lives here, not in lib, because
|
||||
// it reads the Plex provider (lib stays provider-free, like useActiveFilters).
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export function usePlexActiveFilters(): ActiveFilter[] {
|
||||
const { show, filters } = usePlex();
|
||||
const { setShow, setFilters } = usePlexActions();
|
||||
const { t } = useTranslation();
|
||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||
return buildPlexActiveFilters(filters, show, patch, setShow, t);
|
||||
}
|
||||
|
||||
// The pure builder behind the hook: turns a Plex filter set + watch-state into the source-agnostic
|
||||
// ActiveFilter[]. Extracted so the saved-views manager can DESCRIBE an arbitrary view's captured
|
||||
// filters (with no-op removes) — the labels stay in one place, no duplication with the live chip row.
|
||||
export function buildPlexActiveFilters(
|
||||
filters: PlexFilters,
|
||||
show: string,
|
||||
patch: (p: Partial<PlexFilters>) => void,
|
||||
setShow: (s: string) => void,
|
||||
t: TFn,
|
||||
): ActiveFilter[] {
|
||||
const out: ActiveFilter[] = [];
|
||||
|
||||
const has = (x: number | null | undefined) => x != null;
|
||||
@@ -102,11 +117,21 @@ export function usePlexActiveFilters(): ActiveFilter[] {
|
||||
remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }),
|
||||
});
|
||||
}
|
||||
if (filters.collection) {
|
||||
for (const c of filters.collections) {
|
||||
out.push({
|
||||
key: "collection",
|
||||
label: filters.collectionTitle ?? t("plex.filter.collection"),
|
||||
remove: () => patch({ collection: null, collectionTitle: null }),
|
||||
key: `collection:${c}`,
|
||||
label: filters.collectionTitles?.[c] ?? t("plex.filter.collection"),
|
||||
// Prune the title too — collectionTitles is hashed into the plex query keys, so a stale entry
|
||||
// splits the cache from the logically-identical no-collection state (mirrors CollectionPicker).
|
||||
// Collapse an empty map back to undefined so the last-removed state key-matches the pristine
|
||||
// one (an empty `{}` still hashes differently from an absent key).
|
||||
remove: () => {
|
||||
const { [c]: _drop, ...restTitles } = filters.collectionTitles ?? {};
|
||||
patch({
|
||||
collections: filters.collections.filter((x) => x !== c),
|
||||
collectionTitles: Object.keys(restTitles).length ? restTitles : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
if (show !== "all") {
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"loading": "Loading…",
|
||||
"language": "Language",
|
||||
"somethingWrong": "Something went wrong.",
|
||||
"itemCount_one": "{{formattedCount}} item",
|
||||
"itemCount_other": "{{formattedCount}} items",
|
||||
"accessRequestsTitle": "Access requests",
|
||||
"accessRequestsMessage": "{{count}} pending — review them on the Users page.",
|
||||
"accessRequestsReview": "Review",
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"label": "Filtered by",
|
||||
"remove": "Remove filter: {{name}}",
|
||||
"contentNone": "No content types",
|
||||
"count_one": "{{count}} filter",
|
||||
"count_other": "{{count}} filters",
|
||||
"moreCount_one": "{{count}} more filter",
|
||||
"moreCount_other": "{{count}} more filters",
|
||||
"maxAge_one": "Last {{count}} day",
|
||||
"maxAge_other": "Last {{count}} days"
|
||||
},
|
||||
@@ -16,6 +20,8 @@
|
||||
"demoEmptyBody": "The demo account has no subscriptions of its own — but the whole shared library is yours to explore, filter and sort.",
|
||||
"browseLibrary": "Browse the library",
|
||||
"noMatches": "No videos match these filters.",
|
||||
"channelScopeHint": "This channel isn't in your subscriptions feed — it may be hidden. Its videos are in the shared library.",
|
||||
"viewInLibrary": "View in Library",
|
||||
"videoCount_one": "{{formattedCount}} video",
|
||||
"videoCount_other": "{{formattedCount}} videos",
|
||||
"sortLabel": "Sort",
|
||||
|
||||
@@ -2,6 +2,33 @@
|
||||
"navLabel": "Plex",
|
||||
"backToFeed": "YouTube feed",
|
||||
"backToLibrary": "Back to library",
|
||||
"collection": {
|
||||
"playAll": "Play all",
|
||||
"shuffle": "Shuffle",
|
||||
"rename": "Rename",
|
||||
"addItem": "Add title",
|
||||
"delete": "Delete",
|
||||
"enableEdit": "Enable editing",
|
||||
"removeItem": "Remove from collection",
|
||||
"removeConfirm": "Remove \"{{title}}\" from this collection? This affects all users on Plex.",
|
||||
"nameMaybeTaken": "A collection with this name may already exist in another library — you can still create it here.",
|
||||
"readOnly": "Read-only",
|
||||
"empty": "This collection has no items.",
|
||||
"more": "More",
|
||||
"less": "Less",
|
||||
"addSearch": "Search titles to add…",
|
||||
"addHint": "Type to search for a title to add.",
|
||||
"addNoResults": "No matches.",
|
||||
"add": "Add",
|
||||
"added": "Added",
|
||||
"deleteConfirm": "Delete the collection \"{{title}}\"? This removes it from Plex too.",
|
||||
"editFailed": "Collection edit failed",
|
||||
"newCollection": "New collection",
|
||||
"namePlaceholder": "Collection name",
|
||||
"nameTaken": "A collection with this name already exists.",
|
||||
"newHint": "Pick the first title to add — a collection needs at least one.",
|
||||
"createWith": "Create"
|
||||
},
|
||||
"searchPlaceholder": "Search Plex…",
|
||||
"searchField": {
|
||||
"aria": "Search scope",
|
||||
@@ -27,7 +54,9 @@
|
||||
"scopeOpt": {
|
||||
"both": "All",
|
||||
"movie": "Movies",
|
||||
"show": "Shows"
|
||||
"show": "Shows",
|
||||
"playlists": "Playlists",
|
||||
"collections": "Collections"
|
||||
},
|
||||
"show": "Show",
|
||||
"showOpt": {
|
||||
@@ -243,6 +272,8 @@
|
||||
"playlist": {
|
||||
"section": "Playlists",
|
||||
"newPlaceholder": "New playlist…",
|
||||
"pickerPlaceholder": "Find or create a playlist…",
|
||||
"createNamed": "Create \"{{name}}\"",
|
||||
"create": "Create playlist",
|
||||
"none": "No playlists yet.",
|
||||
"deleteConfirm": "Delete the playlist \"{{title}}\"?",
|
||||
@@ -282,9 +313,13 @@
|
||||
"label": "View",
|
||||
"browse": "Browse"
|
||||
},
|
||||
"browse": {
|
||||
"items": "{{count}} items"
|
||||
},
|
||||
"discover": {
|
||||
"tab": "Discover",
|
||||
"continueWatching": "Continue watching",
|
||||
"recentlyWatched": "Recently watched",
|
||||
"recentlyAdded": "Recently added",
|
||||
"surprise": "Surprise me",
|
||||
"reshuffle": "Reshuffle",
|
||||
|
||||
@@ -67,9 +67,19 @@
|
||||
"datePreset": {
|
||||
"24h": "24h",
|
||||
"1week": "1 week",
|
||||
"2weeks": "2 weeks",
|
||||
"1month": "1 month",
|
||||
"3months": "3 months",
|
||||
"6months": "6 months",
|
||||
"1year": "1 year"
|
||||
"1year": "1 year",
|
||||
"2years": "2 years",
|
||||
"5years": "5 years"
|
||||
},
|
||||
"date": {
|
||||
"anyTime": "Any time",
|
||||
"customRange": "Custom range",
|
||||
"fromShort": "From {{date}}",
|
||||
"untilShort": "Until {{date}}"
|
||||
},
|
||||
"channelSearch": "Search channels…",
|
||||
"noChannelMatch": "No channel matches.",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"namePlaceholder": "View name",
|
||||
"saveAction": "Save",
|
||||
"cancel": "Cancel",
|
||||
"empty": "No saved views yet. Filter the feed, then save it.",
|
||||
"empty": "No saved views yet. Apply some filters, then save them.",
|
||||
"apply": "Apply this view",
|
||||
"makeDefault": "Set as default (loads on start)",
|
||||
"isDefault": "Default view — loads on start",
|
||||
@@ -12,5 +12,13 @@
|
||||
"deleteConfirm": "Delete the saved view “{{name}}”?",
|
||||
"reorder": "Drag to reorder",
|
||||
"actionFailed": "Couldn't update your saved views — please try again.",
|
||||
"reorderFailed": "Couldn't save the new order — please try again."
|
||||
"reorderFailed": "Couldn't save the new order — please try again.",
|
||||
"saved": "Saved views",
|
||||
"manage": "Manage views…",
|
||||
"manageTitle": "Personal views",
|
||||
"capturedFilters": "Captured filters",
|
||||
"capturedNone": "No filters — matches every video.",
|
||||
"default": "Default",
|
||||
"selectPrompt": "Select a view to see and edit it.",
|
||||
"modified": "Modified — the current filters differ from this saved view."
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"loading": "Betöltés…",
|
||||
"language": "Nyelv",
|
||||
"somethingWrong": "Hiba történt.",
|
||||
"itemCount_one": "{{formattedCount}} elem",
|
||||
"itemCount_other": "{{formattedCount}} elem",
|
||||
"accessRequestsTitle": "Hozzáférési kérések",
|
||||
"accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.",
|
||||
"accessRequestsReview": "Áttekintés",
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"label": "Szűrve",
|
||||
"remove": "Szűrő eltávolítása: {{name}}",
|
||||
"contentNone": "Nincs tartalomtípus",
|
||||
"count_one": "{{count}} szűrő",
|
||||
"count_other": "{{count}} szűrő",
|
||||
"moreCount_one": "még {{count}} szűrő",
|
||||
"moreCount_other": "még {{count}} szűrő",
|
||||
"maxAge_one": "Utolsó {{count}} nap",
|
||||
"maxAge_other": "Utolsó {{count}} nap"
|
||||
},
|
||||
@@ -16,6 +20,8 @@
|
||||
"demoEmptyBody": "A demo fióknak nincsenek saját feliratkozásai — de a teljes közös könyvtár a tiéd: böngészd, szűrd és rendezd kedvedre.",
|
||||
"browseLibrary": "Könyvtár böngészése",
|
||||
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
|
||||
"channelScopeHint": "Ez a csatorna nincs a feliratkozásaid feedjében – lehet, hogy el van rejtve. A videói a közös könyvtárban vannak.",
|
||||
"viewInLibrary": "Megnézés a Könyvtárban",
|
||||
"videoCount_one": "{{formattedCount}} videó",
|
||||
"videoCount_other": "{{formattedCount}} videó",
|
||||
"sortLabel": "Rendezés",
|
||||
|
||||
@@ -2,6 +2,33 @@
|
||||
"navLabel": "Plex",
|
||||
"backToFeed": "YouTube feed",
|
||||
"backToLibrary": "Vissza a könyvtárhoz",
|
||||
"collection": {
|
||||
"playAll": "Összes lejátszása",
|
||||
"shuffle": "Keverés",
|
||||
"rename": "Átnevezés",
|
||||
"addItem": "Cím hozzáadása",
|
||||
"delete": "Törlés",
|
||||
"enableEdit": "Szerkesztés engedélyezése",
|
||||
"removeItem": "Eltávolítás a kollekcióból",
|
||||
"removeConfirm": "Eltávolítod a(z) „{{title}}” elemet ebből a kollekcióból? Ez a Plexen minden felhasználót érint.",
|
||||
"nameMaybeTaken": "Lehet, hogy már létezik ilyen nevű kollekció egy másik könyvtárban — itt akkor is létrehozhatod.",
|
||||
"readOnly": "Csak olvasható",
|
||||
"empty": "Ennek a kollekciónak nincsenek elemei.",
|
||||
"more": "Több",
|
||||
"less": "Kevesebb",
|
||||
"addSearch": "Keress címet a hozzáadáshoz…",
|
||||
"addHint": "Gépelj a hozzáadni kívánt cím kereséséhez.",
|
||||
"addNoResults": "Nincs találat.",
|
||||
"add": "Hozzáad",
|
||||
"added": "Hozzáadva",
|
||||
"deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót? Ez a Plexből is eltávolítja.",
|
||||
"editFailed": "A kollekció szerkesztése sikertelen",
|
||||
"newCollection": "Új kollekció",
|
||||
"namePlaceholder": "Kollekció neve",
|
||||
"nameTaken": "Már létezik ilyen nevű kollekció.",
|
||||
"newHint": "Válaszd ki az első címet — egy kollekcióhoz legalább egy elem kell.",
|
||||
"createWith": "Létrehozás"
|
||||
},
|
||||
"searchPlaceholder": "Keresés a Plexben…",
|
||||
"searchField": {
|
||||
"aria": "Keresési hatókör",
|
||||
@@ -27,7 +54,9 @@
|
||||
"scopeOpt": {
|
||||
"both": "Mind",
|
||||
"movie": "Filmek",
|
||||
"show": "Sorozatok"
|
||||
"show": "Sorozatok",
|
||||
"playlists": "Listák",
|
||||
"collections": "Gyűjtemények"
|
||||
},
|
||||
"show": "Megjelenítés",
|
||||
"showOpt": {
|
||||
@@ -243,6 +272,8 @@
|
||||
"playlist": {
|
||||
"section": "Lejátszási listák",
|
||||
"newPlaceholder": "Új lista…",
|
||||
"pickerPlaceholder": "Lista keresése vagy létrehozása…",
|
||||
"createNamed": "„{{name}}” létrehozása",
|
||||
"create": "Lista létrehozása",
|
||||
"none": "Még nincs lista.",
|
||||
"deleteConfirm": "Törlöd a(z) „{{title}}” listát?",
|
||||
@@ -282,9 +313,13 @@
|
||||
"label": "Nézet",
|
||||
"browse": "Böngészés"
|
||||
},
|
||||
"browse": {
|
||||
"items": "{{count}} elem"
|
||||
},
|
||||
"discover": {
|
||||
"tab": "Felfedezés",
|
||||
"continueWatching": "Folytatás",
|
||||
"recentlyWatched": "Nemrég megnézve",
|
||||
"recentlyAdded": "Nemrég hozzáadva",
|
||||
"surprise": "Lepj meg",
|
||||
"reshuffle": "Újrakeverés",
|
||||
|
||||
@@ -67,9 +67,19 @@
|
||||
"datePreset": {
|
||||
"24h": "24 óra",
|
||||
"1week": "1 hét",
|
||||
"2weeks": "2 hét",
|
||||
"1month": "1 hónap",
|
||||
"3months": "3 hónap",
|
||||
"6months": "6 hónap",
|
||||
"1year": "1 év"
|
||||
"1year": "1 év",
|
||||
"2years": "2 év",
|
||||
"5years": "5 év"
|
||||
},
|
||||
"date": {
|
||||
"anyTime": "Bármikor",
|
||||
"customRange": "Egyéni tartomány",
|
||||
"fromShort": "{{date}}-től",
|
||||
"untilShort": "{{date}}-ig"
|
||||
},
|
||||
"channelSearch": "Csatornák keresése…",
|
||||
"noChannelMatch": "Nincs találat.",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"namePlaceholder": "Nézet neve",
|
||||
"saveAction": "Mentés",
|
||||
"cancel": "Mégse",
|
||||
"empty": "Még nincs mentett nézet. Szűrd a feedet, majd mentsd el.",
|
||||
"empty": "Még nincs mentett nézet. Állíts be szűrőket, majd mentsd el.",
|
||||
"apply": "Nézet alkalmazása",
|
||||
"makeDefault": "Beállítás alapértelmezettként (indításkor betölt)",
|
||||
"isDefault": "Alapértelmezett nézet — indításkor betölt",
|
||||
@@ -12,5 +12,13 @@
|
||||
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
|
||||
"reorder": "Húzd az átrendezéshez",
|
||||
"actionFailed": "Nem sikerült frissíteni a mentett nézeteket — próbáld újra.",
|
||||
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra."
|
||||
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra.",
|
||||
"saved": "Mentett nézetek",
|
||||
"manage": "Nézetek kezelése…",
|
||||
"manageTitle": "Személyes nézetek",
|
||||
"capturedFilters": "Rögzített szűrők",
|
||||
"capturedNone": "Nincs szűrő — minden videóra illeszkedik.",
|
||||
"default": "Alapértelmezett",
|
||||
"selectPrompt": "Válassz egy nézetet a megtekintéshez és szerkesztéshez.",
|
||||
"modified": "Módosítva — a jelenlegi szűrők eltérnek a mentett nézettől."
|
||||
}
|
||||
|
||||
@@ -98,6 +98,17 @@
|
||||
place since App and Playlists both reference it. */
|
||||
--hdr-h: 4.25rem;
|
||||
|
||||
/* Width of the app scroller's scrollbar gutter (scrollbar-gutter: stable reserves it on the right).
|
||||
Measured once at startup (App) and written here; the floating header and the toolbar band pad
|
||||
their right edge by it so the fixed chrome lines up with the scrolled cards, not 15px past them.
|
||||
Defaults to 0 for overlay-scrollbar platforms where the gutter is empty. */
|
||||
--sbw: 0px;
|
||||
|
||||
/* Max width of a page's content column (cards / table). The floating header + toolbar band read it
|
||||
so they centre on the SAME max-width the content uses, instead of spanning the whole column while
|
||||
a max-w page centres its grid. Each page sets it via PageShell's `maxWidth`; `none` = full width. */
|
||||
--content-mw: none;
|
||||
|
||||
/* ===== Glass tunables — every value that materially drives the look lives here so it can be
|
||||
tuned in ONE place. ===== */
|
||||
/* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome
|
||||
|
||||
@@ -31,6 +31,8 @@ import { setupApi } from "./api/setup";
|
||||
export type * from "./api/setup";
|
||||
import { feedApi } from "./api/feed";
|
||||
export type * from "./api/feed";
|
||||
export type * from "./api/tagModes";
|
||||
export { DEFAULT_TAG_MODES, TAG_CATEGORIES, coerceTagModes } from "./api/tagModes";
|
||||
import { channelsApi } from "./api/channels";
|
||||
export type * from "./api/channels";
|
||||
import { syncApi } from "./api/sync";
|
||||
@@ -49,7 +51,13 @@ import { adminApi } from "./api/admin";
|
||||
export type * from "./api/admin";
|
||||
import { plexApi } from "./api/plex";
|
||||
export type * from "./api/plex";
|
||||
export { EMPTY_PLEX_FILTERS, plexFilterCount } from "./api/plex";
|
||||
export {
|
||||
EMPTY_PLEX_FILTERS,
|
||||
plexFilterCount,
|
||||
coercePlexFilters,
|
||||
plexFiltersSignature,
|
||||
isIntrinsicReadOnly,
|
||||
} from "./api/plex";
|
||||
import { messagingApi } from "./api/messaging";
|
||||
export type * from "./api/messaging";
|
||||
import { downloadsApi } from "./api/downloads";
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// the shared FeedFilters into the query string the feed + facet endpoints both consume; they stay
|
||||
// module-local (no consumer names them). Only touches core's req/beacon.
|
||||
import { req, beacon } from "./core";
|
||||
import { TAG_CATEGORIES, type TagModes } from "./tagModes";
|
||||
|
||||
// Per-user watch status stored server-side (VALID_STATES in routes/feed.py). "in_progress" and
|
||||
// "unwatched" are NOT statuses — they're derived feed filters (status "new" + a resume position),
|
||||
@@ -54,7 +55,8 @@ export interface FeedResponse {
|
||||
|
||||
export interface FeedFilters {
|
||||
tags: number[];
|
||||
tagMode: "or" | "and";
|
||||
/** Per-category tag match (language / topic / other each independently any/all). */
|
||||
tagModes: TagModes;
|
||||
q: string;
|
||||
sort: string;
|
||||
// Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
|
||||
@@ -83,7 +85,10 @@ export interface FeedFilters {
|
||||
function filterParams(f: FeedFilters): URLSearchParams {
|
||||
const p = new URLSearchParams();
|
||||
f.tags.forEach((t) => p.append("tags", String(t)));
|
||||
p.set("tag_mode", f.tagMode);
|
||||
// Only emit the categories set to "and"; the backend defaults each to "or".
|
||||
for (const cat of TAG_CATEGORIES) {
|
||||
if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and");
|
||||
}
|
||||
if (f.q) p.set("q", f.q);
|
||||
p.set("scope", f.scope);
|
||||
p.set("show_normal", String(f.includeNormal));
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
coercePlexFilters,
|
||||
plexFiltersSignature,
|
||||
EMPTY_PLEX_FILTERS,
|
||||
type PlexFilters,
|
||||
} from "./plex";
|
||||
|
||||
describe("coercePlexFilters", () => {
|
||||
it("migrates the legacy single collection/collectionTitle into the multi-select shape", () => {
|
||||
const out = coercePlexFilters({ collection: "A", collectionTitle: "Marvel" });
|
||||
expect(out.collections).toEqual(["A"]);
|
||||
expect(out.collectionTitles).toEqual({ A: "Marvel" });
|
||||
});
|
||||
|
||||
it("migrates a legacy collection with no title", () => {
|
||||
const out = coercePlexFilters({ collection: "A" });
|
||||
expect(out.collections).toEqual(["A"]);
|
||||
expect(out.collectionTitles).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves an already-multi collections blob untouched (guard on !collections.length)", () => {
|
||||
const out = coercePlexFilters({ collections: ["X", "Y"], collection: "A" });
|
||||
expect(out.collections).toEqual(["X", "Y"]);
|
||||
});
|
||||
|
||||
it("fills every default for an empty blob", () => {
|
||||
expect(coercePlexFilters({})).toEqual(EMPTY_PLEX_FILTERS);
|
||||
expect(coercePlexFilters({}).collections).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops the legacy sortDir field", () => {
|
||||
const out = coercePlexFilters({ sortDir: "desc", genres: ["Drama"] });
|
||||
expect((out as unknown as Record<string, unknown>).sortDir).toBeUndefined();
|
||||
expect(out.genres).toEqual(["Drama"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plexFiltersSignature", () => {
|
||||
const base = (): PlexFilters => ({
|
||||
...EMPTY_PLEX_FILTERS,
|
||||
genres: ["Drama"],
|
||||
collections: ["A"],
|
||||
});
|
||||
|
||||
it("is independent of object key insertion order", () => {
|
||||
// Full literals with genuinely different key INSERTION order (NOT spread from a shared base, which
|
||||
// would inherit that base's order and make this tautological). appendPlexFilters reads by field, so
|
||||
// the signatures must match — this test FAILS against an order-sensitive JSON.stringify signature.
|
||||
const a: PlexFilters = {
|
||||
genres: ["Drama"],
|
||||
contentRatings: [],
|
||||
directors: [],
|
||||
actors: [],
|
||||
studios: [],
|
||||
keywords: [],
|
||||
collections: ["A"],
|
||||
};
|
||||
const b: PlexFilters = {
|
||||
collections: ["A"],
|
||||
keywords: [],
|
||||
studios: [],
|
||||
actors: [],
|
||||
directors: [],
|
||||
contentRatings: [],
|
||||
genres: ["Drama"],
|
||||
};
|
||||
expect(JSON.stringify(Object.keys(a))).not.toBe(JSON.stringify(Object.keys(b))); // orders truly differ
|
||||
expect(plexFiltersSignature(a)).toBe(plexFiltersSignature(b));
|
||||
});
|
||||
|
||||
it("ignores display-only collectionTitles (not part of the wire params)", () => {
|
||||
const a = base();
|
||||
const b: PlexFilters = { ...base(), collectionTitles: { A: "Marvel" } };
|
||||
expect(plexFiltersSignature(a)).toBe(plexFiltersSignature(b));
|
||||
});
|
||||
|
||||
it("differs when a real filter differs", () => {
|
||||
const a = base();
|
||||
const b: PlexFilters = { ...base(), genres: ["Comedy"] };
|
||||
expect(plexFiltersSignature(a)).not.toBe(plexFiltersSignature(b));
|
||||
});
|
||||
});
|
||||
@@ -184,6 +184,21 @@ export interface PlexCollection {
|
||||
source?: string;
|
||||
editable: boolean;
|
||||
can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection
|
||||
art?: string[]; // up to 2 poster rating_keys for the browse-tile montage (browse union only)
|
||||
total_runtime_s?: number; // total runtime of all items, for the tile sub-line (browse union only)
|
||||
}
|
||||
/** A collection Plex regenerates outside a user's control — a smart collection or an external auto-list
|
||||
* (imdb/tmdb/tvdb/trakt/…) — can never be edited. The single source of truth for the lock badge shown
|
||||
* on both the browse tile and the info page, so the two surfaces can't drift. */
|
||||
export function isIntrinsicReadOnly(c: Pick<PlexCollection, "smart" | "source">): boolean {
|
||||
return !!c.smart || (!!c.source && c.source !== "collection");
|
||||
}
|
||||
// The Collection info page: the card fields + hero aggregates + the ordered members.
|
||||
export interface PlexCollectionDetail extends PlexCollection {
|
||||
content_rating?: string | null;
|
||||
year_min?: number | null;
|
||||
year_max?: number | null;
|
||||
items: PlexCard[];
|
||||
}
|
||||
export interface PlexPlaylist {
|
||||
id: number;
|
||||
@@ -239,8 +254,9 @@ export interface PlexFilters {
|
||||
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
|
||||
studios: string[]; // OR — from any selected studio
|
||||
keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5)
|
||||
collection?: string | null; // a single Plex collection (rating_key)
|
||||
collectionTitle?: string | null; // its title, for the active-filter chip
|
||||
collections: string[]; // Plex collection rating_keys; a title can be in several
|
||||
collectionMode?: "any" | "all"; // how multiple collections combine (default any = OR)
|
||||
collectionTitles?: Record<string, string>; // rating_key → title, for the active-filter chips
|
||||
}
|
||||
export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
||||
genres: [],
|
||||
@@ -249,7 +265,39 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
||||
actors: [],
|
||||
studios: [],
|
||||
keywords: [],
|
||||
collections: [],
|
||||
};
|
||||
// Coerce an opaque persisted/saved filter blob to a full PlexFilters: fill defaults for any missing
|
||||
// array/scalar, and migrate the legacy single `collection`/`collectionTitle` into the multi-select
|
||||
// `collections`/`collectionTitles`. Shared by PlexProvider (localStorage seed) and the saved-views
|
||||
// provider (a DB view blob). Not a migration — an unknown field a newer build dropped just falls away.
|
||||
export function coercePlexFilters(raw: Record<string, unknown>): PlexFilters {
|
||||
const {
|
||||
sortDir: _drop,
|
||||
collection,
|
||||
collectionTitle,
|
||||
...rest
|
||||
} = raw as Record<string, unknown> & {
|
||||
collection?: string | null;
|
||||
collectionTitle?: string | null;
|
||||
};
|
||||
const merged: PlexFilters = { ...EMPTY_PLEX_FILTERS, ...(rest as Partial<PlexFilters>) };
|
||||
if (collection && !merged.collections.length) {
|
||||
merged.collections = [collection];
|
||||
if (collectionTitle) merged.collectionTitles = { [collection]: collectionTitle };
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// A stable signature of a PlexFilters set (only non-default keys; display-only titles excluded), so a
|
||||
// saved view can be matched against the live filters regardless of key order — the Plex analogue of the
|
||||
// feed's filtersToParams. Reuses appendPlexFilters so the signature can't drift from the wire params.
|
||||
export function plexFiltersSignature(f: PlexFilters): string {
|
||||
const u = new URLSearchParams();
|
||||
appendPlexFilters(u, f);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
export function plexFilterCount(f: PlexFilters): number {
|
||||
return (
|
||||
f.genres.length +
|
||||
@@ -262,7 +310,7 @@ export function plexFilterCount(f: PlexFilters): number {
|
||||
f.actors.length +
|
||||
f.studios.length +
|
||||
f.keywords.length +
|
||||
(f.collection ? 1 : 0)
|
||||
f.collections.length
|
||||
);
|
||||
}
|
||||
// Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid
|
||||
@@ -283,7 +331,8 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
|
||||
if (f.actorMode === "all") u.set("actor_mode", "all");
|
||||
if (f.studios?.length) u.set("studios", f.studios.join(","));
|
||||
if (f.keywords?.length) u.set("keywords", f.keywords.join(","));
|
||||
if (f.collection) u.set("collection", f.collection);
|
||||
if (f.collections?.length) u.set("collections", f.collections.join(","));
|
||||
if (f.collectionMode === "all") u.set("collection_mode", "all");
|
||||
}
|
||||
export interface PlexFacets {
|
||||
genres: { value: string; count: number }[];
|
||||
@@ -307,8 +356,10 @@ export interface PlexPlaySession {
|
||||
export interface PlexDiscover {
|
||||
scope: string;
|
||||
continue_watching: PlexCard[];
|
||||
recently_watched: PlexCard[]; // last fully-watched movies + episodes, newest first
|
||||
recently_added: PlexCard[];
|
||||
genres: { value: string; count: number }[]; // top genres → tap a tile to filter the grid
|
||||
// top genres → tap a tile to filter the grid; `art` = up to 4 poster rating_keys for the collage
|
||||
genres: { value: string; count: number; art: string[] }[];
|
||||
because_you_watched: { anchor: { id: string; title: string }; items: PlexCard[] } | null;
|
||||
}
|
||||
|
||||
@@ -379,6 +430,9 @@ export const plexApi = {
|
||||
const qs = u.toString();
|
||||
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
// One collection's hero + ordered members (the Collection info page).
|
||||
plexCollection: (rk: string): Promise<PlexCollectionDetail> =>
|
||||
req(`/api/plex/collections/${encodeURIComponent(rk)}`),
|
||||
// --- Collection editing (admin only; writes back to Plex) ---
|
||||
plexCreateCollection: (
|
||||
library: string,
|
||||
@@ -460,16 +514,26 @@ export const plexApi = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ item_rating_keys: itemRks }),
|
||||
}),
|
||||
// Discover landing rails in one request (serve-from-DB). `lang` picks the genre-tile vocabulary.
|
||||
plexDiscover: (scope: string, lang?: string): Promise<PlexDiscover> => {
|
||||
// Discover landing rails in one request (serve-from-DB). `lang` picks the genre-tile vocabulary; the
|
||||
// active sidebar `filters` narrow every rail (so Discover behaves like the Browse grid).
|
||||
plexDiscover: (scope: string, lang?: string, filters?: PlexFilters): Promise<PlexDiscover> => {
|
||||
const u = new URLSearchParams({ scope });
|
||||
if (lang === "hu") u.set("lang", "hu");
|
||||
if (filters) appendPlexFilters(u, filters);
|
||||
return req(`/api/plex/discover?${u.toString()}`);
|
||||
},
|
||||
// A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch).
|
||||
plexSurprise: (scope: string, limit?: number): Promise<{ scope: string; items: PlexCard[] }> => {
|
||||
// A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch), narrowed by
|
||||
// the active filters like the other rails.
|
||||
plexSurprise: (
|
||||
scope: string,
|
||||
limit?: number,
|
||||
filters?: PlexFilters,
|
||||
lang?: string,
|
||||
): Promise<{ scope: string; items: PlexCard[] }> => {
|
||||
const u = new URLSearchParams({ scope });
|
||||
if (limit) u.set("limit", String(limit));
|
||||
if (lang === "hu") u.set("lang", "hu");
|
||||
if (filters) appendPlexFilters(u, filters);
|
||||
return req(`/api/plex/surprise?${u.toString()}`);
|
||||
},
|
||||
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
// Saved "smart views": per-user named snapshots of FeedFilters (see SavedViewsWidget).
|
||||
// Only touches core's req; the FeedFilters shape is owned by feed.ts.
|
||||
// Saved "smart views": per-user named snapshots of a module's filters, scoped by `source`
|
||||
// ("feed" | "plex"). Only touches core's req; `filters` is an opaque JSON blob (each module casts it
|
||||
// to its own filter shape), so this stays independent of feed.ts / plex.ts.
|
||||
import { req } from "./core";
|
||||
import type { FeedFilters } from "./feed";
|
||||
|
||||
// A saved "smart view": a per-user named snapshot of FeedFilters (see SavedViewsWidget).
|
||||
// A module's saved filter snapshot. `filters` is opaque here — the feed reads it as FeedFilters, Plex
|
||||
// as PlexFilters; `source` is implicit (the caller queries per source).
|
||||
export interface SavedView {
|
||||
id: number;
|
||||
name: string;
|
||||
filters: FeedFilters;
|
||||
filters: Record<string, unknown>;
|
||||
position: number;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
// The module a view belongs to. "feed" is the default so existing callers keep working.
|
||||
type ViewSource = "feed" | "plex";
|
||||
|
||||
export const savedViewsApi = {
|
||||
savedViews: (): Promise<SavedView[]> => req("/api/saved-views"),
|
||||
createView: (name: string, filters: FeedFilters): Promise<SavedView> =>
|
||||
req("/api/saved-views", { method: "POST", body: JSON.stringify({ name, filters }) }),
|
||||
savedViews: (source: ViewSource = "feed"): Promise<SavedView[]> =>
|
||||
req(`/api/saved-views?source=${source}`),
|
||||
createView: (
|
||||
name: string,
|
||||
filters: Record<string, unknown>,
|
||||
source: ViewSource = "feed",
|
||||
): Promise<SavedView> =>
|
||||
req("/api/saved-views", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, filters, source }),
|
||||
}),
|
||||
updateView: (
|
||||
id: number,
|
||||
patch: Partial<{ name: string; filters: FeedFilters; is_default: boolean }>,
|
||||
patch: Partial<{ name: string; filters: Record<string, unknown>; is_default: boolean }>,
|
||||
): Promise<SavedView> =>
|
||||
req(`/api/saved-views/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
deleteView: (id: number): Promise<{ deleted: number }> =>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { coerceTagModes, DEFAULT_TAG_MODES } from "./tagModes";
|
||||
|
||||
// coerceTagModes runs on every persisted saved-view blob (SavedViewsProvider.asFilters), on the URL
|
||||
// parse, and on the localStorage seed — the single migration point for the legacy global `tagMode`.
|
||||
describe("coerceTagModes", () => {
|
||||
it("defaults to all-'or' for empty/undefined input", () => {
|
||||
expect(coerceTagModes(undefined)).toEqual(DEFAULT_TAG_MODES);
|
||||
expect(coerceTagModes({})).toEqual(DEFAULT_TAG_MODES);
|
||||
expect(coerceTagModes(null)).toEqual(DEFAULT_TAG_MODES);
|
||||
});
|
||||
|
||||
it("migrates a legacy global tagMode:'and' into the topic slot only", () => {
|
||||
expect(coerceTagModes({ tagMode: "and" })).toEqual({
|
||||
language: "or",
|
||||
topic: "and",
|
||||
other: "or",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a legacy tagMode:'or' (stays all-'or')", () => {
|
||||
expect(coerceTagModes({ tagMode: "or" })).toEqual(DEFAULT_TAG_MODES);
|
||||
});
|
||||
|
||||
it("preserves an explicit per-category tagModes object", () => {
|
||||
expect(coerceTagModes({ tagModes: { language: "and", topic: "or", other: "and" } })).toEqual({
|
||||
language: "and",
|
||||
topic: "or",
|
||||
other: "and",
|
||||
});
|
||||
});
|
||||
|
||||
it("an explicit per-category value wins over the legacy scalar", () => {
|
||||
// A blob carrying both should honour the new per-category topic, not the legacy global.
|
||||
expect(coerceTagModes({ tagMode: "and", tagModes: { topic: "or" } }).topic).toBe("or");
|
||||
});
|
||||
|
||||
it("coerces invalid/garbage values to 'or'", () => {
|
||||
expect(coerceTagModes({ tagModes: { topic: "x" as unknown as "or" } }).topic).toBe("or");
|
||||
expect(coerceTagModes({ tagModes: { language: 1 as unknown as "or" } }).language).toBe("or");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// Per-category tag match — the small, dependency-free vocabulary shared by the feed filter model, the
|
||||
// URL (de)serializer, the sidebar comboboxes, and the saved-view migration. Kept in its own leaf (no
|
||||
// imports) so node-env tests and any module can pull it in without dragging the HTTP/i18n machinery.
|
||||
|
||||
/** A tag category's match: "or" = a channel with ANY selected tag passes, "and" = it must carry ALL. */
|
||||
export type TagMode = "or" | "and";
|
||||
/** The three tag categories, each with its OWN independent match mode. */
|
||||
export type TagCategory = "language" | "topic" | "other";
|
||||
export type TagModes = Record<TagCategory, TagMode>;
|
||||
export const TAG_CATEGORIES: readonly TagCategory[] = ["language", "topic", "other"];
|
||||
export const DEFAULT_TAG_MODES: TagModes = { language: "or", topic: "or", other: "or" };
|
||||
|
||||
/** Normalise a persisted/shared filter's tag match into the per-category shape, migrating the legacy
|
||||
* global `tagMode` (which only ever affected topics) into the topic slot. */
|
||||
export function coerceTagModes(raw: unknown): TagModes {
|
||||
const r = (raw ?? {}) as { tagModes?: Partial<TagModes>; tagMode?: unknown };
|
||||
const legacyTopic = r.tagMode === "and" ? "and" : undefined;
|
||||
const pick = (cat: TagCategory): TagMode => {
|
||||
const v = r.tagModes?.[cat];
|
||||
if (v === "and" || v === "or") return v;
|
||||
return cat === "topic" && legacyTopic ? "and" : "or";
|
||||
};
|
||||
return { language: pick("language"), topic: pick("topic"), other: pick("other") };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideChipLayout, type ChipFitInput } from "./chipOverflow";
|
||||
|
||||
// Build measurer-style right edges for `n` chips of a fixed width, starting after a label, with a gap.
|
||||
function rights(labelRight: number, widths: number[], gap: number): number[] {
|
||||
const out: number[] = [];
|
||||
let cursor = labelRight;
|
||||
for (const w of widths) {
|
||||
cursor += gap + w;
|
||||
out.push(cursor);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const base = (over: Partial<ChipFitInput> = {}): ChipFitInput => {
|
||||
const gap = 6;
|
||||
const labelRight = 60;
|
||||
const widths = [50, 50, 50]; // 3 chips
|
||||
return {
|
||||
n: 3,
|
||||
labelRight,
|
||||
chipRights: rights(labelRight, widths, gap),
|
||||
plusW: 40,
|
||||
clearW: 6 + 60,
|
||||
avail: 1000,
|
||||
gap,
|
||||
narrowPx: 420,
|
||||
...over,
|
||||
};
|
||||
};
|
||||
|
||||
describe("decideChipLayout", () => {
|
||||
it("shows all chips when the whole set plus Clear all fits", () => {
|
||||
expect(decideChipLayout(base({ avail: 1000 }))).toEqual({ mode: "all", visible: 3 });
|
||||
});
|
||||
|
||||
it("empty chip set is a no-op 'all' with 0 visible", () => {
|
||||
expect(decideChipLayout(base({ n: 0, chipRights: [] }))).toEqual({ mode: "all", visible: 0 });
|
||||
});
|
||||
|
||||
it("collapses to a single count pill on a narrow lane when overflowing", () => {
|
||||
// last chip right edge is ~228; avail below NARROW and below that -> count.
|
||||
expect(decideChipLayout(base({ avail: 200, narrowPx: 420 }))).toEqual({
|
||||
mode: "count",
|
||||
visible: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("fits as many chips as leave room for the +N pill on a wide-but-tight lane", () => {
|
||||
// chip right edges: 116, 172, 228; clearW 66. all-fit needs 228+66=294 <= avail; keep avail below
|
||||
// that (so not 'all') but >= narrowPx (so not 'count'). Loop reserves gap(6)+plus(40)=46 per chip:
|
||||
// chip2 228+46=274 > 250 -> dropped; chip1 218 <= 250 -> visible 2.
|
||||
expect(decideChipLayout(base({ avail: 250, narrowPx: 200 }))).toEqual({
|
||||
mode: "some",
|
||||
visible: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("reserves the +N pill width so a later chip is dropped when it wouldn't fit beside it", () => {
|
||||
// Two chips of width 100 after a label, gap 6, plus pill 40. Right edges: 166, 272. avail 300,
|
||||
// narrowPx 100 (so 300 >= narrow -> 'some'): chip0 166+46=212 <= 300 ok; chip1 272+46=318 > 300
|
||||
// -> dropped. visible 1.
|
||||
const input = base({
|
||||
n: 2,
|
||||
chipRights: rights(60, [100, 100], 6),
|
||||
plusW: 40,
|
||||
clearW: 6 + 60,
|
||||
avail: 300,
|
||||
gap: 6,
|
||||
narrowPx: 100,
|
||||
});
|
||||
expect(decideChipLayout(input)).toEqual({ mode: "some", visible: 1 });
|
||||
});
|
||||
|
||||
it("falls back to the count pill when not even one chip fits beside the +N pill", () => {
|
||||
// Wide-ish lane (>= narrow) but a single very wide chip: label 60, chip width 500 -> right 566.
|
||||
// avail 430 >= narrow(420); all-fit 566+clearW > 430; loop: 566+6+40 > 430 -> k=0; labelRight
|
||||
// 60+6+40=106 <= 430 so NOT count by that guard... use narrow-avail instead: keep avail 430 but
|
||||
// make label+gap+plus exceed it to force count.
|
||||
const input = base({
|
||||
n: 1,
|
||||
labelRight: 400,
|
||||
chipRights: [966],
|
||||
plusW: 40,
|
||||
clearW: 0,
|
||||
avail: 430,
|
||||
gap: 6,
|
||||
narrowPx: 420,
|
||||
});
|
||||
// label 400 + gap 6 + plus 40 = 446 > 430 and k===0 -> count.
|
||||
expect(decideChipLayout(input)).toEqual({ mode: "count", visible: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// Pure fit decision for a single-line chip lane that must share its width with fixed trailing
|
||||
// controls (the feed's applied-filter row + Sort/View). Lifted out of ActiveFilterChips so the
|
||||
// all/some/count math is unit-testable without a layout engine (jsdom reports 0-width boxes), the
|
||||
// same split as lib/dateFilter and lib/columnSort. The caller measures real widths off a hidden
|
||||
// mirror (including the live rem gap, so nothing is frozen in px) and hands them here.
|
||||
//
|
||||
// A sibling one-line-fit measurer lives in ChannelTags (ChannelTagsControl); if a third appears,
|
||||
// promote the DOM-measurement half to a shared hook and keep this as its policy core.
|
||||
|
||||
export interface ChipFitInput {
|
||||
/** Number of chips. */
|
||||
n: number;
|
||||
/** Right edge (offsetLeft + offsetWidth) of the leading label, in the measurer. */
|
||||
labelRight: number;
|
||||
/** Right edge of each chip, in the measurer (index-aligned to the chips). */
|
||||
chipRights: number[];
|
||||
/** Width of the "+N" overflow pill. */
|
||||
plusW: number;
|
||||
/** Width the trailing "Clear all" button needs INCLUDING its leading gap (0 when n <= 1, since it
|
||||
* only renders with more than one chip). Reserved only in the "all" branch, which renders it. */
|
||||
clearW: number;
|
||||
/** Available lane width. */
|
||||
avail: number;
|
||||
/** The real (rem-scaled) inter-item gap. */
|
||||
gap: number;
|
||||
/** At or below this lane width an overflow collapses to a single count pill instead of "+N". */
|
||||
narrowPx: number;
|
||||
}
|
||||
|
||||
export type ChipFit = { mode: "all" | "some" | "count"; visible: number };
|
||||
|
||||
export function decideChipLayout(p: ChipFitInput): ChipFit {
|
||||
const { n, labelRight, chipRights, plusW, clearW, avail, gap, narrowPx } = p;
|
||||
if (n <= 0) return { mode: "all", visible: 0 };
|
||||
|
||||
// Do all chips (plus the Clear all button it renders) fit? Use the last chip's right edge.
|
||||
const allRight = chipRights[n - 1] ?? labelRight;
|
||||
if (allRight + clearW <= avail) return { mode: "all", visible: n };
|
||||
|
||||
// Overflow. On a narrow lane, collapse everything into one count pill.
|
||||
if (avail < narrowPx) return { mode: "count", visible: 0 };
|
||||
|
||||
// Otherwise fit as many chips as leave room for the "+N" pill after them.
|
||||
let k = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const right = chipRights[i] ?? 0;
|
||||
if (right + gap + plusW <= avail) k = i + 1;
|
||||
else break;
|
||||
}
|
||||
// If not even one chip fits beside the pill, fall back to the count pill.
|
||||
if (k === 0 && labelRight + gap + plusW > avail) return { mode: "count", visible: 0 };
|
||||
return { mode: "some", visible: k };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applyDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
|
||||
import { selectDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
|
||||
import { type FeedFilters } from "./api";
|
||||
|
||||
const PRESETS: DatePreset[] = [
|
||||
@@ -12,7 +12,7 @@ const PRESETS: DatePreset[] = [
|
||||
|
||||
const base = (over: Partial<FeedFilters> = {}): FeedFilters => ({
|
||||
tags: [],
|
||||
tagMode: "or",
|
||||
tagModes: { language: "or", topic: "or", other: "or" },
|
||||
q: "",
|
||||
sort: "date",
|
||||
scope: "my",
|
||||
@@ -48,41 +48,43 @@ describe("deriveDateKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDateChip", () => {
|
||||
describe("selectDateChip", () => {
|
||||
it("picking a preset sets maxAgeDays and clears any custom range", () => {
|
||||
const change = applyDateChip(
|
||||
"7",
|
||||
base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }),
|
||||
false,
|
||||
);
|
||||
expect(change.customDates).toBeUndefined();
|
||||
const change = selectDateChip("7", base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }));
|
||||
expect(change.customOpen).toBe(false);
|
||||
expect(change.filters).toMatchObject({ maxAgeDays: 7, dateFrom: undefined, dateTo: undefined });
|
||||
});
|
||||
|
||||
it("re-picking the active preset toggles it off", () => {
|
||||
const change = applyDateChip("7", base({ maxAgeDays: 7 }), false);
|
||||
expect(change.filters?.maxAgeDays).toBeUndefined();
|
||||
it("picking a preset always sets it (no toggle-off) even when it's already active", () => {
|
||||
const change = selectDateChip("7", base({ maxAgeDays: 7 }));
|
||||
expect(change.filters.maxAgeDays).toBe(7);
|
||||
expect(change.customOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("a preset is not 'active' while a custom range is open, so it sets rather than toggling off", () => {
|
||||
const change = applyDateChip("7", base({ maxAgeDays: 7 }), true);
|
||||
expect(change.filters?.maxAgeDays).toBe(7);
|
||||
it("'any' clears the preset and any custom range", () => {
|
||||
const change = selectDateChip("any", base({ maxAgeDays: 7, dateFrom: "2026-01-01" }));
|
||||
expect(change.filters).toMatchObject({
|
||||
maxAgeDays: undefined,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
});
|
||||
expect(change.customOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("opening Custom sets the flag and clears a preset's maxAgeDays", () => {
|
||||
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), false);
|
||||
expect(change.customDates).toBe(true);
|
||||
expect(change.filters).toMatchObject({ maxAgeDays: undefined });
|
||||
it("an empty key behaves like 'any'", () => {
|
||||
const change = selectDateChip("", base({ maxAgeDays: 7 }));
|
||||
expect(change.filters.maxAgeDays).toBeUndefined();
|
||||
expect(change.customOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("closing Custom clears the flag and leaves filters untouched", () => {
|
||||
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), true);
|
||||
expect(change.customDates).toBe(false);
|
||||
expect(change.filters).toBeUndefined();
|
||||
it("opening Custom sets the flag and clears a preset's maxAgeDays but keeps a typed range", () => {
|
||||
const change = selectDateChip("custom", base({ maxAgeDays: 7, dateFrom: "2026-01-01" }));
|
||||
expect(change.customOpen).toBe(true);
|
||||
expect(change.filters).toMatchObject({ maxAgeDays: undefined, dateFrom: "2026-01-01" });
|
||||
});
|
||||
|
||||
it("preserves unrelated filter fields when picking a preset", () => {
|
||||
const change = applyDateChip("30", base({ q: "hello", tags: [1, 2] }), false);
|
||||
const change = selectDateChip("30", base({ q: "hello", tags: [1, 2] }));
|
||||
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,51 +1,49 @@
|
||||
import { type FeedFilters } from "./api";
|
||||
|
||||
// Pure date-filter selection logic for the feed sidebar's "Upload date" chip row, lifted out of the
|
||||
// Pure date-filter selection logic for the feed sidebar's "Upload date" selector, lifted out of the
|
||||
// component so the single-select derivation + the preset/custom dispatch can be unit-tested without
|
||||
// mounting Sidebar (same split as columnSort.ts for sort). The row is single-select across the day
|
||||
// presets plus a "Custom" range chip.
|
||||
// mounting Sidebar (same split as columnSort.ts for sort). The control is a single-select dropdown
|
||||
// across an "Any time" clear, the day presets, and a "Custom range" (reveal-on-select from/to).
|
||||
|
||||
export interface DatePreset {
|
||||
days: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** Which date chip is active: a preset's day-count (as a string), "custom", or "" (none). A custom
|
||||
* range (the editor is open, or a from/to is set) owns the selection; otherwise a preset matches only
|
||||
* when no custom range is active; a maxAge that matches no preset (e.g. a shared-link value) is none. */
|
||||
/** Which date option is active: a preset's day-count (as a string), "custom", or "" (none / "Any
|
||||
* time"). A custom range (the reveal is open, or a from/to is set) owns the selection; otherwise a
|
||||
* preset matches only when no custom range is active; a maxAge that matches no preset (e.g. a
|
||||
* shared-link value) is none. */
|
||||
export function deriveDateKey(
|
||||
filters: Pick<FeedFilters, "maxAgeDays" | "dateFrom" | "dateTo">,
|
||||
customDates: boolean,
|
||||
customOpen: boolean,
|
||||
presets: readonly DatePreset[],
|
||||
): string {
|
||||
if (customDates || filters.dateFrom || filters.dateTo) return "custom";
|
||||
if (customOpen || filters.dateFrom || filters.dateTo) return "custom";
|
||||
const preset = presets.find((p) => filters.maxAgeDays === p.days);
|
||||
return preset ? String(preset.days) : "";
|
||||
}
|
||||
|
||||
/** The state change when a date chip is picked. A preset is single-select with toggle-off (re-picking
|
||||
* the active preset clears it) and clears any custom range; "custom" toggles the range editor and,
|
||||
* when opened, clears a preset. Returns the next `filters` (only when they change) and/or the next
|
||||
* `customDates` flag (only when it changes) so the caller applies exactly what moved. */
|
||||
export function applyDateChip(
|
||||
/** The state change when a date option is picked from the dropdown. Unlike the old chip row there is no
|
||||
* toggle-off — "Any time" is the explicit clear. A preset sets maxAge and clears any custom range;
|
||||
* "custom" opens the reveal and clears the preset (keeping any typed range); "any"/"" clears everything.
|
||||
* Returns the next `filters` and the next `customOpen` flag so the caller applies both. */
|
||||
export function selectDateChip(
|
||||
key: string,
|
||||
filters: FeedFilters,
|
||||
customDates: boolean,
|
||||
): { filters?: FeedFilters; customDates?: boolean } {
|
||||
): { filters: FeedFilters; customOpen: boolean } {
|
||||
if (key === "custom") {
|
||||
const next = !customDates;
|
||||
return next
|
||||
? { customDates: true, filters: { ...filters, maxAgeDays: undefined } }
|
||||
: { customDates: false };
|
||||
return { filters: { ...filters, maxAgeDays: undefined }, customOpen: true };
|
||||
}
|
||||
if (key === "any" || key === "") {
|
||||
return {
|
||||
filters: { ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined },
|
||||
customOpen: false,
|
||||
};
|
||||
}
|
||||
const days = Number(key);
|
||||
const activePreset = filters.maxAgeDays === days && !customDates;
|
||||
return {
|
||||
filters: {
|
||||
...filters,
|
||||
maxAgeDays: activePreset ? undefined : days,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
},
|
||||
filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined },
|
||||
customOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The per-history-entry scroll-id helpers + nav bus that back PageScroller's scroll restoration:
|
||||
// a fresh id per new view (so a forward navigation opens at the top), a stable id per entry (so Back
|
||||
// restores it), and a bus so a sub-view open that doesn't re-render the scroller still triggers it.
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { nextNavId, ensureNavId, peekNavId, subscribeNav, notifyNav } from "./history";
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "");
|
||||
});
|
||||
|
||||
describe("nav scroll ids", () => {
|
||||
it("nextNavId hands out strictly increasing ids", () => {
|
||||
const a = nextNavId();
|
||||
const b = nextNavId();
|
||||
expect(b).toBeGreaterThan(a);
|
||||
});
|
||||
|
||||
it("ensureNavId assigns an id to a stateless entry, then is idempotent", () => {
|
||||
expect(peekNavId()).toBe(0); // no id yet
|
||||
const id = ensureNavId();
|
||||
expect(id).toBeGreaterThan(0);
|
||||
expect(peekNavId()).toBe(id);
|
||||
expect(ensureNavId()).toBe(id); // does not reassign
|
||||
expect((window.history.state as { sfScroll?: number }).sfScroll).toBe(id);
|
||||
});
|
||||
|
||||
it("ensureNavId preserves other history.state keys", () => {
|
||||
window.history.replaceState({ sfPage: "plex" }, "");
|
||||
ensureNavId();
|
||||
expect((window.history.state as { sfPage?: string }).sfPage).toBe("plex");
|
||||
});
|
||||
|
||||
it("a pushed entry keeps its own id (so Back restores the right one)", () => {
|
||||
const first = ensureNavId();
|
||||
window.history.pushState({ sfScroll: nextNavId() }, "");
|
||||
const second = peekNavId();
|
||||
expect(second).not.toBe(first);
|
||||
window.history.back();
|
||||
// jsdom doesn't truly pop synchronously, but the ids are distinct per entry — the invariant we need.
|
||||
expect(second).toBeGreaterThan(first);
|
||||
});
|
||||
|
||||
it("ensureNavId advances the counter past a higher persisted id (post-reload collision guard)", () => {
|
||||
// After a full reload the module counter resets to 0 while sfScroll ids persist in history.state.
|
||||
// Landing on an entry whose id is above the counter must push the counter past it, so the next
|
||||
// minted id can't duplicate a live back-stack entry and mis-restore its scroll.
|
||||
const high = 999999;
|
||||
window.history.replaceState({ sfScroll: high }, "");
|
||||
expect(ensureNavId()).toBe(high); // returns the persisted id, doesn't reassign
|
||||
expect(nextNavId()).toBeGreaterThan(high); // and the next mint clears it
|
||||
});
|
||||
|
||||
it("notifyNav fires subscribers until they unsubscribe", () => {
|
||||
const cb = vi.fn();
|
||||
const unsub = subscribeNav(cb);
|
||||
notifyNav();
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
notifyNav();
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
unsub();
|
||||
notifyNav();
|
||||
expect(cb).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,58 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { urlWithParams } from "./urlState";
|
||||
|
||||
// --- Per-history-entry scroll id + a nav bus (for scroll restoration) ------------------------
|
||||
// Every navigation entry carries a stable numeric id in history.state.sfScroll. PageScroller
|
||||
// remembers scroll offsets per (entry id + view key) so Back restores exactly where the user left
|
||||
// an entry, while a forward navigation (a new id) starts at the top. We drive scroll ourselves, so
|
||||
// the browser's own window-scroll restoration (which can't see our inner <main> scroller) is off.
|
||||
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||||
window.history.scrollRestoration = "manual";
|
||||
}
|
||||
let navCounter = 0;
|
||||
// sfScroll ids are persisted into window.history.state and survive a full page reload, but this
|
||||
// module-level counter resets to 0 on every load. Seed it past the current entry's id so freshly
|
||||
// minted ids can't collide with ids already carried by pre-reload back-stack entries (ids are
|
||||
// assigned in forward order, so the current entry's id is ≥ every ancestor's) — a collision would
|
||||
// otherwise cross-contaminate PageScroller's per-entry scroll offsets after a reload + navigation.
|
||||
if (typeof window !== "undefined") {
|
||||
const seed = (window.history.state as { sfScroll?: number } | null)?.sfScroll;
|
||||
if (typeof seed === "number") navCounter = seed;
|
||||
}
|
||||
/** A fresh entry id — stamp it into the state of every pushState that opens a new main-content view. */
|
||||
export function nextNavId(): number {
|
||||
return ++navCounter;
|
||||
}
|
||||
/** The current entry's scroll id, assigning one (via replaceState) to entries that predate this — the
|
||||
* initial load, or an entry pushed without one — so the id survives Back/Forward. */
|
||||
export function ensureNavId(): number {
|
||||
const st = (window.history.state ?? {}) as { sfScroll?: number };
|
||||
if (typeof st.sfScroll === "number") {
|
||||
// Keep the counter ahead of any id we observe (e.g. a Back landed on a higher-id entry after a
|
||||
// reload seeded us low) so the next mint never duplicates a live entry's id.
|
||||
if (st.sfScroll > navCounter) navCounter = st.sfScroll;
|
||||
return st.sfScroll;
|
||||
}
|
||||
const id = nextNavId();
|
||||
window.history.replaceState({ ...st, sfScroll: id }, "");
|
||||
return id;
|
||||
}
|
||||
/** Read the current entry's scroll id without assigning one (0 if none). */
|
||||
export function peekNavId(): number {
|
||||
const st = window.history.state as { sfScroll?: number } | null;
|
||||
return typeof st?.sfScroll === "number" ? st.sfScroll : 0;
|
||||
}
|
||||
const navListeners = new Set<() => void>();
|
||||
/** Fire after a pushState that opens a new view but does NOT re-render PageScroller (a Plex sub-view
|
||||
* open): lets the scroller run its restore/reset without waiting on a React render it won't get. */
|
||||
export function notifyNav(): void {
|
||||
navListeners.forEach((cb) => cb());
|
||||
}
|
||||
export function subscribeNav(cb: () => void): () => void {
|
||||
navListeners.add(cb);
|
||||
return () => navListeners.delete(cb);
|
||||
}
|
||||
|
||||
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
|
||||
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
|
||||
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
|
||||
@@ -61,7 +113,14 @@ export function useHistorySubview<T>(
|
||||
open: (next: T) => {
|
||||
setView(next);
|
||||
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
|
||||
window.history.pushState({ ...window.history.state, _sub: next }, "", href);
|
||||
// A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back
|
||||
// restores the view we came from) — then notify PageScroller, which this push doesn't re-render.
|
||||
window.history.pushState(
|
||||
{ ...window.history.state, _sub: next, sfScroll: nextNavId() },
|
||||
"",
|
||||
href,
|
||||
);
|
||||
notifyNav();
|
||||
},
|
||||
back: () => window.history.back(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// A stable, pleasant hue per string (genre/playlist/collection title) plus the matching tile gradient.
|
||||
// Shared by the Discover genre tiles (PlexDiscover) and the header Playlist/Collection browse tiles
|
||||
// (plexBrowseLists) so the two surfaces that are meant to look identical can't drift. Hashing keeps a
|
||||
// name the same color across renders/sessions without storing anything.
|
||||
export function hueOf(name: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
|
||||
return h;
|
||||
}
|
||||
|
||||
export function hueTileGradient(hue: number): string {
|
||||
return `linear-gradient(135deg, hsl(${hue} 55% 32%), hsl(${(hue + 40) % 360} 60% 22%))`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user