import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { qk } from "../../lib/queryKeys"; import { SlidersHorizontal } from "lucide-react"; import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../../lib/api"; import { defaultLayout, type PanelLayout } from "../../lib/panelLayout"; import { useDebounced } from "../../lib/useDebounced"; import { useActiveFilters } from "../../lib/useActiveFilters"; import TagManager from "../../features/channel/TagManager"; import SavedViewsPopover from "../../features/filters/SavedViewsPopover"; import { useSavedViews } from "../../features/filters/savedViewsContext"; import FilterRail from "../../components/ui/FilterRail"; import { type PanelItem } from "../../components/ui/PanelGroups"; import { type MediaSourceKind } from "../../lib/mediaSource"; import ChannelPicker from "./ChannelPicker"; import { facetOption } from "./facetOption"; import DateFilterSelect from "./DateFilterSelect"; import YearFilter from "./YearFilter"; import Combobox from "../../components/ui/Combobox"; import MatchToggle from "../../components/ui/MatchToggle"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useChannelsActions } from "../channels"; import { useTags } from "../../lib/useTags"; import { compareText } from "../../lib/collate"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const DATE_PRESETS: { days: number; key: string }[] = [ { days: 1, key: "24h" }, { days: 7, key: "1week" }, { days: 14, key: "2weeks" }, { days: 30, key: "1month" }, { days: 90, key: "3months" }, { days: 180, key: "6months" }, { days: 365, key: "1year" }, { days: 730, key: "2years" }, { days: 1825, key: "5years" }, ]; export default function Sidebar({ layout, setLayout, collapsed, onToggleCollapse, }: { layout: PanelLayout; setLayout: (l: PanelLayout) => void; collapsed: boolean; onToggleCollapse: () => void; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; const { openModal } = useSavedViews(); const filters = useFeedFilters(); const { setFilters } = useFeedFiltersActions(); // "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider. const { focusChannel: onFocusChannel } = useChannelsActions(); const tagsQuery = useTags(); const tags = tagsQuery.data ?? []; // Live per-tag channel counts for the current filter context. Debounce the search term so // typing doesn't refire facets per keystroke, and keep previous counts during a refetch. const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) }; // Shared with the feed's count (same key + filters -> React Query dedupes to one request): the feed // reads `.count`, this reads `.facets` (PERF-13, replaces the separate facets query). const summaryQuery = useQuery({ queryKey: qk.feedSummary(facetFilters), queryFn: () => api.feedSummary(facetFilters), staleTime: 30_000, placeholderData: keepPreviousData, }); const facetsReady = !!summaryQuery.data; const facetCounts = summaryQuery.data?.facets ?? {}; // Ordering falls back to the tag's whole-library count until the facets land; the number shown does // not (facetOption). const chipCount = (tag: Tag): number => facetsReady ? (facetCounts[String(tag.id)] ?? 0) : tag.channel_count; const visibleChips = (list: Tag[]): Tag[] => { const shown = facetsReady ? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id)) : list; return [...shown].sort( (a, b) => chipCount(b) - chipCount(a) || compareText(a.name, b.name, lang), ); }; const languages = tags.filter((t) => t.category === "language"); const topics = tags.filter((t) => t.category === "topic"); const userTags = tags.filter((t) => !t.system); const [tagManagerOpen, setTagManagerOpen] = useState(false); // This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives // the panel layout key. `FilterRail` owns the customize `editing` state now. const source: MediaSourceKind = "feed"; // The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns // only its own slice: its selection replaces the ids that belong to its list, leaving the others. const tagOption = (tg: Tag) => facetOption(tg, summaryQuery.data?.facets); const selectedIn = (list: Tag[]): string[] => filters.tags.filter((id) => list.some((tg) => tg.id === id)).map(String); function setTagSelection(list: Tag[], next: string[]) { const inList = new Set(list.map((tg) => tg.id)); setFilters({ ...filters, tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)], }); } // Each tag category (language/topic/other) has its OWN any/all match, set from the switch above its // picker (ui/MatchToggle); the store keeps the older or/and words. const setTagMode = (category: TagCategory, mode: "or" | "and") => setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } }); // The "Your tags" combobox carries the "No tag" sentinel (item B) at the top: selecting it filters to // channels you HAVEN'T personally tagged in the channel manager (`filters.untagged`). It's mutually // exclusive with picking real user tags — choosing it clears your tag picks, and picking a tag clears // it. Language/Topic are auto/system tags, so "No tag" is meaningless there and only lives here. function userTagsCombo(): ReactNode { const noneOn = !!filters.untagged; const inList = (id: number) => userTags.some((tg) => tg.id === id); const onChange = (next: string[]) => { if (next.includes(NO_TAG_SENTINEL) && !noneOn) { setFilters({ ...filters, tags: filters.tags.filter((id) => !inList(id)), untagged: true }); } else { const reals = next.filter((v) => v !== NO_TAG_SENTINEL).map(Number); setFilters({ ...filters, tags: [...filters.tags.filter((id) => !inList(id)), ...reals], untagged: false, }); } }; return ( <> setTagMode("other", m === "all" ? "and" : "or")} /> ); } // The badge counts exactly what the chip row above the feed shows — same list, so they can't // disagree about what's applied. const activeCount = useActiveFilters(filters, setFilters).length; const available: Record = { date: true, year: true, channel: true, language: languages.length > 0, topic: topics.length > 0, tags: userTags.length > 0, }; function widgetBody(id: string): ReactNode { switch (id) { case "channel": return ; case "date": return ( ); case "year": return ; case "language": return ( <> setTagMode("language", m === "all" ? "and" : "or")} /> setTagSelection(languages, next)} emptyText={t("sidebar.noMatchingTags")} /> ); case "topic": return ( <> setTagMode("topic", m === "all" ? "and" : "or")} /> setTagSelection(topics, next)} emptyText={t("sidebar.noMatchingTags")} /> ); case "tags": // The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the // body is just the picker — user tags plus the "No tag" sentinel. return userTagsCombo(); default: return null; } } // Group ids come from the shared default order (panelLayout) so there's one source of truth; // the actual render order is the user's saved layout (PanelGroups orders by it). const items: PanelItem[] = defaultLayout(source) .order.filter((id) => available[id]) .map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id), // The widgets aren't collapsible, so a widget's title can act as its own trigger: "Your tags" // opens the tag manager (replacing the old inline Manage button). onTitleClick: id === "tags" ? () => setTagManagerOpen(true) : undefined, titleActionLabel: id === "tags" ? t("sidebar.manageTags") : undefined, })); return ( } count={activeCount} collapsed={collapsed} onToggleCollapse={onToggleCollapse} layout={layout} setLayout={setLayout} items={items} // The feed widgets are simple label + control rows, not collapsible islands. collapsible={false} // Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the // full manager modal. Feed rail only. iconExpanded={ source === "feed" ? ( } /> ) : undefined } onTitleClick={source === "feed" ? openModal : undefined} titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined} > {tagManagerOpen && ( setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> )} ); }