import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react"; import { api, type FeedFilters, type Tag } from "../lib/api"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { shareUrl } from "../lib/urlState"; import { copyText } from "../lib/clipboard"; import { notify } from "../lib/notifications"; 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 SidePanel from "./SidePanel"; import PanelGroups, { type PanelItem } from "./PanelGroups"; import ChannelPicker from "./ChannelPicker"; import SegmentedToggle from "./SegmentedToggle"; import FilterChipGroup, { filterChipClass } from "./FilterChipGroup"; 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" }, ]; function TagChip({ tag, count, active, onClick, }: { tag: Tag; count: number; active: boolean; onClick: () => void; }) { const { t } = useTranslation(); return ( ); } 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) }; const facetsQuery = useQuery({ queryKey: qk.facets(facetFilters), queryFn: () => api.facets(facetFilters), staleTime: 30_000, placeholderData: keepPreviousData, }); const facetsReady = !!facetsQuery.data; const facetCounts = facetsQuery.data?.counts ?? {}; 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); const [editing, setEditing] = useState(false); function toggleTag(id: number) { const has = filters.tags.includes(id); setFilters({ ...filters, tags: has ? filters.tags.filter((t) => t !== id) : [...filters.tags, id], }); } // 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; async function shareView() { if (await copyText(shareUrl(filters))) { notify({ level: "success", message: t("sidebar.shareCopied") }); } else { notify({ level: "warning", message: t("sidebar.shareFailed") }); } } 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": { const chips = visibleChips(languages); return (
{chips.map((tg) => ( toggleTag(tg.id)} /> ))} {facetsReady && chips.length === 0 && ( {t("sidebar.noMatchingTags")} )}
); } case "topic": { const chips = visibleChips(topics); return ( <> setFilters({ ...filters, tagMode })} />
{chips.map((tg) => ( toggleTag(tg.id)} /> ))} {facetsReady && chips.length === 0 && ( {t("sidebar.noMatchingTags")} )}
); } case "tags": { const tagChips = visibleChips(userTags); return (
{tagChips.map((tg) => ( toggleTag(tg.id)} /> ))} {facetsReady && tagChips.length === 0 && ( {t("sidebar.noMatchingTags")} )}
); } 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("feed") .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} editing={editing} onToggleEditing={() => setEditing((e) => !e)} onReset={() => setLayout(defaultLayout("feed"))} editHint={t("sidebar.editHint")} actions={actions} > {/* Scope: your own subscriptions (Mine) vs the shared Library. */} , title: t("header.scope.myTip"), }, { value: "all", label: t("header.scope.all"), icon: , title: t("header.scope.allTip"), }, ]} value={filters.scope} onChange={(scope) => setFilters({ ...filters, scope })} /> {tagManagerOpen && ( setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> )} ); }