/* * Copyright (c) 2021 - 2025, Ludvig Lundgren and the autobrr contributors. * SPDX-License-Identifier: GPL-2.0-or-later */ import { Fragment, useMemo, useRef, useState } from "react"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { Menu, MenuButton, MenuItem, MenuItems, Transition } from "@headlessui/react"; import { ArrowsRightLeftIcon, DocumentTextIcon, EllipsisHorizontalIcon, ForwardIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/24/outline"; import { APIClient } from "@api/APIClient"; import { FeedsQueryOptions } from "@api/queries"; import { FeedKeys } from "@api/query_keys"; import { useToggle } from "@hooks/hooks"; import { baseUrl, classNames, IsEmptyDate, simplifyDate } from "@utils"; import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { DeleteModal, ForceRunModal } from "@components/modals"; import { FeedUpdateForm } from "@forms/settings/FeedForms"; import { EmptySimple } from "@components/emptystates"; import { ImplementationBadges } from "./Indexer"; import { ArrowPathIcon } from "@heroicons/react/24/solid"; import { ExternalLink } from "@components/ExternalLink"; import { Section } from "./_components"; import { Checkbox } from "@components/Checkbox"; interface SortConfig { key: keyof ListItemProps["feed"] | "enabled"; direction: "ascending" | "descending"; } const isErrorWithMessage = (error: unknown): error is { message: string } => { return typeof error === 'object' && error !== null && 'message' in error; }; function useSort(items: ListItemProps["feed"][], config?: SortConfig) { const [sortConfig, setSortConfig] = useState(config); const sortedItems = useMemo(() => { if (!sortConfig) { return items; } const sortableItems = [...items]; sortableItems.sort((a, b) => { const aValue = sortConfig.key === "enabled" ? (a[sortConfig.key] ?? false) as number | boolean | string : a[sortConfig.key] as number | boolean | string; const bValue = sortConfig.key === "enabled" ? (b[sortConfig.key] ?? false) as number | boolean | string : b[sortConfig.key] as number | boolean | string; if (aValue < bValue) { return sortConfig.direction === "ascending" ? -1 : 1; } if (aValue > bValue) { return sortConfig.direction === "ascending" ? 1 : -1; } return 0; }); return sortableItems; }, [items, sortConfig]); const requestSort = (key: keyof ListItemProps["feed"] | "enabled") => { let direction: "ascending" | "descending" = "ascending"; if ( sortConfig && sortConfig.key === key && sortConfig.direction === "ascending" ) { direction = "descending"; } setSortConfig({ key, direction }); }; const getSortIndicator = (key: keyof ListItemProps["feed"]) => { if (!sortConfig || sortConfig.key !== key) { return ""; } return sortConfig.direction === "ascending" ? "↑" : "↓"; }; return { items: sortedItems, requestSort, sortConfig, getSortIndicator }; } function FeedSettings() { const feedsQuery = useSuspenseQuery(FeedsQueryOptions()) const sortedFeeds = useSort(feedsQuery.data || []); return (
{feedsQuery.data && feedsQuery.data.length > 0 ? ( ) : ( )}
); } interface ListItemProps { feed: Feed; } function ListItem({ feed }: ListItemProps) { const [updateFormIsOpen, toggleUpdateForm] = useToggle(false); const [enabled, setEnabled] = useState(feed.enabled); const queryClient = useQueryClient(); const updateMutation = useMutation({ mutationFn: (status: boolean) => APIClient.feeds.toggleEnable(feed.id, status), onSuccess: () => { queryClient.invalidateQueries({ queryKey: FeedKeys.lists() }); queryClient.invalidateQueries({ queryKey: FeedKeys.detail(feed.id) }); toast.custom((t) => ); } }); const toggleActive = (status: boolean) => { setEnabled(status); updateMutation.mutate(status); }; return (
  • {feed.name} {feed.indexer.identifier}
    {ImplementationBadges[feed.type.toLowerCase()]}
    {IsEmptyDate(feed.last_run)}
    {IsEmptyDate(feed.next_run)}
  • ); } interface FeedItemDropdownProps { feed: Feed; onToggle: (newState: boolean) => void; toggleUpdate: () => void; } const FeedItemDropdown = ({ feed, onToggle, toggleUpdate }: FeedItemDropdownProps) => { const cancelModalButtonRef = useRef(null); const cancelCacheModalButtonRef = useRef(null); const queryClient = useQueryClient(); const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false); const [deleteCacheModalIsOpen, toggleDeleteCacheModal] = useToggle(false); const [forceRunModalIsOpen, toggleForceRunModal] = useToggle(false); const deleteMutation = useMutation({ mutationFn: (id: number) => APIClient.feeds.delete(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: FeedKeys.lists() }); queryClient.invalidateQueries({ queryKey: FeedKeys.detail(feed.id) }); toast.custom((t) => ); } }); const deleteCacheMutation = useMutation({ mutationFn: (id: number) => APIClient.feeds.deleteCache(id), onSuccess: () => { toast.custom((t) => ); } }); const forceRunMutation = useMutation({ mutationFn: (id: number) => APIClient.feeds.forceRun(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: FeedKeys.lists() }); toast.custom((t) => ); toggleForceRunModal(); }, onError: (error: unknown) => { let errorMessage = 'An unknown error occurred'; if (isErrorWithMessage(error)) { errorMessage = error.message; } toast.custom((t) => , { duration: 10000 }); toggleForceRunModal(); } }); return ( { deleteMutation.mutate(feed.id); toggleDeleteModal(); }} title={`Remove feed: ${feed.name}`} text="Are you sure you want to remove this feed? This action cannot be undone." /> { deleteCacheMutation.mutate(feed.id); }} title={`Remove feed cache: ${feed.name}`} text="Are you sure you want to remove the feed cache? This action cannot be undone." /> { forceRunMutation.mutate(feed.id); toggleForceRunModal(); }} title={`Force run feed: ${feed.name}`} text={`Are you sure you want to force run the ${feed.name} feed? Respecting RSS interval rules is crucial to avoid potential IP bans.`} />
    {({ active }) => ( )} {({ active }) => ( )}
    {({ active }) => ( )} {({ active }) => ( )}
    {({ active }) => ( )}
    ); }; export default FeedSettings;