feat(plex): field-scoped search (S2) + instant search typeahead
Add a search scope-pill to the Plex header search box (GitHub-style, one field-scope at a time) and an instant typeahead of the top matches below it. Backend (unified_library): - New `field` param: all | title | people | summary | episode. `all` is byte-identical to the pre-scope behaviour (untouched weighted search_vector); a specific field keeps the GIN `search_vector @@ tsq` prefilter AND ANDs a per-column tsvector recheck, so scoped hits are a strict subset of Everywhere. Per-field expressions mirror the search_vector Computed def (same TS config, same left(summary,1000) truncation). - `episode` scope empties the movie/show grid and title-only-matches the grouped Episodes section; the title/people/summary scopes suppress that section. - Unknown field falls back to `all`. New DB-lane characterization tests (test_plex_field_scoped_search.py): each scope narrows correctly, field=all is identical to today, episode empties the grid and needs shows in scope. Frontend: - PlexSearchBox wraps the shared SearchBar with the scope-pill (reusing ActionMenu) and the typeahead; SearchBar gains generic leading/below/focus/ keydown slots so other modules' search boxes are untouched. - Episode scope offered only when shows are in scope (movie-only hides it), healed at the point of use like the sort state. - Typeahead reuses /api/plex/library (limit=10), honours scope+field (not the sidebar filters), shows poster+title+year, keyboard nav (arrows/Enter/Esc), and jumps straight to a movie's info page or a show's drill-down via a new provider jumpTo hand-off (mirrors the playlistOpen pattern). - `field` persisted per-account; en/hu labels added.
This commit is contained in:
@@ -53,6 +53,30 @@ router = APIRouter(prefix="/api/plex", tags=["plex"])
|
|||||||
|
|
||||||
_TS_CONFIG = "public.unaccent_simple"
|
_TS_CONFIG = "public.unaccent_simple"
|
||||||
|
|
||||||
|
# Field-scoped search: the scope-pill in the header search box narrows a full-text query to ONE
|
||||||
|
# facet of the row. `all` uses the GIN-indexed weighted `search_vector` untouched (byte-identical to
|
||||||
|
# the pre-scope behaviour); a specific field keeps that GIN prefilter AND re-checks a per-column
|
||||||
|
# tsvector, so the scoped hits are always a strict subset of Everywhere. The per-field expressions
|
||||||
|
# mirror the `search_vector` Computed definition (same `_TS_CONFIG`, same `left(summary, 1000)`
|
||||||
|
# truncation) so scoping never surfaces a match the vector itself wouldn't. `episode` is handled
|
||||||
|
# separately in the query body (it targets episode rows, not the movie/show grid).
|
||||||
|
_FIELD_COLUMNS = ("title", "people", "summary", "episode")
|
||||||
|
|
||||||
|
|
||||||
|
def _field_recheck(model, field: str, tsq):
|
||||||
|
"""A per-field `to_tsvector(...) @@ tsq` clause for a non-`all`, non-`episode` scope, or None."""
|
||||||
|
if field == "title":
|
||||||
|
expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.title, ""))
|
||||||
|
elif field == "people":
|
||||||
|
expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.people_text, ""))
|
||||||
|
elif field == "summary":
|
||||||
|
expr = func.to_tsvector(
|
||||||
|
_TS_CONFIG, func.left(func.coalesce(model.summary, ""), 1000)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return expr.op("@@")(tsq)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/libraries")
|
@router.get("/libraries")
|
||||||
def list_libraries(
|
def list_libraries(
|
||||||
@@ -251,6 +275,7 @@ class LibraryFilters:
|
|||||||
def unified_library(
|
def unified_library(
|
||||||
scope: str = "both",
|
scope: str = "both",
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
|
field: str = "all",
|
||||||
sort: str = "added",
|
sort: str = "added",
|
||||||
sort_dir: str = "desc",
|
sort_dir: str = "desc",
|
||||||
show: str = "all",
|
show: str = "all",
|
||||||
@@ -276,9 +301,17 @@ def unified_library(
|
|||||||
|
|
||||||
ts = to_tsquery_str(q) if q else None
|
ts = to_tsquery_str(q) if q else None
|
||||||
tsq = func.to_tsquery(_TS_CONFIG, ts) if ts else None
|
tsq = func.to_tsquery(_TS_CONFIG, ts) if ts else None
|
||||||
|
if field not in _FIELD_COLUMNS and field != "all":
|
||||||
|
field = "all"
|
||||||
|
# `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for
|
||||||
|
# the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter.
|
||||||
|
grid_field = field != "episode"
|
||||||
|
recheck = (
|
||||||
|
_field_recheck if tsq is not None and field not in ("all", "episode") else None
|
||||||
|
)
|
||||||
|
|
||||||
selects = []
|
selects = []
|
||||||
if want_movies:
|
if want_movies and grid_field:
|
||||||
st = aliased(PlexState)
|
st = aliased(PlexState)
|
||||||
m_status = case(
|
m_status = case(
|
||||||
(st.status == "watched", "watched"),
|
(st.status == "watched", "watched"),
|
||||||
@@ -334,8 +367,10 @@ def unified_library(
|
|||||||
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
||||||
if tsq is not None:
|
if tsq is not None:
|
||||||
mq = mq.where(PlexItem.search_vector.op("@@")(tsq))
|
mq = mq.where(PlexItem.search_vector.op("@@")(tsq))
|
||||||
|
if recheck is not None:
|
||||||
|
mq = mq.where(recheck(PlexItem, field, tsq))
|
||||||
selects.append(mq)
|
selects.append(mq)
|
||||||
if want_shows:
|
if want_shows and grid_field:
|
||||||
agg = rollup.agg_subquery(db, user.id).subquery()
|
agg = rollup.agg_subquery(db, user.id).subquery()
|
||||||
tot = func.coalesce(agg.c.total, 0)
|
tot = func.coalesce(agg.c.total, 0)
|
||||||
wat = func.coalesce(agg.c.watched, 0)
|
wat = func.coalesce(agg.c.watched, 0)
|
||||||
@@ -377,69 +412,74 @@ def unified_library(
|
|||||||
sq = _apply_meta_filters(sq, PlexShow, p)
|
sq = _apply_meta_filters(sq, PlexShow, p)
|
||||||
if tsq is not None:
|
if tsq is not None:
|
||||||
sq = sq.where(PlexShow.search_vector.op("@@")(tsq))
|
sq = sq.where(PlexShow.search_vector.op("@@")(tsq))
|
||||||
|
if recheck is not None:
|
||||||
|
sq = sq.where(recheck(PlexShow, field, tsq))
|
||||||
selects.append(sq)
|
selects.append(sq)
|
||||||
|
|
||||||
if not selects:
|
|
||||||
return {
|
|
||||||
"scope": scope,
|
|
||||||
"total": 0,
|
|
||||||
"offset": offset,
|
|
||||||
"limit": limit,
|
|
||||||
"items": [],
|
|
||||||
"episodes": [],
|
|
||||||
}
|
|
||||||
union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
|
|
||||||
u = union.subquery()
|
|
||||||
total = db.scalar(select(func.count()).select_from(u)) or 0
|
|
||||||
|
|
||||||
if tsq is not None:
|
|
||||||
order = [u.c.rank.desc()]
|
|
||||||
else:
|
|
||||||
col = {
|
|
||||||
"title": func.lower(u.c.title),
|
|
||||||
"year": u.c.year,
|
|
||||||
"rating": u.c.rating,
|
|
||||||
"duration": u.c.duration_s,
|
|
||||||
"release": u.c.rel,
|
|
||||||
}.get(sort, u.c.added_at)
|
|
||||||
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
|
|
||||||
order.append(u.c.rk.desc())
|
|
||||||
rows = db.execute(
|
|
||||||
select(u).order_by(*order).offset(offset).limit(limit)
|
|
||||||
).all()
|
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for r in rows:
|
total = 0
|
||||||
if r.kind == "movie":
|
if selects:
|
||||||
items.append(
|
union = (
|
||||||
{
|
selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
|
||||||
"id": r.rk,
|
)
|
||||||
"type": "movie",
|
u = union.subquery()
|
||||||
"title": r.title,
|
total = db.scalar(select(func.count()).select_from(u)) or 0
|
||||||
"year": r.year,
|
|
||||||
"duration_seconds": r.duration_s,
|
|
||||||
"thumb": f"/api/plex/image/{r.rk}",
|
|
||||||
"playable": r.playable,
|
|
||||||
"status": r.status,
|
|
||||||
"position_seconds": r.position_seconds,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"id": r.rk,
|
|
||||||
"type": "show",
|
|
||||||
"title": r.title,
|
|
||||||
"year": r.year,
|
|
||||||
"thumb": f"/api/plex/image/{r.rk}",
|
|
||||||
"season_count": r.season_count,
|
|
||||||
"status": r.status,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if tsq is not None:
|
||||||
|
order = [u.c.rank.desc()]
|
||||||
|
else:
|
||||||
|
col = {
|
||||||
|
"title": func.lower(u.c.title),
|
||||||
|
"year": u.c.year,
|
||||||
|
"rating": u.c.rating,
|
||||||
|
"duration": u.c.duration_s,
|
||||||
|
"release": u.c.rel,
|
||||||
|
}.get(sort, u.c.added_at)
|
||||||
|
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
|
||||||
|
order.append(u.c.rk.desc())
|
||||||
|
rows = db.execute(
|
||||||
|
select(u).order_by(*order).offset(offset).limit(limit)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
if r.kind == "movie":
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": r.rk,
|
||||||
|
"type": "movie",
|
||||||
|
"title": r.title,
|
||||||
|
"year": r.year,
|
||||||
|
"duration_seconds": r.duration_s,
|
||||||
|
"thumb": f"/api/plex/image/{r.rk}",
|
||||||
|
"playable": r.playable,
|
||||||
|
"status": r.status,
|
||||||
|
"position_seconds": r.position_seconds,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": r.rk,
|
||||||
|
"type": "show",
|
||||||
|
"title": r.title,
|
||||||
|
"year": r.year,
|
||||||
|
"thumb": f"/api/plex/image/{r.rk}",
|
||||||
|
"season_count": r.season_count,
|
||||||
|
"status": r.status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Episode "grouped result" section: shown for Everywhere (full-vector match, today's behaviour)
|
||||||
|
# and for the Episode scope (title-only match, empty item grid). The title/people/summary scopes
|
||||||
|
# target the movie/show grid, so they suppress episodes.
|
||||||
episodes = []
|
episodes = []
|
||||||
if tsq is not None and want_shows:
|
if tsq is not None and want_shows and field in ("all", "episode"):
|
||||||
ep_st = aliased(PlexState)
|
ep_st = aliased(PlexState)
|
||||||
|
ep_vec = (
|
||||||
|
func.to_tsvector(_TS_CONFIG, func.coalesce(PlexItem.title, ""))
|
||||||
|
if field == "episode"
|
||||||
|
else PlexItem.search_vector
|
||||||
|
)
|
||||||
eps = db.execute(
|
eps = db.execute(
|
||||||
select(PlexItem, ep_st, PlexShow.title.label("show_title"))
|
select(PlexItem, ep_st, PlexShow.title.label("show_title"))
|
||||||
.outerjoin(
|
.outerjoin(
|
||||||
@@ -449,9 +489,9 @@ def unified_library(
|
|||||||
.where(
|
.where(
|
||||||
PlexItem.kind == "episode",
|
PlexItem.kind == "episode",
|
||||||
PlexItem.library_id.in_(show_lib_ids),
|
PlexItem.library_id.in_(show_lib_ids),
|
||||||
PlexItem.search_vector.op("@@")(tsq),
|
ep_vec.op("@@")(tsq),
|
||||||
)
|
)
|
||||||
.order_by(func.ts_rank(PlexItem.search_vector, tsq).desc())
|
.order_by(func.ts_rank(ep_vec, tsq).desc())
|
||||||
.limit(24)
|
.limit(24)
|
||||||
).all()
|
).all()
|
||||||
for e, s, show_title in eps:
|
for e, s, show_title in eps:
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""S2 · field-scoped search — the `field` param on /api/plex/library.
|
||||||
|
|
||||||
|
The search box's scope-pill narrows a full-text query to ONE facet of the row: Everywhere (`all`,
|
||||||
|
the untouched weighted `search_vector`), Titles, Cast & crew (`people`), Descriptions (`summary`),
|
||||||
|
or Episodes (episode rows only, title-only). These lock that each scope is a strict narrowing of
|
||||||
|
Everywhere, that `all` is unchanged from the pre-scope behaviour, and that the Episode scope empties
|
||||||
|
the movie/show grid and only applies where shows are in scope. DB lane (`db` fixture) — skipped
|
||||||
|
without Postgres (the `search_vector` GIN + per-field `to_tsvector` recheck are real Postgres FTS).
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
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 (
|
||||||
|
PlexItem,
|
||||||
|
PlexLibrary,
|
||||||
|
PlexSeason,
|
||||||
|
PlexShow,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(day: int) -> datetime:
|
||||||
|
return datetime(2024, 2, day, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db):
|
||||||
|
"""One rare token, `zephyr`, planted in exactly one facet per row so a scope's narrowing is
|
||||||
|
unambiguous: mt has it ONLY in the title, mp ONLY in cast (people_text), ms ONLY in the summary,
|
||||||
|
and episode ep ONLY in its title (its show carries none). Everywhere matches all four; each
|
||||||
|
scoped field matches exactly its one row."""
|
||||||
|
movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
|
||||||
|
db.add_all([movie_lib, show_lib])
|
||||||
|
db.flush()
|
||||||
|
mt = PlexItem(rating_key="mt", library_id=movie_lib.id, kind="movie",
|
||||||
|
title="Zephyr Rising", people_text="Alice Stone", summary="A calm coastal day.",
|
||||||
|
year=2001, added_at=_dt(1))
|
||||||
|
mp = PlexItem(rating_key="mp", library_id=movie_lib.id, kind="movie",
|
||||||
|
title="Quiet Harbor", people_text="Zephyr Vance", summary="A calm coastal day.",
|
||||||
|
year=2002, added_at=_dt(2))
|
||||||
|
ms = PlexItem(rating_key="ms", library_id=movie_lib.id, kind="movie",
|
||||||
|
title="Quiet Harbor Two", people_text="Alice Stone",
|
||||||
|
summary="A story about zephyr winds.", year=2003, added_at=_dt(3))
|
||||||
|
show = PlexShow(rating_key="s1", library_id=show_lib.id, title="Nimbus Show",
|
||||||
|
summary="Weather anthology.", people_text="Bob Cloud",
|
||||||
|
year=2004, added_at=_dt(4), child_count=1)
|
||||||
|
db.add_all([mt, mp, ms, show])
|
||||||
|
db.flush()
|
||||||
|
season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1)
|
||||||
|
db.add(season)
|
||||||
|
db.flush()
|
||||||
|
ep = PlexItem(rating_key="ep", library_id=show_lib.id, kind="episode", title="Zephyr Episode",
|
||||||
|
show_id=show.id, season_id=season.id, season_number=1, episode_number=1,
|
||||||
|
added_at=_dt(5))
|
||||||
|
db.add(ep)
|
||||||
|
user = User(email="plexfield@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
return {"user": 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 _ids(body):
|
||||||
|
return sorted(i["id"] for i in body["items"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldScopedSearch:
|
||||||
|
def test_everywhere_matches_every_facet(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json()
|
||||||
|
# title (mt), cast (mp), summary (ms) all land in the item grid; the episode is grouped out
|
||||||
|
assert _ids(body) == ["mp", "ms", "mt"]
|
||||||
|
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
|
||||||
|
|
||||||
|
def test_field_all_is_identical_to_default(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
default = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr").json()
|
||||||
|
scoped = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json()
|
||||||
|
assert _ids(default) == _ids(scoped) == ["mp", "ms", "mt"]
|
||||||
|
assert default["total"] == scoped["total"]
|
||||||
|
assert [e["id"] for e in default["episodes"]] == [e["id"] for e in scoped["episodes"]]
|
||||||
|
|
||||||
|
def test_title_scope_narrows_to_title_match(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=title").json()
|
||||||
|
assert _ids(body) == ["mt"]
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert body["episodes"] == [] # non-episode field scopes suppress the episode section
|
||||||
|
|
||||||
|
def test_people_scope_narrows_to_cast_match(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=people").json()
|
||||||
|
assert _ids(body) == ["mp"]
|
||||||
|
|
||||||
|
def test_summary_scope_narrows_to_description_match(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=summary").json()
|
||||||
|
assert _ids(body) == ["ms"]
|
||||||
|
|
||||||
|
def test_episode_scope_empties_grid_and_returns_episode(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode").json()
|
||||||
|
assert body["items"] == []
|
||||||
|
assert body["total"] == 0
|
||||||
|
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
|
||||||
|
|
||||||
|
def test_episode_scope_needs_shows_in_scope(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(
|
||||||
|
db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode&scope=movie"
|
||||||
|
).json()
|
||||||
|
# movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty
|
||||||
|
assert body["items"] == []
|
||||||
|
assert body["episodes"] == []
|
||||||
|
|
||||||
|
def test_unknown_field_falls_back_to_everywhere(self, db, monkeypatch):
|
||||||
|
s = _seed(db)
|
||||||
|
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=bogus").json()
|
||||||
|
assert _ids(body) == ["mp", "ms", "mt"]
|
||||||
@@ -5,11 +5,11 @@ import type { Me } from "../lib/api";
|
|||||||
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
||||||
import ModuleName from "./ModuleName";
|
import ModuleName from "./ModuleName";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
|
import PlexSearchBox from "./PlexSearchBox";
|
||||||
import SyncStatus from "./SyncStatus";
|
import SyncStatus from "./SyncStatus";
|
||||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
|
||||||
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||||
|
|
||||||
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
|
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
|
||||||
@@ -34,8 +34,6 @@ export default function Header({
|
|||||||
// "go to full history" jump is a ChannelsProvider action.
|
// "go to full history" jump is a ChannelsProvider action.
|
||||||
const { search: playlistsSearch } = usePlaylists();
|
const { search: playlistsSearch } = usePlaylists();
|
||||||
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
|
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
|
||||||
const { q: plexQ } = usePlex();
|
|
||||||
const { setQ: setPlexQ } = usePlexActions();
|
|
||||||
const { search: channelsSearch } = useChannels();
|
const { search: channelsSearch } = useChannels();
|
||||||
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
|
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
|
||||||
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
||||||
@@ -70,14 +68,7 @@ export default function Header({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (page === "plex" && me.plex_enabled) {
|
} else if (page === "plex" && me.plex_enabled) {
|
||||||
search = (
|
search = <PlexSearchBox />;
|
||||||
<SearchBar
|
|
||||||
value={plexQ}
|
|
||||||
placeholder={t("plex.searchPlaceholder")}
|
|
||||||
onChange={setPlexQ}
|
|
||||||
onGo={blur}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (page === "channels") {
|
} else if (page === "channels") {
|
||||||
search = (
|
search = (
|
||||||
<SearchBar
|
<SearchBar
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { healPlexField } from "../lib/plexSearch";
|
||||||
import { healPlexSort } from "../lib/plexSort";
|
import { healPlexSort } from "../lib/plexSort";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -90,8 +91,8 @@ export default function PlexBrowse() {
|
|||||||
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
|
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
|
||||||
// search term (q) comes from the shared header box; the rail hands off a playlist to open via the
|
// 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.
|
// shared `playlistOpen` slot, which we consume into a history subview then clear.
|
||||||
const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex();
|
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
|
||||||
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
|
const { setScope, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
|
||||||
const dq = useDebounced(q.trim(), 350);
|
const dq = useDebounced(q.trim(), 350);
|
||||||
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
|
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
|
||||||
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
|
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
|
||||||
@@ -100,6 +101,8 @@ export default function PlexBrowse() {
|
|||||||
// The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only
|
// The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only
|
||||||
// key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
|
// key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
|
||||||
const sortState = healPlexSort(sort, scope);
|
const sortState = healPlexSort(sort, scope);
|
||||||
|
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
|
||||||
|
const fieldState = healPlexField(field, scope);
|
||||||
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
||||||
|
|
||||||
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
|
||||||
@@ -112,6 +115,20 @@ export default function PlexBrowse() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [openPlaylist]);
|
}, [openPlaylist]);
|
||||||
|
|
||||||
|
// The header search typeahead picked a title → open it the same way a card click would (a movie's
|
||||||
|
// info page like the card's ⓘ, a show's drill-down), then clear the hand-off so it doesn't re-open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (jumpTo != null) {
|
||||||
|
sub.open(
|
||||||
|
jumpTo.type === "show"
|
||||||
|
? { kind: "show", id: jumpTo.id }
|
||||||
|
: { kind: "info", id: jumpTo.id },
|
||||||
|
);
|
||||||
|
setJumpTo(null);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [jumpTo]);
|
||||||
|
|
||||||
// Opening a show/player replaces the grid entirely (the component returns early below), so the
|
// Opening a show/player replaces the grid entirely (the component returns early below), so the
|
||||||
// scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
|
// scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
|
||||||
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
||||||
@@ -151,13 +168,14 @@ export default function PlexBrowse() {
|
|||||||
}, [sub]);
|
}, [sub]);
|
||||||
|
|
||||||
const browseQ = useInfiniteQuery({
|
const browseQ = useInfiniteQuery({
|
||||||
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters),
|
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState),
|
||||||
enabled: !!scope && sub.view.kind === "grid",
|
enabled: !!scope && sub.view.kind === "grid",
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
api.plexLibrary({
|
api.plexLibrary({
|
||||||
scope,
|
scope,
|
||||||
q: dq || undefined,
|
q: dq || undefined,
|
||||||
|
field: fieldState,
|
||||||
sort: sortState,
|
sort: sortState,
|
||||||
show,
|
show,
|
||||||
filters,
|
filters,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
||||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||||
|
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
|
||||||
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||||
@@ -19,13 +20,19 @@ import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
|||||||
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
|
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
|
||||||
// into (and is never greeted by a stale value from) the feed.
|
// into (and is never greeted by a stale value from) the feed.
|
||||||
|
|
||||||
|
// A "jump straight to this title" hand-off from the header typeahead: PlexBrowse opens a movie's
|
||||||
|
// info page (like the card's ⓘ) or a show's drill-down (like clicking a show card) by type.
|
||||||
|
export type PlexJumpTarget = { id: string; type: "movie" | "show" };
|
||||||
|
|
||||||
type PlexState = {
|
type PlexState = {
|
||||||
scope: string; // movie | show | both (unified cross-library scope)
|
scope: string; // movie | show | both (unified cross-library scope)
|
||||||
show: string;
|
show: string;
|
||||||
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
|
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
|
||||||
filters: PlexFilters;
|
filters: PlexFilters;
|
||||||
q: string;
|
q: string;
|
||||||
|
field: PlexSearchField; // search scope-pill; healed against `scope` at the point of use
|
||||||
playlistOpen: number | null;
|
playlistOpen: number | null;
|
||||||
|
jumpTo: PlexJumpTarget | null; // typeahead → grid hand-off ("open this title")
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PlexActions {
|
interface PlexActions {
|
||||||
@@ -34,7 +41,9 @@ interface PlexActions {
|
|||||||
setSort: (v: SortState) => void;
|
setSort: (v: SortState) => void;
|
||||||
setFilters: (f: PlexFilters) => void;
|
setFilters: (f: PlexFilters) => void;
|
||||||
setQ: (q: string) => void;
|
setQ: (q: string) => void;
|
||||||
|
setField: (f: PlexSearchField) => void;
|
||||||
setPlaylistOpen: (id: number | null) => void;
|
setPlaylistOpen: (id: number | null) => void;
|
||||||
|
setJumpTo: (t: PlexJumpTarget | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StateContext = createContext<PlexState>({
|
const StateContext = createContext<PlexState>({
|
||||||
@@ -43,7 +52,9 @@ const StateContext = createContext<PlexState>({
|
|||||||
sort: PLEX_SORT_FALLBACK,
|
sort: PLEX_SORT_FALLBACK,
|
||||||
filters: EMPTY_PLEX_FILTERS,
|
filters: EMPTY_PLEX_FILTERS,
|
||||||
q: "",
|
q: "",
|
||||||
|
field: "all",
|
||||||
playlistOpen: null,
|
playlistOpen: null,
|
||||||
|
jumpTo: null,
|
||||||
});
|
});
|
||||||
const ActionsContext = createContext<PlexActions>({
|
const ActionsContext = createContext<PlexActions>({
|
||||||
setScope: () => {},
|
setScope: () => {},
|
||||||
@@ -51,7 +62,9 @@ const ActionsContext = createContext<PlexActions>({
|
|||||||
setSort: () => {},
|
setSort: () => {},
|
||||||
setFilters: () => {},
|
setFilters: () => {},
|
||||||
setQ: () => {},
|
setQ: () => {},
|
||||||
|
setField: () => {},
|
||||||
setPlaylistOpen: () => {},
|
setPlaylistOpen: () => {},
|
||||||
|
setJumpTo: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function usePlex(): PlexState {
|
export function usePlex(): PlexState {
|
||||||
@@ -69,6 +82,7 @@ function readPlexScope(): string {
|
|||||||
const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all";
|
const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all";
|
||||||
const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}";
|
const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}";
|
||||||
const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? "";
|
const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? "";
|
||||||
|
const readPlexField = (): PlexSearchField => parsePlexField(getAccountRaw(LS.plexField));
|
||||||
// Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the
|
// Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the
|
||||||
// direction that used to live in plexFilters.sortDir — so an existing sort preference survives the
|
// direction that used to live in plexFilters.sortDir — so an existing sort preference survives the
|
||||||
// upgrade rather than snapping back to the default.
|
// upgrade rather than snapping back to the default.
|
||||||
@@ -118,7 +132,11 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [filtersRaw]);
|
}, [filtersRaw]);
|
||||||
// Survives F5 (persisted), unlike the feed search — see the scope note above.
|
// Survives F5 (persisted), unlike the feed search — see the scope note above.
|
||||||
const [q, setQState] = useState<string>(readPlexQ);
|
const [q, setQState] = useState<string>(readPlexQ);
|
||||||
|
const [field, setFieldState] = useState<PlexSearchField>(readPlexField);
|
||||||
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
|
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
|
||||||
|
// Ephemeral hand-off like `playlistOpen`: the header typeahead sets the title to jump to;
|
||||||
|
// PlexBrowse consumes it into an info/show history subview then clears it.
|
||||||
|
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
|
||||||
|
|
||||||
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
|
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
|
||||||
const markDirty = useAccountRehydrate(() => {
|
const markDirty = useAccountRehydrate(() => {
|
||||||
@@ -127,6 +145,7 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
|||||||
setSortState(readPlexSort());
|
setSortState(readPlexSort());
|
||||||
setFiltersRaw(readPlexFiltersRaw());
|
setFiltersRaw(readPlexFiltersRaw());
|
||||||
setQState(readPlexQ());
|
setQState(readPlexQ());
|
||||||
|
setFieldState(readPlexField());
|
||||||
});
|
});
|
||||||
|
|
||||||
const setScope = useCallback(
|
const setScope = useCallback(
|
||||||
@@ -170,14 +189,22 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
|||||||
},
|
},
|
||||||
[markDirty],
|
[markDirty],
|
||||||
);
|
);
|
||||||
|
const setField = useCallback(
|
||||||
|
(v: PlexSearchField) => {
|
||||||
|
markDirty();
|
||||||
|
setFieldState(v);
|
||||||
|
setAccountRaw(LS.plexField, v);
|
||||||
|
},
|
||||||
|
[markDirty],
|
||||||
|
);
|
||||||
|
|
||||||
const state = useMemo<PlexState>(
|
const state = useMemo<PlexState>(
|
||||||
() => ({ scope, show, sort, filters, q, playlistOpen }),
|
() => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }),
|
||||||
[scope, show, sort, filters, q, playlistOpen],
|
[scope, show, sort, filters, q, field, playlistOpen, jumpTo],
|
||||||
);
|
);
|
||||||
const actions = useMemo<PlexActions>(
|
const actions = useMemo<PlexActions>(
|
||||||
() => ({ setScope, setShow, setSort, setFilters, setQ, setPlaylistOpen }),
|
() => ({ setScope, setShow, setSort, setFilters, setQ, setField, setPlaylistOpen, setJumpTo }),
|
||||||
[setScope, setShow, setSort, setFilters, setQ],
|
[setScope, setShow, setSort, setFilters, setQ, setField],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ChevronDown, Film, Tv } from "lucide-react";
|
||||||
|
import SearchBar from "./SearchBar";
|
||||||
|
import ActionMenu from "./ActionMenu";
|
||||||
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
|
import {
|
||||||
|
availablePlexFields,
|
||||||
|
healPlexField,
|
||||||
|
plexFieldLabelKey,
|
||||||
|
} from "../lib/plexSearch";
|
||||||
|
|
||||||
|
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
|
||||||
|
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
|
||||||
|
// below it. Kept out of the generic SearchBar (and out of Header) so all Plex-specific wiring —
|
||||||
|
// provider state, the suggest query, the jump-to hand-off — lives in one place. Everything reuses
|
||||||
|
// existing platform pieces: ActionMenu (the pill), the /api/plex/library endpoint (limit=10 for the
|
||||||
|
// typeahead), and the provider's jumpTo hand-off (typeahead → PlexBrowse subview).
|
||||||
|
const MIN_CHARS = 2;
|
||||||
|
const SUGGEST_LIMIT = 10;
|
||||||
|
|
||||||
|
export default function PlexSearchBox() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { q, scope, field } = usePlex();
|
||||||
|
const { setQ, setField, setJumpTo } = usePlexActions();
|
||||||
|
const activeField = healPlexField(field, scope);
|
||||||
|
const dq = useDebounced(q.trim(), 200);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [hi, setHi] = useState(-1); // highlighted suggestion index (-1 = none)
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// The Episode scope surfaces episodes in the grid's grouped section, not as title cards, so the
|
||||||
|
// titles-only typeahead has nothing to show there — suppress it rather than show "no results".
|
||||||
|
const suggestEnabled = focused && dq.length >= MIN_CHARS && activeField !== "episode";
|
||||||
|
const suggestQ = useQuery({
|
||||||
|
queryKey: qk.plexSuggest(scope, dq, activeField),
|
||||||
|
enabled: suggestEnabled,
|
||||||
|
queryFn: () =>
|
||||||
|
api.plexLibrary({ scope, q: dq, field: activeField, limit: SUGGEST_LIMIT }),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []).slice(0, SUGGEST_LIMIT) : [];
|
||||||
|
const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0);
|
||||||
|
|
||||||
|
// A fresh query resets the highlight; keep it in range if the list shrank.
|
||||||
|
useEffect(() => setHi(-1), [dq, activeField, scope]);
|
||||||
|
|
||||||
|
const pick = (i: number) => {
|
||||||
|
const c = suggestions[i];
|
||||||
|
if (!c) return;
|
||||||
|
setJumpTo({ id: c.id, type: c.type === "show" ? "show" : "movie" });
|
||||||
|
setFocused(false);
|
||||||
|
setHi(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fields = availablePlexFields(scope);
|
||||||
|
const pillLabel = t(plexFieldLabelKey(activeField));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchBar
|
||||||
|
value={q}
|
||||||
|
placeholder={t("plex.searchPlaceholder")}
|
||||||
|
onChange={setQ}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
// Suggestion rows preventDefault on mousedown so the input keeps focus through the click; any
|
||||||
|
// other blur (Tab, the pill, clicking away) closes the panel.
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!open) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHi((h) => Math.min(h + 1, suggestions.length - 1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHi((h) => Math.max(h - 1, -1));
|
||||||
|
} else if (e.key === "Enter" && hi >= 0) {
|
||||||
|
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
|
||||||
|
pick(hi);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setFocused(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
leading={
|
||||||
|
<ActionMenu
|
||||||
|
ariaLabel={t("plex.searchField.aria")}
|
||||||
|
className="shrink-0 inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
|
||||||
|
trigger={
|
||||||
|
<>
|
||||||
|
<span className="max-w-28 truncate">{pillLabel}</span>
|
||||||
|
<ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
items={fields.map((f) => ({
|
||||||
|
id: f,
|
||||||
|
label: t(plexFieldLabelKey(f)),
|
||||||
|
onSelect: () => setField(f),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
below={
|
||||||
|
open ? (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
role="listbox"
|
||||||
|
aria-label={t("plex.suggest.aria")}
|
||||||
|
className="glass-menu absolute left-0 right-0 top-full z-20 mt-2 max-h-[70vh] overflow-y-auto rounded-2xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||||
|
>
|
||||||
|
{suggestions.length === 0 && suggestQ.isFetching && (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
|
||||||
|
)}
|
||||||
|
{suggestions.map((c, i) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={hi === i}
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
|
||||||
|
onMouseEnter={() => setHi(i)}
|
||||||
|
onClick={() => pick(i)}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-xl p-1.5 text-left transition ${
|
||||||
|
hi === i ? "bg-border/60" : "hover:bg-border/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={c.thumb}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-12 w-8 shrink-0 rounded-md object-cover bg-border/40"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm">{c.title}</span>
|
||||||
|
{c.year != null && (
|
||||||
|
<span className="shrink-0 text-xs text-muted tabular-nums">{c.year}</span>
|
||||||
|
)}
|
||||||
|
{c.type === "show" ? (
|
||||||
|
<Tv className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Film className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type FocusEventHandler, type KeyboardEventHandler, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
|
|
||||||
@@ -6,6 +6,12 @@ import { Search, X } from "lucide-react";
|
|||||||
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
|
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
|
||||||
// clear button, and a "Go" trigger (Enter also fires it). The feed passes its live-YouTube
|
// clear button, and a "Go" trigger (Enter also fires it). The feed passes its live-YouTube
|
||||||
// escalation as the Go action; the other modules filter as you type and Go just commits/blurs.
|
// escalation as the Go action; the other modules filter as you type and Go just commits/blurs.
|
||||||
|
//
|
||||||
|
// Optional slots keep the box reusable while letting one module enrich it: `leading` replaces the
|
||||||
|
// search icon with a control (Plex's search scope-pill), and `below` renders an absolutely
|
||||||
|
// positioned panel attached under the box (Plex's instant typeahead results). `onFocus`/`onBlur`/
|
||||||
|
// `onKeyDown` pass through to the input so a caller can drive that panel (open on focus, arrow-key
|
||||||
|
// navigation); the built-in Enter→Go fires only if the caller didn't `preventDefault()` first.
|
||||||
export default function SearchBar({
|
export default function SearchBar({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -15,6 +21,11 @@ export default function SearchBar({
|
|||||||
goIcon,
|
goIcon,
|
||||||
goDisabled,
|
goDisabled,
|
||||||
goTitle,
|
goTitle,
|
||||||
|
leading,
|
||||||
|
below,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
onKeyDown,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (q: string) => void;
|
onChange: (q: string) => void;
|
||||||
@@ -25,46 +36,57 @@ export default function SearchBar({
|
|||||||
goIcon?: ReactNode;
|
goIcon?: ReactNode;
|
||||||
goDisabled?: boolean;
|
goDisabled?: boolean;
|
||||||
goTitle?: string;
|
goTitle?: string;
|
||||||
|
leading?: ReactNode;
|
||||||
|
below?: ReactNode;
|
||||||
|
onFocus?: FocusEventHandler<HTMLInputElement>;
|
||||||
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||||
|
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fire = () => {
|
const fire = () => {
|
||||||
if (onGo && !goDisabled) onGo();
|
if (onGo && !goDisabled) onGo();
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
|
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl">
|
||||||
<Search className="w-4 h-4 shrink-0 text-muted" />
|
<div className="h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
|
||||||
<input
|
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
|
||||||
value={value}
|
<input
|
||||||
onChange={(e) => onChange(e.target.value)}
|
value={value}
|
||||||
onKeyDown={(e) => {
|
onChange={(e) => onChange(e.target.value)}
|
||||||
if (e.key === "Enter") fire();
|
onFocus={onFocus}
|
||||||
}}
|
onBlur={onBlur}
|
||||||
placeholder={placeholder}
|
onKeyDown={(e) => {
|
||||||
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
|
onKeyDown?.(e);
|
||||||
/>
|
if (e.key === "Enter" && !e.defaultPrevented) fire();
|
||||||
{value && (
|
}}
|
||||||
<button
|
placeholder={placeholder}
|
||||||
onClick={() => onChange("")}
|
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
|
||||||
title={t("header.clearSearch")}
|
/>
|
||||||
aria-label={t("header.clearSearch")}
|
{value && (
|
||||||
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
|
<button
|
||||||
>
|
onClick={() => onChange("")}
|
||||||
<X className="w-4 h-4" />
|
title={t("header.clearSearch")}
|
||||||
</button>
|
aria-label={t("header.clearSearch")}
|
||||||
)}
|
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
|
||||||
{onGo && (
|
>
|
||||||
<button
|
<X className="w-4 h-4" />
|
||||||
onClick={fire}
|
</button>
|
||||||
disabled={goDisabled}
|
)}
|
||||||
title={goTitle ?? goLabel ?? t("header.goTip")}
|
{onGo && (
|
||||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
|
<button
|
||||||
>
|
onClick={fire}
|
||||||
{goIcon}
|
disabled={goDisabled}
|
||||||
<span className={goIcon ? "hidden md:inline" : undefined}>
|
title={goTitle ?? goLabel ?? t("header.goTip")}
|
||||||
{goLabel ?? t("header.go")}
|
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
|
||||||
</span>
|
>
|
||||||
</button>
|
{goIcon}
|
||||||
)}
|
<span className={goIcon ? "hidden md:inline" : undefined}>
|
||||||
|
{goLabel ?? t("header.go")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{below}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,17 @@
|
|||||||
"backToFeed": "YouTube feed",
|
"backToFeed": "YouTube feed",
|
||||||
"backToLibrary": "Back to library",
|
"backToLibrary": "Back to library",
|
||||||
"searchPlaceholder": "Search Plex…",
|
"searchPlaceholder": "Search Plex…",
|
||||||
|
"searchField": {
|
||||||
|
"aria": "Search scope",
|
||||||
|
"all": "Everywhere",
|
||||||
|
"title": "Titles",
|
||||||
|
"people": "Cast & crew",
|
||||||
|
"summary": "Descriptions",
|
||||||
|
"episode": "Episodes"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"aria": "Search suggestions"
|
||||||
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"added": "Recently added",
|
"added": "Recently added",
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
|
|||||||
@@ -3,6 +3,17 @@
|
|||||||
"backToFeed": "YouTube feed",
|
"backToFeed": "YouTube feed",
|
||||||
"backToLibrary": "Vissza a könyvtárhoz",
|
"backToLibrary": "Vissza a könyvtárhoz",
|
||||||
"searchPlaceholder": "Keresés a Plexben…",
|
"searchPlaceholder": "Keresés a Plexben…",
|
||||||
|
"searchField": {
|
||||||
|
"aria": "Keresési hatókör",
|
||||||
|
"all": "Mindenhol",
|
||||||
|
"title": "Címek",
|
||||||
|
"people": "Szereplők",
|
||||||
|
"summary": "Leírások",
|
||||||
|
"episode": "Epizódok"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"aria": "Keresési javaslatok"
|
||||||
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"added": "Nemrég hozzáadott",
|
"added": "Nemrég hozzáadott",
|
||||||
"title": "Cím",
|
"title": "Cím",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// touch only the shared `req`/`beacon` from core plus the local `appendPlexFilters` helper.
|
// touch only the shared `req`/`beacon` from core plus the local `appendPlexFilters` helper.
|
||||||
import { req, beacon } from "./core";
|
import { req, beacon } from "./core";
|
||||||
import { type SortState } from "../columnSort";
|
import { type SortState } from "../columnSort";
|
||||||
|
import { type PlexSearchField } from "../plexSearch";
|
||||||
|
|
||||||
// Admin: result of testing the Plex server connection (also drives the library-picker).
|
// Admin: result of testing the Plex server connection (also drives the library-picker).
|
||||||
interface PlexSection {
|
interface PlexSection {
|
||||||
@@ -275,6 +276,7 @@ export const plexApi = {
|
|||||||
plexLibrary: (p: {
|
plexLibrary: (p: {
|
||||||
scope: string; // movie | show | both
|
scope: string; // movie | show | both
|
||||||
q?: string;
|
q?: string;
|
||||||
|
field?: PlexSearchField; // search scope-pill (all | title | people | summary | episode)
|
||||||
sort?: SortState;
|
sort?: SortState;
|
||||||
show?: string;
|
show?: string;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
@@ -283,6 +285,7 @@ export const plexApi = {
|
|||||||
}): Promise<PlexUnifiedResult> => {
|
}): Promise<PlexUnifiedResult> => {
|
||||||
const u = new URLSearchParams({ scope: p.scope });
|
const u = new URLSearchParams({ scope: p.scope });
|
||||||
if (p.q) u.set("q", p.q);
|
if (p.q) u.set("q", p.q);
|
||||||
|
if (p.field && p.field !== "all") u.set("field", p.field);
|
||||||
if (p.sort) {
|
if (p.sort) {
|
||||||
u.set("sort", p.sort.key);
|
u.set("sort", p.sort.key);
|
||||||
u.set("sort_dir", p.sort.dir);
|
u.set("sort_dir", p.sort.dir);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// The search-box scope-pill (S2): one field-scope active at a time, narrowing the Plex full-text
|
||||||
|
// search to a single facet of the row. Kept in one module so the provider (state), the header
|
||||||
|
// SearchBar (the pill UI), and the api layer (the `field` query param) share one source of truth.
|
||||||
|
|
||||||
|
export type PlexSearchField = "all" | "title" | "people" | "summary" | "episode";
|
||||||
|
|
||||||
|
export const PLEX_SEARCH_FIELDS: PlexSearchField[] = [
|
||||||
|
"all",
|
||||||
|
"title",
|
||||||
|
"people",
|
||||||
|
"summary",
|
||||||
|
"episode",
|
||||||
|
];
|
||||||
|
|
||||||
|
// i18n label key per scope (see plex.searchField.* in the locale files).
|
||||||
|
export const plexFieldLabelKey = (f: PlexSearchField): string => `plex.searchField.${f}`;
|
||||||
|
|
||||||
|
// Episodes only exist under show libraries, so the Episode scope is offered only when shows are in
|
||||||
|
// scope (show | both) — mirroring the backend, which returns nothing for episode+movie-only.
|
||||||
|
export function availablePlexFields(scope: string): PlexSearchField[] {
|
||||||
|
return scope === "movie"
|
||||||
|
? PLEX_SEARCH_FIELDS.filter((f) => f !== "episode")
|
||||||
|
: PLEX_SEARCH_FIELDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heal a stored/selected field against the current scope: a movie-only scope can't hold Episode, so
|
||||||
|
// it falls back to Everywhere (analogous to healPlexSort). Applied at the point of use.
|
||||||
|
export function healPlexField(field: PlexSearchField, scope: string): PlexSearchField {
|
||||||
|
return availablePlexFields(scope).includes(field) ? field : "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePlexField(v: string | null | undefined): PlexSearchField {
|
||||||
|
return v && (PLEX_SEARCH_FIELDS as string[]).includes(v) ? (v as PlexSearchField) : "all";
|
||||||
|
}
|
||||||
@@ -91,6 +91,7 @@ export const qk = {
|
|||||||
plex: () => ["plex"] as const,
|
plex: () => ["plex"] as const,
|
||||||
plexWatchLink: () => ["plex-watch-link"] as const,
|
plexWatchLink: () => ["plex-watch-link"] as const,
|
||||||
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
|
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
|
||||||
|
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest],
|
||||||
plexShow: (showId?: string) =>
|
plexShow: (showId?: string) =>
|
||||||
showId !== undefined ? (["plex-show", showId] as const) : (["plex-show"] as const),
|
showId !== undefined ? (["plex-show", showId] as const) : (["plex-show"] as const),
|
||||||
plexItem: (id: string) => ["plex-item", id] as const,
|
plexItem: (id: string) => ["plex-item", id] as const,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const LS = {
|
|||||||
plexSort: "siftlode.plexSort",
|
plexSort: "siftlode.plexSort",
|
||||||
plexFilters: "siftlode.plexFilters",
|
plexFilters: "siftlode.plexFilters",
|
||||||
plexQ: "siftlode.plexQ",
|
plexQ: "siftlode.plexQ",
|
||||||
|
plexField: "siftlode.plexField",
|
||||||
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
|
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
|
||||||
plexPlayerPrefs: "siftlode.plexPlayerPrefs",
|
plexPlayerPrefs: "siftlode.plexPlayerPrefs",
|
||||||
// The YouTube modal player's volume (`{ volume: 0–100 }`; 0 IS muted — there is no separate
|
// The YouTube modal player's volume (`{ volume: 0–100 }`; 0 IS muted — there is no separate
|
||||||
|
|||||||
Reference in New Issue
Block a user