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, 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 "./TagManager"; import SavedViewsPopover from "./SavedViewsPopover"; import { useSavedViews } from "./SavedViewsProvider"; import FilterRail from "./FilterRail"; import { type PanelItem } from "./PanelGroups"; import { type MediaSourceKind } from "../lib/mediaSource"; import ChannelPicker from "./ChannelPicker"; import DateFilterSelect from "./DateFilterSelect"; import Combobox, { type ComboboxOption } from "./Combobox"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useChannelsActions } from "./ChannelsProvider"; import { useMe } from "../lib/useMe"; // 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 } = useTranslation(); const isDemo = useMe().is_demo; 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 = useQuery({ queryKey: qk.tags(), queryFn: api.tags }); 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 ?? {}; 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) || a.name.localeCompare(b.name)); }; 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): ComboboxOption => ({ value: String(tg.id), label: tg.name, count: chipCount(tg), }); 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 its combobox footer. const setTagMode = (category: TagCategory, mode: "or" | "and") => setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } }); // 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, 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 "language": { return ( setTagSelection(languages, next)} emptyText={t("sidebar.noMatchingTags")} matchMode={filters.tagModes.language} onMatchModeChange={(m) => setTagMode("language", m)} /> ); } case "topic": { return ( setTagSelection(topics, next)} emptyText={t("sidebar.noMatchingTags")} matchMode={filters.tagModes.topic} onMatchModeChange={(m) => setTagMode("topic", m)} /> ); } case "tags": { // The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the // body is just the picker. return ( setTagSelection(userTags, next)} emptyText={t("sidebar.noMatchingTags")} matchMode={filters.tagModes.other} onMatchModeChange={(m) => setTagMode("other", m)} /> ); } 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. Hidden for the demo account (no per-user views). Feed rail only. iconExpanded={ !isDemo && source === "feed" ? ( } /> ) : undefined } onTitleClick={!isDemo && source === "feed" ? openModal : undefined} titleActionLabel={!isDemo && source === "feed" ? t("views.manageTitle") : undefined} > {tagManagerOpen && ( setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> )} ); }