merge: Plex search ephemeral state + empty-with-filters hint (dev-only)
This commit is contained in:
commit
c53a65d9d4
6 changed files with 57 additions and 10 deletions
|
|
@ -260,6 +260,12 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}, [plexFiltersRaw]);
|
}, [plexFiltersRaw]);
|
||||||
const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f));
|
const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f));
|
||||||
|
// The Plex library search term is its OWN ephemeral state — deliberately NOT the persisted feed
|
||||||
|
// `filters.q`. Sharing that box meant a Plex search leaked into the feed and, being persisted,
|
||||||
|
// greeted you with a stale query (colliding with a persisted collection filter → confusing
|
||||||
|
// "0 matches") after a reload. Kept in App so it survives page switches within a session, but
|
||||||
|
// resets on reload.
|
||||||
|
const [plexQ, setPlexQ] = useState("");
|
||||||
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
||||||
// there to see a specific set (the header's "without full history" link).
|
// there to see a specific set (the header's "without full history" link).
|
||||||
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
||||||
|
|
@ -767,6 +773,8 @@ export default function App() {
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
|
plexQ={plexQ}
|
||||||
|
setPlexQ={setPlexQ}
|
||||||
page={page}
|
page={page}
|
||||||
onYtSearch={enterYtSearch}
|
onYtSearch={enterYtSearch}
|
||||||
/>
|
/>
|
||||||
|
|
@ -819,8 +827,8 @@ export default function App() {
|
||||||
/>
|
/>
|
||||||
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
||||||
<PlexBrowse
|
<PlexBrowse
|
||||||
q={filters.q}
|
q={plexQ}
|
||||||
onClearSearch={() => setFilters({ ...filters, q: "" })}
|
onClearSearch={() => setPlexQ("")}
|
||||||
library={plexLib}
|
library={plexLib}
|
||||||
show={plexShowFilter}
|
show={plexShowFilter}
|
||||||
sort={plexSort}
|
sort={plexSort}
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,17 @@ export default function Header({
|
||||||
me,
|
me,
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
|
plexQ,
|
||||||
|
setPlexQ,
|
||||||
page,
|
page,
|
||||||
onYtSearch,
|
onYtSearch,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
|
// Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not.
|
||||||
|
plexQ: string;
|
||||||
|
setPlexQ: (q: string) => void;
|
||||||
page: Page;
|
page: Page;
|
||||||
// Trigger a live YouTube search for the current term (hidden for the demo account, which
|
// Trigger a live YouTube search for the current term (hidden for the demo account, which
|
||||||
// can't spend the shared quota).
|
// can't spend the shared quota).
|
||||||
|
|
@ -25,11 +30,14 @@ export default function Header({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const canSearchYt = !me.is_demo;
|
const canSearchYt = !me.is_demo;
|
||||||
const trimmedQ = filters.q.trim();
|
|
||||||
// The search box serves both the YouTube feed and the Plex module (integrated search); the live
|
// The search box serves both the YouTube feed and the Plex module (integrated search); the live
|
||||||
// YouTube-search escalation (Enter / button) is feed-only.
|
// YouTube-search escalation (Enter / button) is feed-only. On Plex it drives `plexQ`, on the feed
|
||||||
|
// the persisted `filters.q`.
|
||||||
const isSearchPage = page === "feed" || page === "plex";
|
const isSearchPage = page === "feed" || page === "plex";
|
||||||
|
const isPlex = page === "plex";
|
||||||
const isYtCapable = page === "feed" && canSearchYt;
|
const isYtCapable = page === "feed" && canSearchYt;
|
||||||
|
const searchValue = isPlex ? plexQ : filters.q;
|
||||||
|
const trimmedQ = searchValue.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
||||||
|
|
@ -38,14 +46,18 @@ export default function Header({
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||||
<input
|
<input
|
||||||
value={filters.q}
|
value={searchValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const q = e.target.value;
|
const q = e.target.value;
|
||||||
|
if (isPlex) {
|
||||||
|
setPlexQ(q);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// When a search first appears, rank the feed by relevance — set atomically with
|
// When a search first appears, rank the feed by relevance — set atomically with
|
||||||
// the query (race-free vs. per-keystroke updates). Only overrides the default
|
// the query (race-free vs. per-keystroke updates). Only overrides the default
|
||||||
// "newest" sort; a custom sort the user chose is left alone. Cleared in Feed.
|
// "newest" sort; a custom sort the user chose is left alone. Cleared in Feed.
|
||||||
const startSearch =
|
const startSearch =
|
||||||
page === "feed" && !filters.q.trim() && !!q.trim() && filters.sort === "newest";
|
!filters.q.trim() && !!q.trim() && filters.sort === "newest";
|
||||||
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
|
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|
@ -56,9 +68,9 @@ export default function Header({
|
||||||
placeholder={page === "plex" ? t("plex.searchPlaceholder") : t("header.searchPlaceholder")}
|
placeholder={page === "plex" ? t("plex.searchPlaceholder") : t("header.searchPlaceholder")}
|
||||||
className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent"
|
className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent"
|
||||||
/>
|
/>
|
||||||
{filters.q && (
|
{searchValue && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilters({ ...filters, q: "" })}
|
onClick={() => (isPlex ? setPlexQ("") : setFilters({ ...filters, q: "" }))}
|
||||||
title={t("header.clearSearch")}
|
title={t("header.clearSearch")}
|
||||||
aria-label={t("header.clearSearch")}
|
aria-label={t("header.clearSearch")}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-muted hover:text-fg hover:bg-border/60 transition"
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-muted hover:text-fg hover:bg-border/60 transition"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,14 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSP
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, CheckCircle2, Info, ListPlus, Play } from "lucide-react";
|
import { ArrowLeft, CheckCircle2, Info, ListPlus, Play } from "lucide-react";
|
||||||
import { api, type PlexCard, type PlexFilters, type PlexPerson } from "../lib/api";
|
import {
|
||||||
|
api,
|
||||||
|
EMPTY_PLEX_FILTERS,
|
||||||
|
plexFilterCount,
|
||||||
|
type PlexCard,
|
||||||
|
type PlexFilters,
|
||||||
|
type PlexPerson,
|
||||||
|
} from "../lib/api";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { useHistorySubview } from "../lib/history";
|
import { useHistorySubview } from "../lib/history";
|
||||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||||
|
|
@ -267,7 +274,21 @@ export default function PlexBrowse({
|
||||||
{browseQ.isLoading ? (
|
{browseQ.isLoading ? (
|
||||||
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
|
dq && plexFilterCount(filters) > 0 ? (
|
||||||
|
// A search that comes up empty WHILE filters are active is usually the filters, not the
|
||||||
|
// query — say so and offer a one-click escape, instead of a bare "No matches".
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm text-muted">
|
||||||
|
<span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters(EMPTY_PLEX_FILTERS)}
|
||||||
|
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
{t("plex.filter.clearAll")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||||
{items.map((c) => (
|
{items.map((c) => (
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@
|
||||||
"count": "{{count}} Titel",
|
"count": "{{count}} Titel",
|
||||||
"searchCount": "{{count}} Treffer",
|
"searchCount": "{{count}} Treffer",
|
||||||
"noMatches": "Keine Treffer.",
|
"noMatches": "Keine Treffer.",
|
||||||
|
"noMatchesFiltered_one": "Keine Treffer — ein Filter schränkt die Ergebnisse zusätzlich ein.",
|
||||||
|
"noMatchesFiltered_other": "Keine Treffer — {{count}} Filter schränken die Ergebnisse zusätzlich ein.",
|
||||||
"loading": "Wird geladen…",
|
"loading": "Wird geladen…",
|
||||||
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
|
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
|
||||||
"loadMore": "Mehr laden",
|
"loadMore": "Mehr laden",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@
|
||||||
"count": "{{count}} titles",
|
"count": "{{count}} titles",
|
||||||
"searchCount": "{{count}} matches",
|
"searchCount": "{{count}} matches",
|
||||||
"noMatches": "No matches.",
|
"noMatches": "No matches.",
|
||||||
|
"noMatchesFiltered_one": "No matches — a filter is also narrowing the results.",
|
||||||
|
"noMatchesFiltered_other": "No matches — {{count}} filters are also narrowing the results.",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
|
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
|
||||||
"loadMore": "Load more",
|
"loadMore": "Load more",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@
|
||||||
"count": "{{count}} cím",
|
"count": "{{count}} cím",
|
||||||
"searchCount": "{{count}} találat",
|
"searchCount": "{{count}} találat",
|
||||||
"noMatches": "Nincs találat.",
|
"noMatches": "Nincs találat.",
|
||||||
|
"noMatchesFiltered_one": "Nincs találat — egy szűrő is szűkíti az eredményt.",
|
||||||
|
"noMatchesFiltered_other": "Nincs találat — {{count}} szűrő is szűkíti az eredményt.",
|
||||||
"loading": "Betöltés…",
|
"loading": "Betöltés…",
|
||||||
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
|
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
|
||||||
"loadMore": "Több betöltése",
|
"loadMore": "Több betöltése",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue