Files
siftlode/frontend/src/components/PlexSidebar.tsx
T
peter e257e2aca0 style(frontend): Prettier config + one-time repo-wide format pass
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing
hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass
(`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a
feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest
all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha.
2026-07-21 02:26:40 +02:00

552 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 { 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-[10px] 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-[10px]"
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: ["plex-playlists"], 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: ["plex-playlists"] });
onOpenPlaylist(pl.id);
}
const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
const facetsQ = useQuery({
queryKey: ["plex-facets", 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: ["plex-collections", "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 outline-none focus:border-accent"
/>
<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 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-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
/>
<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-[9rem]">{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-card px-2 py-1 text-sm tabular-nums focus:border-accent focus:outline-none"
/>
);
}