import { useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Check, ChevronDown, Eye, EyeOff, GripVertical, Pencil, RefreshCw, RotateCcw, Share2, X, } from "lucide-react"; import { closestCenter, DndContext, PointerSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { api, type FeedFilters, type Tag } from "../lib/api"; import { DEFAULT_LAYOUT, type SidebarLayout, type WidgetId, } from "../lib/sidebarLayout"; import { shareUrl } from "../lib/urlState"; import { notify } from "../lib/notifications"; import { useDebounced } from "../lib/useDebounced"; import { Switch } from "./ui/form"; import TagManager from "./TagManager"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const SORT_IDS = [ "newest", "oldest", "views", "duration_desc", "duration_asc", "title", "subscribers", "priority", "shuffle", ]; const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"]; // Fresh shuffle token for the "surprise me" sort. const rollSeed = () => Math.floor(Math.random() * 1_000_000_000); 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" }, ]; // Filter values owned by the sidebar (everything except the header search `q` and the // `scope` mode, both preserved across a "Clear all"). const DEFAULT_SIDEBAR_FILTERS: Omit = { tags: [], tagMode: "or", sort: "newest", includeNormal: true, includeShorts: false, includeLive: false, show: "unwatched", }; function TagChip({ tag, count, active, onClick, }: { tag: Tag; count: number; active: boolean; onClick: () => void; }) { const { t } = useTranslation(); return ( ); } export default function Sidebar({ filters, setFilters, layout, setLayout, onFocusChannel, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; layout: SidebarLayout; setLayout: (l: SidebarLayout) => void; onFocusChannel: (name: string) => void; }) { const { t } = useTranslation(); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tags = tagsQuery.data ?? []; // Live per-tag channel counts for the current filter context (scope, channel, date, // search, watch state, other category's tags). Lets us show contextual counts and hide // chips that no longer match anything. Keyed on filters so it refetches as they change. // Debounce the search term (matching the feed) so typing doesn't refire facets per // keystroke, and keep the previous counts on screen during a refetch so chips don't flicker. const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) }; const facetsQuery = useQuery({ queryKey: ["facets", facetFilters], queryFn: () => api.facets(facetFilters), staleTime: 30_000, placeholderData: keepPreviousData, }); const facetsReady = !!facetsQuery.data; const facetCounts = facetsQuery.data?.counts ?? {}; // Before facets load, fall back to the static global count so chips don't flash empty. const chipCount = (tag: Tag): number => facetsReady ? facetCounts[String(tag.id)] ?? 0 : tag.channel_count; // Visible chips: hide zero-count ones once facets are in, but always keep selected ones // so an active filter can still be cleared. Sorted by count (largest first), then name — // so scanning top→bottom the smallest counts end up at the very bottom. 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) ); }; // After a page refresh the channel name isn't in the URL (only the id is), so // filters.channelName is undefined and the chip would fall back to "This channel". // Resolve the title from the (cached) channel list keyed by id. Only fetch when a // channel filter is active but its name is missing. const needChannelName = !!filters.channelId && !filters.channelName; const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels, enabled: needChannelName, staleTime: 5 * 60_000, }); const resolvedChannelName = filters.channelName ?? channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ?? undefined; // Don't flash the misleading "This channel" fallback while the name is still resolving. const channelChipLabel = resolvedChannelName ?? (needChannelName && channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.thisChannel")); const languages = tags.filter((t) => t.category === "language"); const topics = tags.filter((t) => t.category === "topic"); // The user's own (non-system) tags — those assigned to channels in the Channel manager. // Facets cover them too now (the "other" category), so they're contextual like the rest: // zero-count chips hide once facets load, counts reflect the current filter. const userTags = tags.filter((t) => !t.system); const [customDates, setCustomDates] = useState(false); const [tagManagerOpen, setTagManagerOpen] = useState(false); const [editing, setEditing] = useState(false); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }) ); function toggleTag(id: number) { const has = filters.tags.includes(id); setFilters({ ...filters, tags: has ? filters.tags.filter((t) => t !== id) : [...filters.tags, id], }); } const dateActive = !!filters.maxAgeDays || !!filters.dateFrom || !!filters.dateTo; const contentChanged = !filters.includeNormal || filters.includeShorts || filters.includeLive; const activeCount = filters.tags.length + (filters.show !== "unwatched" ? 1 : 0) + (filters.sort !== "newest" ? 1 : 0) + (contentChanged ? 1 : 0) + (filters.channelId ? 1 : 0) + (dateActive ? 1 : 0); const active = activeCount > 0; async function shareView() { try { await navigator.clipboard.writeText(shareUrl(filters)); notify({ level: "success", message: t("sidebar.shareCopied") }); } catch { notify({ level: "warning", message: t("sidebar.shareFailed") }); } } function clearAll() { setCustomDates(false); setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: filters.scope }); } const available: Record = { date: true, language: languages.length > 0, topic: topics.length > 0, tags: userTags.length > 0, }; function toggleCollapse(id: WidgetId) { setLayout({ ...layout, collapsed: { ...layout.collapsed, [id]: !layout.collapsed[id] } }); } function toggleHidden(id: WidgetId) { setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } }); } function onDragEnd(e: DragEndEvent) { const { active: a, over } = e; if (!over || a.id === over.id) return; const oldIndex = layout.order.indexOf(a.id as WidgetId); const newIndex = layout.order.indexOf(over.id as WidgetId); if (oldIndex < 0 || newIndex < 0) return; setLayout({ ...layout, order: arrayMove(layout.order, oldIndex, newIndex) }); } function widgetBody(id: WidgetId): React.ReactNode { switch (id) { 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")} )}
); } } } const orderedAvailable = layout.order.filter((id) => available[id]); const renderedIds = editing ? orderedAvailable : orderedAvailable.filter((id) => !layout.hidden[id]); return ( ); } function WidgetCard({ id, title, editing, collapsed, hidden, onToggleCollapse, onToggleHidden, children, }: { id: WidgetId; title: string; editing: boolean; collapsed: boolean; hidden: boolean; onToggleCollapse: () => void; onToggleHidden: () => void; children: React.ReactNode; }) { const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing, }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, }; const showBody = !editing && !collapsed; return (
{editing && ( )}
{title}
{editing ? ( ) : ( )}
{showBody &&
{children}
}
); } function Toggle({ label, checked, onChange, }: { label: string; checked: boolean; onChange: (v: boolean) => void; }) { return ( ); }