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 TagManager from "./TagManager"; import SavedViewsWidget from "./SavedViewsWidget"; import SidePanel from "./SidePanel"; import PanelGroups, { type PanelItem } from "./PanelGroups"; import ChannelPicker from "./ChannelPicker"; 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": return ( <>
{DATE_PRESETS.map((p) => { const activePreset = filters.maxAgeDays === p.days && !customDates; return ( ); })}
{(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 ( <>
{t("sidebar.match")}
{(["or", "and"] as const).map((m) => ( ))}
{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. */}
{(["my", "all"] as const).map((s) => ( ))}
{tagManagerOpen && ( setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> )}
); }