import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { Pencil, 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 { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import { applyDateChip, deriveDateKey } from "../lib/dateFilter"; import TagManager from "./TagManager"; import SavedViewsWidget from "./SavedViewsWidget"; import FilterRail from "./FilterRail"; import { type PanelItem } from "./PanelGroups"; import { type MediaSourceKind } from "../lib/mediaSource"; import ChannelPicker from "./ChannelPicker"; import FilterChipGroup from "./FilterChipGroup"; 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: 30, key: "1month" }, { days: 180, key: "6months" }, { days: 365, key: "1year" }, ]; 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 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 [customDates, setCustomDates] = useState(false); 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 active = activeCount > 0; function clearAll() { setCustomDates(false); setFilters(clearedFilters(filters)); } const available: Record = { savedviews: !isDemo, date: true, channel: true, language: languages.length > 0, topic: topics.length > 0, tags: userTags.length > 0, }; function widgetBody(id: string): ReactNode { switch (id) { case "savedviews": return ; case "channel": return ; case "date": { // Single-select across the presets + Custom; pure logic lives in lib/dateFilter. const dateKey = deriveDateKey(filters, customDates, DATE_PRESETS); return ( <> ({ value: String(p.days), label: t("sidebar.datePreset." + p.key), })), { value: "custom", label: t("sidebar.custom") }, ]} onChange={(key) => { const change = applyDateChip(key, filters, customDates); if (change.customDates !== undefined) setCustomDates(change.customDates); if (change.filters) setFilters(change.filters); }} /> {(customDates || filters.dateFrom || filters.dateTo) && (
{(filters.dateFrom || filters.dateTo) && ( )}
)} ); } 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": { 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) })); const actions = ( <> ); return ( } count={activeCount} collapsed={collapsed} onToggleCollapse={onToggleCollapse} layout={layout} setLayout={setLayout} items={items} actions={actions} > {tagManagerOpen && ( setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> )} ); }