Files
siftlode/frontend/src/components/PlexSidebar.tsx
T
peter 0559e1a503 refactor(input): R10 S3b — one input recipe, ring focus everywhere
Unify text inputs onto the form.tsx inputCls look (surface bg, rounded-lg, 2px accent
RING focus), replacing the settings-family border-focus style app-wide:
- deleted 3 duplicate local `const inputCls` (KeyGate/SettingsPanel/SetupWizard) → import
  the shared one; a 4th (Welcome, py-2.5) kept local but re-styled to match.
- ~30 inline settings inputs: default full-size fields → inputCls; size variants (pager/
  table-filter/chat textarea/plex/glass-card) keep their width/radius/padding but swap
  bg-card→bg-surface and border-focus→ring-focus. glass-card/bg-transparent bgs left as-is.
- refreshed the form.tsx doc-comment (the old two-style split is gone).

The ring is the more visible / a11y-correct focus cue, matching R9's focus foundation.
2026-08-09 17:43:42 +02:00

553 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, type ReactNode } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { useDebounced } from "../lib/useDebounced";
import { useScrollFade } from "../lib/useScrollFade";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import { usePlex, usePlexActions } from "./PlexProvider";
// 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
// set; shows keep library + sort. State is owned by App (per-account persisted); facets come from
// the backend so the panel only offers what the library actually contains.
type Props = {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
};
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] 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 }[] = [
{ key: "short", min: null, max: 90 * 60 },
{ key: "mid", min: 90 * 60, max: 120 * 60 },
{ key: "long", min: 120 * 60, max: null },
];
// The any/all combine toggle for a multi-value filter (genres, actors). Same segmented-pill look as
// the feed rail's tag MATCH toggle (rounded-full track + inset active pill), not a boxed button row.
function MatchToggle({
mode,
onChange,
}: {
mode: "any" | "all";
onChange: (m: "any" | "all") => void;
}) {
const { t } = useTranslation();
return (
<div className="mb-1.5 flex items-center justify-between">
<span className="text-3xs uppercase tracking-wide text-muted">
{t("plex.filter.matchLabel")}
</span>
<div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-3xs"
role="group"
aria-label={t("plex.filter.matchLabel")}
>
{(["any", "all"] as const).map((m) => (
<button
key={m}
onClick={() => onChange(m)}
aria-pressed={mode === m}
className={`rounded-full px-2 py-0.5 transition ${
mode === m ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{t(`plex.filter.match.${m}`)}
</button>
))}
</div>
</div>
);
}
export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse }: Props) {
const { t } = useTranslation();
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
// 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, sort, filters } = usePlex();
const {
setScope,
setShow,
setSort,
setFilters,
setPlaylistOpen: onOpenPlaylist,
} = usePlexActions();
const collFade = useScrollFade();
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
const [editing, setEditing] = useState(false);
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 { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
const facetsQ = useQuery({
queryKey: qk.plexFacets(scope, show, facetKey),
queryFn: () => api.plexFacets(scope, filters, show),
enabled: !!scope,
placeholderData: (prev) => prev,
});
const facets = facetsQ.data;
const [collSearch, setCollSearch] = useState("");
const collSearchDeb = useDebounced(collSearch.trim(), 300);
const collectionsQ = useQuery({
queryKey: qk.plexCollections("union", collSearchDeb),
queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined),
enabled: !filters.collection,
});
const collections = collectionsQ.data?.collections ?? [];
const fCount = plexFilterCount(filters);
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "title" ? 1 : 0);
const anyActive = activeCount > 0;
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
const toggleIn = (arr: string[], v: string) =>
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
const clearAll = () => {
setFilters(EMPTY_PLEX_FILTERS);
setShow("all");
setSort("title");
};
const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) =>
t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)),
);
const durBucketKey = DURATION_BUCKETS.find(
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
)?.key;
const peopleActive =
filters.directors.length + filters.actors.length + filters.studios.length > 0;
// Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed,
// so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved
// layout and lets the user reorder / hide / collapse them.
const items: PanelItem[] = [
{
id: "scope",
title: t("plex.filter.scope"),
body: (
<div className="grid grid-cols-3 gap-1">
{(["both", "movie", "show"] as const).map((s) => (
<button
key={s}
onClick={() => setScope(s)}
className={`min-w-0 px-1.5 py-1.5 rounded-lg text-xs font-medium truncate transition ${
s === scope ? "bg-accent text-accent-fg" : "glass-card glass-hover"
}`}
>
{t(`plex.filter.scopeOpt.${s}`)}
</button>
))}
</div>
),
},
{
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: (
<ChipRow>
{SHOW_OPTS.map((s) => (
<Chip key={s} active={show === s} onClick={() => setShow(s)}>
{t(`plex.filter.showOpt.${s}`)}
</Chip>
))}
</ChipRow>
),
},
{
id: "sort",
title: t("plex.filter.sort"),
body: (
<>
<ChipRow>
{sorts.map((s) => (
<Chip key={s} active={sort === s} onClick={() => setSort(s)}>
{t(`plex.sort.${s}`)}
</Chip>
))}
</ChipRow>
<div className="mt-1.5 flex gap-1">
{(["asc", "desc"] as const).map((d) => (
<Chip
key={d}
active={(filters.sortDir ?? "asc") === d}
onClick={() => patch({ sortDir: d })}
>
{t(`plex.filter.dir.${d}`)}
</Chip>
))}
</div>
</>
),
},
...(peopleActive
? [
{
id: "people",
title: t("plex.filter.active"),
body: (
<div className="flex flex-wrap gap-1.5">
{filters.directors.map((d) => (
<RemovableChip
key={`d:${d}`}
label={d}
onRemove={() => patch({ directors: filters.directors.filter((x) => x !== d) })}
/>
))}
{filters.actors.length > 1 && (
<div className="w-full">
<MatchToggle
mode={filters.actorMode ?? "any"}
onChange={(m) => patch({ actorMode: m })}
/>
</div>
)}
{filters.actors.map((a) => (
<RemovableChip
key={`a:${a}`}
label={a}
onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })}
/>
))}
{filters.studios.map((s) => (
<RemovableChip
key={`s:${s}`}
label={s}
onRemove={() => patch({ studios: filters.studios.filter((x) => x !== s) })}
/>
))}
</div>
),
},
]
: []),
{
id: "collection",
title: t("plex.filter.collection"),
body: filters.collection ? (
<div className="flex flex-wrap gap-1.5">
<RemovableChip
label={filters.collectionTitle || filters.collection}
onRemove={() => patch({ collection: null, collectionTitle: null })}
/>
</div>
) : (
<div>
<input
value={collSearch}
onChange={(e) => setCollSearch(e.target.value)}
placeholder={t("plex.filter.collectionSearch")}
className="mb-1.5 w-full rounded-lg border border-border bg-surface px-2 py-1 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<div
ref={collFade.ref}
style={collFade.style}
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto no-scrollbar"
>
{collections.map((c) => (
<button
key={c.id}
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
>
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
<span className="flex-1 truncate">{c.title}</span>
<span className="text-xs text-muted">{c.child_count}</span>
</button>
))}
{collections.length === 0 && (
<span className="px-2 py-1 text-xs text-muted">
{t("plex.filter.collectionEmpty")}
</span>
)}
</div>
</div>
),
},
{
id: "rating",
title: t("plex.filter.rating"),
body: (
<ChipRow>
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
{t("plex.filter.any")}
</Chip>
{RATING_STEPS.map((r) => (
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
{r}+
</Chip>
))}
</ChipRow>
),
},
...(facets && facets.genres.length > 0
? [
{
id: "genre",
title: t("plex.filter.genre"),
body: (
<>
{filters.genres.length > 1 && (
<MatchToggle
mode={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
)}
<ChipRow>
{facets.genres.map((g) => (
<Chip
key={g.value}
active={filters.genres.includes(g.value)}
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
>
{g.value}
</Chip>
))}
</ChipRow>
</>
),
},
]
: []),
{
id: "year",
title: t("plex.filter.year"),
body: (
<div className="flex items-center gap-2">
<NumberInput
value={filters.yearMin ?? null}
placeholder={facets?.year_min ?? undefined}
onChange={(v) => patch({ yearMin: v })}
/>
<span className="text-muted text-xs">–</span>
<NumberInput
value={filters.yearMax ?? null}
placeholder={facets?.year_max ?? undefined}
onChange={(v) => patch({ yearMax: v })}
/>
</div>
),
},
...(scope === "movie"
? [
{
id: "duration",
title: t("plex.filter.duration"),
body: (
<ChipRow>
<Chip
active={
!durBucketKey && filters.durationMin == null && filters.durationMax == null
}
onClick={() => patch({ durationMin: null, durationMax: null })}
>
{t("plex.filter.any")}
</Chip>
{DURATION_BUCKETS.map((b) => (
<Chip
key={b.key}
active={durBucketKey === b.key}
onClick={() => patch({ durationMin: b.min, durationMax: b.max })}
>
{t(`plex.filter.durationOpt.${b.key}`)}
</Chip>
))}
</ChipRow>
),
},
]
: []),
{
id: "added",
title: t("plex.filter.added"),
body: (
<ChipRow>
<Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}>
{t("plex.filter.any")}
</Chip>
{ADDED_OPTS.map((a) => (
<Chip
key={a}
active={filters.addedWithin === a}
onClick={() => patch({ addedWithin: a })}
>
{t(`plex.filter.addedOpt.${a}`)}
</Chip>
))}
</ChipRow>
),
},
...(facets && facets.content_ratings.length > 0
? [
{
id: "contentrating",
title: t("plex.filter.contentRating"),
body: (
<ChipRow>
{facets.content_ratings.map((c) => (
<Chip
key={c.value}
active={filters.contentRatings.includes(c.value)}
onClick={() =>
patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })
}
>
{c.value}
</Chip>
))}
</ChipRow>
),
},
]
: []),
];
return (
<SidePanel
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
editing={editing}
onToggleEditing={() => setEditing((e) => !e)}
onReset={() => setLayout(defaultLayout("plex"))}
editHint={t("sidebar.editHint")}
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
}
>
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
</SidePanel>
);
}
function ChipRow({ children }: { children: ReactNode }) {
return <div className="flex flex-wrap gap-1">{children}</div>;
}
function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
onClick={onClick}
className={`px-2.5 py-1 rounded-full text-xs transition ${
active ? "bg-accent text-accent-fg" : "glass-card glass-hover"
}`}
>
{children}
</button>
);
}
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-accent/15 text-accent px-2 py-1 text-xs">
<span className="truncate max-w-36">{label}</span>
<button onClick={onRemove} className="hover:opacity-70" aria-label="remove">
<X className="w-3 h-3" />
</button>
</span>
);
}
function NumberInput({
value,
placeholder,
onChange,
}: {
value: number | null;
placeholder?: number;
onChange: (v: number | null) => void;
}) {
return (
<input
type="number"
inputMode="numeric"
value={value ?? ""}
placeholder={placeholder != null ? String(placeholder) : ""}
onChange={(e) => {
const n = e.target.value === "" ? null : Number(e.target.value);
onChange(n != null && Number.isFinite(n) ? n : null);
}}
className="w-20 rounded-lg border border-border bg-surface px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
);
}