feat(web): move from react-router to @tanstack/router (#1338)

* fix(auth): invalid cookie handling and wrongful basic auth invalidation

* fix(auth): fix test to reflect new HTTP status code

* fix(auth/web): do not throw on error

* fix(http): replace http codes in middleware to prevent basic auth invalidation
fix typo in comment

* fix test

* fix(web): api client handle 403

* refactor(http): auth_test use testify.assert

* refactor(http): set session opts after valid login

* refactor(http): send more client headers

* fix(http): test

* refactor(web): move router to tanstack/router

* refactor(web): use route loaders and suspense

* refactor(web): useSuspense for settings

* refactor(web): invalidate cookie in middleware

* fix: loclfile

* fix: load filter/id

* fix(web): login, onboard, types, imports

* fix(web): filter load

* fix(web): build errors

* fix(web): ts-expect-error

* fix(tests): filter_test.go

* fix(filters): tests

* refactor: remove duplicate spinner components
refactor: ReleaseTable.tsx loading animation
refactor: remove dedicated `pendingComponent` for `settingsRoute`

* fix: refactor missed SectionLoader to RingResizeSpinner

* fix: substitute divides with borders to account for unloaded elements

* fix(api): action status URL param

* revert: action status URL param
add comment

* fix(routing): notfound handling and split files

* fix(filters): notfound get params

* fix(queries): colon

* fix(queries): comments ts-ignore

* fix(queries): extract queryKeys

* fix(queries): remove err

* fix(routes): move zob schema inline

* fix(auth): middleware and redirect to login

* fix(auth): failing test

* fix(logs): invalidate correct key

* fix(logs): invalidate correct key

* fix(logs): invalidate correct key

* fix: JSX element stealing focus from searchbar

* reimplement empty release table state text

* fix(context): use deep-copy

* fix(releases): empty state and filter input warnings

* fix(releases): empty states

* fix(auth): onboarding

* fix(cache): invalidate queries

---------

Co-authored-by: ze0s <43699394+zze0s@users.noreply.github.com>
This commit is contained in:
martylukyy 2024-02-12 13:07:00 +01:00 committed by GitHub
parent cc9656cd41
commit 1a23b69bcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 2543 additions and 2091 deletions

View file

@ -23,7 +23,6 @@ import { EmptySimple } from "@components/emptystates";
import { RingResizeSpinner } from "@components/Icons";
import Toast from "@components/notifications/Toast";
type LogEvent = {
time: string;
level: string;
@ -182,7 +181,7 @@ export const LogFiles = () => {
});
if (isError) {
console.log(error);
console.log("could not load log files", error);
}
return (
@ -194,7 +193,7 @@ export const LogFiles = () => {
</p>
</div>
{data && data.files.length > 0 ? (
{data && data.files && data.files.length > 0 ? (
<ul className="py-3 min-w-full relative">
<li className="grid grid-cols-12 mb-2 border-b border-gray-200 dark:border-gray-700">
<div className="hidden sm:block col-span-5 px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">

View file

@ -3,8 +3,6 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Suspense } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import {
BellIcon,
ChatBubbleLeftRightIcon,
@ -16,25 +14,26 @@ import {
Square3Stack3DIcon,
UserCircleIcon
} from "@heroicons/react/24/outline";
import { Link, Outlet } from "@tanstack/react-router";
import { classNames } from "@utils";
import { SectionLoader } from "@components/SectionLoader";
interface NavTabType {
name: string;
href: string;
icon: typeof CogIcon;
exact?: boolean;
}
const subNavigation: NavTabType[] = [
{ name: "Application", href: "", icon: CogIcon },
{ name: "Application", href: ".", icon: CogIcon, exact: true },
{ name: "Logs", href: "logs", icon: Square3Stack3DIcon },
{ name: "Indexers", href: "indexers", icon: KeyIcon },
{ name: "IRC", href: "irc", icon: ChatBubbleLeftRightIcon },
{ name: "Feeds", href: "feeds", icon: RssIcon },
{ name: "Clients", href: "clients", icon: FolderArrowDownIcon },
{ name: "Notifications", href: "notifications", icon: BellIcon },
{ name: "API keys", href: "api-keys", icon: KeyIcon },
{ name: "API keys", href: "api", icon: KeyIcon },
{ name: "Releases", href: "releases", icon: RectangleStackIcon },
{ name: "Account", href: "account", icon: UserCircleIcon }
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
@ -46,29 +45,38 @@ interface NavLinkProps {
}
function SubNavLink({ item }: NavLinkProps) {
const { pathname } = useLocation();
const splitLocation = pathname.split("/");
// const { pathname } = useLocation();
// const splitLocation = pathname.split("/");
// we need to clean the / if it's a base root path
return (
<NavLink
key={item.name}
<Link
key={item.href}
to={item.href}
end
className={({ isActive }) => classNames(
"transition group border-l-4 px-3 py-2 flex items-center text-sm font-medium",
isActive
? "font-bold bg-blue-100 dark:bg-gray-700 border-sky-500 dark:border-blue-500 text-sky-700 dark:text-gray-200 hover:bg-blue-200 dark:hover:bg-gray-600 hover:text-sky-900 dark:hover:text-white"
: "border-transparent text-gray-900 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-gray-300"
)}
aria-current={splitLocation[2] === item.href ? "page" : undefined}
activeOptions={{ exact: item.exact }}
search={{}}
params={{}}
// aria-current={splitLocation[2] === item.href ? "page" : undefined}
>
<item.icon
className="text-gray-500 dark:text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
aria-hidden="true"
/>
<span className="truncate">{item.name}</span>
</NavLink>
{({ isActive }) => {
return (
<span className={
classNames(
"transition group border-l-4 px-3 py-2 flex items-center text-sm font-medium",
isActive
? "font-bold bg-blue-100 dark:bg-gray-700 border-sky-500 dark:border-blue-500 text-sky-700 dark:text-gray-200 hover:bg-blue-200 dark:hover:bg-gray-600 hover:text-sky-900 dark:hover:text-white"
: "border-transparent text-gray-900 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-gray-300"
)
}>
<item.icon
className="text-gray-500 dark:text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
aria-hidden="true"
/>
<span className="truncate">{item.name}</span>
</span>
)
}}
</Link>
);
}
@ -78,10 +86,10 @@ interface SidebarNavProps {
function SidebarNav({ subNavigation }: SidebarNavProps) {
return (
<aside className="py-2 lg:col-span-3">
<aside className="py-2 lg:col-span-3 border-b lg:border-b-0 lg:border-r border-gray-150 dark:border-gray-725">
<nav className="space-y-1">
{subNavigation.map((item) => (
<SubNavLink item={item} key={item.href} />
<SubNavLink key={item.href} item={item} />
))}
</nav>
</aside>
@ -97,17 +105,9 @@ export function Settings() {
<div className="max-w-screen-xl mx-auto pb-6 px-2 sm:px-6 lg:pb-16 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-table border border-gray-250 dark:border-gray-775">
<div className="divide-y divide-gray-150 dark:divide-gray-725 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<div className="lg:grid lg:grid-cols-12">
<SidebarNav subNavigation={subNavigation}/>
<Suspense
fallback={
<div className="flex items-center justify-center lg:col-span-9">
<SectionLoader $size="large" />
</div>
}
>
<Outlet />
</Suspense>
</div>
</div>
</div>

View file

@ -3,19 +3,19 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useEffect } from "react";
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { useRouter, useSearch } from "@tanstack/react-router";
import toast from "react-hot-toast";
import { RocketLaunchIcon } from "@heroicons/react/24/outline";
import { APIClient } from "@api/APIClient";
import { AuthContext } from "@utils/Context";
import Toast from "@components/notifications/Toast";
import { Tooltip } from "@components/tooltips/Tooltip";
import { PasswordInput, TextInput } from "@components/inputs/text";
import { LoginRoute } from "@app/routes";
import Logo from "@app/logo.svg?react";
@ -25,35 +25,25 @@ type LoginFormFields = {
};
export const Login = () => {
const router = useRouter()
const { auth } = LoginRoute.useRouteContext()
const search = useSearch({ from: LoginRoute.id })
const { handleSubmit, register, formState } = useForm<LoginFormFields>({
defaultValues: { username: "", password: "" },
mode: "onBlur"
});
const navigate = useNavigate();
const [, setAuthContext] = AuthContext.use();
useEffect(() => {
// remove user session when visiting login page'
APIClient.auth.logout()
.then(() => {
AuthContext.reset();
});
// Check if onboarding is available for this instance
// and redirect if needed
APIClient.auth.canOnboard()
.then(() => navigate("/onboard"))
.catch(() => { /*don't log to console PAHLLEEEASSSE*/ });
}, [navigate]);
// remove user session when visiting login page
auth.logout()
}, []);
const loginMutation = useMutation({
mutationFn: (data: LoginFormFields) => APIClient.auth.login(data.username, data.password),
onSuccess: (_, variables: LoginFormFields) => {
setAuthContext({
username: variables.username,
isLoggedIn: true
});
navigate("/");
auth.login(variables.username)
router.invalidate()
},
onError: () => {
toast.custom((t) => (
@ -64,6 +54,14 @@ export const Login = () => {
const onSubmit = (data: LoginFormFields) => loginMutation.mutate(data);
React.useLayoutEffect(() => {
if (auth.isLoggedIn && search.redirect) {
router.history.push(search.redirect)
} else if (auth.isLoggedIn) {
router.history.push("/")
}
}, [auth.isLoggedIn, search.redirect])
return (
<div className="min-h-screen flex flex-col justify-center px-3">
<div className="mx-auto w-full max-w-md mb-6">

View file

@ -5,7 +5,7 @@
import { Form, Formik } from "formik";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {useNavigate} from "@tanstack/react-router";
import { APIClient } from "@api/APIClient";
import { TextField, PasswordField } from "@components/inputs";
@ -43,7 +43,7 @@ export const Onboarding = () => {
const mutation = useMutation({
mutationFn: (data: InputValues) => APIClient.auth.onboard(data.username, data.password1),
onSuccess: () => navigate("/")
onSuccess: () => navigate({ to: "/" })
});
return (

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import React, { useState } from "react";
import React, { Suspense, useState } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import {
useTable,
@ -12,13 +12,14 @@ import {
useSortBy,
usePagination, FilterProps, Column
} from "react-table";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
import { APIClient } from "@api/APIClient";
import { EmptyListState } from "@components/emptystates";
import * as Icons from "@components/Icons";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
import * as DataTable from "@components/data-table";
import { RandomLinuxIsos } from "@utils";
import { RingResizeSpinner } from "@components/Icons";
import { ReleasesLatestQueryOptions } from "@api/queries";
// This is a custom filter UI for selecting
// a unique option from a list
@ -80,8 +81,14 @@ function Table({ columns, data }: TableProps) {
usePagination
);
if (!page.length) {
return <EmptyListState text="No recent activity" />;
if (data.length === 0) {
return (
<div className="mt-4 mb-2 bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<div className="flex items-center justify-center py-16">
<EmptyListState text="No recent activity"/>
</div>
</div>
)
}
// Render the UI for your table
@ -159,6 +166,28 @@ function Table({ columns, data }: TableProps) {
);
}
export const RecentActivityTable = () => {
return (
<div className="flex flex-col mt-12">
<h3 className="text-2xl font-medium leading-6 text-gray-900 dark:text-gray-200">
Recent activity
</h3>
<div className="animate-pulse text-black dark:text-white">
<Suspense
fallback={
<div className="flex items-center justify-center lg:col-span-9">
<RingResizeSpinner className="text-blue-500 size-12" />
</div>
}
>
{/*<EmptyListState text="Loading..."/>*/}
<ActivityTableContent />
</Suspense>
</div>
</div>
)
}
export const ActivityTable = () => {
const columns = React.useMemo(() => [
{
@ -185,11 +214,7 @@ export const ActivityTable = () => {
}
] as Column[], []);
const { isLoading, data } = useSuspenseQuery({
queryKey: ["dash_recent_releases"],
queryFn: APIClient.release.findRecent,
refetchOnWindowFocus: false
});
const { isLoading, data } = useSuspenseQuery(ReleasesLatestQueryOptions());
const [modifiedData, setModifiedData] = useState<Release[]>([]);
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
@ -198,7 +223,7 @@ export const ActivityTable = () => {
return (
<div className="flex flex-col mt-12">
<h3 className="text-2xl font-medium leading-6 text-gray-900 dark:text-gray-200">
&nbsp;
Recent activity
</h3>
<div className="animate-pulse text-black dark:text-white">
<EmptyListState text="Loading..."/>
@ -245,3 +270,75 @@ export const ActivityTable = () => {
</div>
);
};
export const ActivityTableContent = () => {
const columns = React.useMemo(() => [
{
Header: "Age",
accessor: "timestamp",
Cell: DataTable.AgeCell
},
{
Header: "Release",
accessor: "name",
Cell: DataTable.TitleCell
},
{
Header: "Actions",
accessor: "action_status",
Cell: DataTable.ReleaseStatusCell
},
{
Header: "Indexer",
accessor: "indexer",
Cell: DataTable.TitleCell,
Filter: SelectColumnFilter,
filter: "includes"
}
] as Column[], []);
const { isLoading, data } = useSuspenseQuery(ReleasesLatestQueryOptions());
const [modifiedData, setModifiedData] = useState<Release[]>([]);
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
if (isLoading) {
return (
<EmptyListState text="Loading..."/>
);
}
const toggleReleaseNames = () => {
setShowLinuxIsos(!showLinuxIsos);
if (!showLinuxIsos && data && data.data) {
const randomNames = RandomLinuxIsos(data.data.length);
const newData: Release[] = data.data.map((item, index) => ({
...item,
name: `${randomNames[index]}.iso`,
indexer: index % 2 === 0 ? "distrowatch" : "linuxtracker"
}));
setModifiedData(newData);
}
};
const displayData = showLinuxIsos ? modifiedData : (data?.data ?? []);
return (
<>
<Table columns={columns} data={displayData} />
<button
onClick={toggleReleaseNames}
className="p-2 absolute -bottom-8 right-0 bg-gray-750 text-white rounded-full opacity-10 hover:opacity-100 transition-opacity duration-300"
aria-label="Toggle view"
title="Go incognito"
>
{showLinuxIsos ? (
<EyeIcon className="h-4 w-4" />
) : (
<EyeSlashIcon className="h-4 w-4" />
)}
</button>
</>
);
};

View file

@ -3,23 +3,28 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useSuspenseQuery } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import { useQuery} from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { classNames } from "@utils";
import { useNavigate } from "react-router-dom";
import { LinkIcon } from "@heroicons/react/24/solid";
import { ReleasesStatsQueryOptions } from "@api/queries";
interface StatsItemProps {
name: string;
value?: number;
placeholder?: string;
onClick?: () => void;
name: string;
value?: number;
placeholder?: string;
to?: string;
eventType?: string;
}
const StatsItem = ({ name, placeholder, value, onClick }: StatsItemProps) => (
<div
const StatsItem = ({ name, placeholder, value, to, eventType }: StatsItemProps) => (
<Link
className="group relative px-4 py-3 cursor-pointer overflow-hidden rounded-lg shadow-lg bg-white dark:bg-gray-800 hover:scale-110 hover:shadow-xl transition-all duration-200 ease-in-out"
onClick={onClick}
to={to}
search={{
action_status: eventType
}}
params={{}}
>
<dt>
<div className="flex items-center text-sm font-medium text-gray-500 group-hover:dark:text-gray-475 group-hover:text-gray-600 transition-colors duration-200 ease-in-out">
@ -36,24 +41,11 @@ const StatsItem = ({ name, placeholder, value, onClick }: StatsItemProps) => (
<p>{value}</p>
</dd>
</div>
</div>
</Link>
);
export const Stats = () => {
const navigate = useNavigate();
const handleStatClick = (filterType: string) => {
if (filterType) {
navigate(`/releases?filter=${filterType}`);
} else {
navigate("/releases");
}
};
const { isLoading, data } = useSuspenseQuery({
queryKey: ["dash_release_stats"],
queryFn: APIClient.release.stats,
refetchOnWindowFocus: false
});
const { isLoading, data } = useQuery(ReleasesStatsQueryOptions());
return (
<div>
@ -62,11 +54,11 @@ export const Stats = () => {
</h1>
<dl className={classNames("grid grid-cols-2 gap-2 sm:gap-5 mt-5 sm:grid-cols-2 lg:grid-cols-4", isLoading ? "animate-pulse" : "")}>
<StatsItem name="Filtered Releases" onClick={() => handleStatClick("")} value={data?.filtered_count ?? 0} />
<StatsItem name="Filtered Releases" to="/releases" value={data?.filtered_count ?? 0} />
{/* <StatsItem name="Filter Rejected Releases" stat={data?.filter_rejected_count} /> */}
<StatsItem name="Approved Pushes" onClick={() => handleStatClick("PUSH_APPROVED")} value={data?.push_approved_count ?? 0} />
<StatsItem name="Rejected Pushes" onClick={() => handleStatClick("PUSH_REJECTED")} value={data?.push_rejected_count ?? 0 } />
<StatsItem name="Errored Pushes" onClick={() => handleStatClick("PUSH_ERROR")} value={data?.push_error_count ?? 0} />
<StatsItem name="Approved Pushes" to="/releases" eventType="PUSH_APPROVED" value={data?.push_approved_count ?? 0} />
<StatsItem name="Rejected Pushes" to="/releases" eventType="PUSH_REJECTED" value={data?.push_rejected_count ?? 0 } />
<StatsItem name="Errored Pushes" to="/releases" eventType="PUSH_ERROR" value={data?.push_error_count ?? 0} />
</dl>
</div>
);

View file

@ -3,17 +3,18 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Suspense, useEffect, useRef } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Form, Formik, useFormikContext } from "formik";
import type { FormikErrors, FormikValues } from "formik";
import { z } from "zod";
import { toast } from "react-hot-toast";
import { toFormikValidationSchema } from "zod-formik-adapter";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { NavLink, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import { APIClient } from "@api/APIClient";
import { FilterByIdQueryOptions } from "@api/queries";
import { FilterKeys } from "@api/query_keys";
import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import { DOWNLOAD_CLIENTS } from "@domain/constants";
@ -21,18 +22,18 @@ import { DOWNLOAD_CLIENTS } from "@domain/constants";
import { DEBUG } from "@components/debug";
import Toast from "@components/notifications/Toast";
import { DeleteModal } from "@components/modals";
import { SectionLoader } from "@components/SectionLoader";
import { filterKeys } from "./List";
import * as Section from "./sections";
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
import { FilterGetByIdRoute } from "@app/routes";
interface tabType {
name: string;
href: string;
exact?: boolean;
}
const tabs: tabType[] = [
{ name: "General", href: "" },
{ name: "General", href: ".", exact: true },
{ name: "Movies and TV", href: "movies-tv" },
{ name: "Music", href: "music" },
{ name: "Advanced", href: "advanced" },
@ -45,25 +46,35 @@ export interface NavLinkProps {
}
function TabNavLink({ item }: NavLinkProps) {
const location = useLocation();
const splitLocation = location.pathname.split("/");
// const location = useLocation();
// const splitLocation = location.pathname.split("/");
// we need to clean the / if it's a base root path
return (
<NavLink
key={item.name}
<Link
to={item.href}
end
className={({ isActive }) => classNames(
"transition border-b-2 whitespace-nowrap py-4 duration-3000 px-1 font-medium text-sm first:rounded-tl-lg last:rounded-tr-lg",
isActive
? "text-blue-600 dark:text-white border-blue-600 dark:border-blue-500"
: "text-gray-550 hover:text-blue-500 dark:hover:text-white border-transparent"
)}
aria-current={splitLocation[2] === item.href ? "page" : undefined}
activeOptions={{ exact: item.exact }}
search={{}}
params={{}}
// aria-current={splitLocation[2] === item.href ? "page" : undefined}
// className="transition border-b-2 whitespace-nowrap py-4 duration-3000 px-1 font-medium text-sm first:rounded-tl-lg last:rounded-tr-lg"
>
{item.name}
</NavLink>
{({ isActive }) => {
return (
<span
className={
classNames(
"transition border-b-2 whitespace-nowrap py-4 duration-3000 px-1 font-medium text-sm first:rounded-tl-lg last:rounded-tr-lg",
isActive
? "text-blue-600 dark:text-white border-blue-600 dark:border-blue-500"
: "text-gray-550 hover:text-blue-500 dark:hover:text-white border-transparent"
)
}>
{item.name}
</span>
)
}}
</Link>
);
}
@ -281,32 +292,20 @@ const schema = z.object({
});
export const FilterDetails = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { filterId } = useParams<{ filterId: string }>();
const ctx = FilterGetByIdRoute.useRouteContext()
const queryClient = ctx.queryClient
if (filterId === "0" || filterId === undefined) {
navigate("/filters");
}
const id = parseInt(filterId!);
const { isLoading, isError, data: filter } = useSuspenseQuery({
queryKey: filterKeys.detail(id),
queryFn: ({ queryKey }) => APIClient.filters.getByID(queryKey[2]),
refetchOnWindowFocus: false
});
if (isError) {
navigate("/filters");
}
const params = FilterGetByIdRoute.useParams()
const filterQuery = useSuspenseQuery(FilterByIdQueryOptions(params.filterId))
const filter = filterQuery.data
const updateMutation = useMutation({
mutationFn: (filter: Filter) => APIClient.filters.update(filter),
onSuccess: (newFilter, variables) => {
queryClient.setQueryData(filterKeys.detail(variables.id), newFilter);
queryClient.setQueryData(FilterKeys.detail(variables.id), newFilter);
queryClient.setQueryData<Filter[]>(filterKeys.lists(), (previous) => {
queryClient.setQueryData<Filter[]>(FilterKeys.lists(), (previous) => {
if (previous) {
return previous.map((filter: Filter) => (filter.id === variables.id ? newFilter : filter));
}
@ -322,22 +321,18 @@ export const FilterDetails = () => {
mutationFn: (id: number) => APIClient.filters.delete(id),
onSuccess: () => {
// Invalidate filters just in case, most likely not necessary but can't hurt.
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: filterKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
queryClient.removeQueries({ queryKey: FilterKeys.detail(params.filterId) });
toast.custom((t) => (
<Toast type="success" body={`${filter?.name} was deleted`} t={t} />
));
// redirect
navigate("/filters");
navigate({ to: "/filters" });
}
});
if (!filter) {
return null;
}
const handleSubmit = (data: Filter) => {
// force set method and type on webhook actions
// TODO add options for these
@ -362,9 +357,9 @@ export const FilterDetails = () => {
<main>
<div className="my-6 max-w-screen-xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center text-black dark:text-white">
<h1 className="text-3xl font-bold">
<NavLink to="/filters">
<Link to="/filters">
Filters
</NavLink>
</Link>
</h1>
<ChevronRightIcon className="h-6 w-4 shrink-0 sm:shrink sm:h-6 sm:w-6 mx-1" aria-hidden="true" />
<h1 className="text-3xl font-bold truncate" title={filter.name}>{filter.name}</h1>
@ -372,9 +367,9 @@ export const FilterDetails = () => {
<div className="max-w-screen-xl mx-auto pb-12 px-2 sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-250 dark:border-gray-775">
<div className="rounded-t-lg bg-gray-125 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
<nav className="px-4 -mb-px flex space-x-6 sm:space-x-8 overflow-x-auto">
<nav className="px-4 py-4 -mb-px flex space-x-6 sm:space-x-8 overflow-x-auto">
{tabs.map((tab) => (
<TabNavLink item={tab} key={tab.href} />
<TabNavLink key={tab.href} item={tab} />
))}
</nav>
</div>
@ -452,22 +447,13 @@ export const FilterDetails = () => {
{({ values, dirty, resetForm }) => (
<Form className="pt-1 pb-4 px-5">
<FormErrorNotification />
<Suspense fallback={<SectionLoader $size="large" />}>
<Routes>
<Route index element={<Section.General />} />
<Route path="movies-tv" element={<Section.MoviesTv />} />
<Route path="music" element={<Section.Music values={values} />} />
<Route path="advanced" element={<Section.Advanced values={values} />} />
<Route path="external" element={<Section.External />} />
<Route path="actions" element={<Section.Actions filter={filter} values={values} />} />
</Routes>
</Suspense>
<Outlet />
<FormButtonsGroup
values={values}
deleteAction={deleteAction}
dirty={dirty}
reset={resetForm}
isLoading={isLoading}
isLoading={false}
/>
<DEBUG values={values} />
</Form>

View file

@ -4,9 +4,9 @@ import { useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { FilterKeys } from "@api/query_keys";
import Toast from "@components/notifications/Toast";
import { filterKeys } from "./List";
import { AutodlIrssiConfigParser } from "./_configParser";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
@ -211,7 +211,7 @@ export const Importer = ({
} finally {
setIsOpen(false);
// Invalidate filter cache, and trigger refresh request
await queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
await queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
}
};

View file

@ -3,24 +3,23 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Dispatch, FC, Fragment, MouseEventHandler, useReducer, useRef, useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { Dispatch, FC, Fragment, MouseEventHandler, useCallback, useEffect, useReducer, useRef, useState } from "react";
import { Link } from '@tanstack/react-router'
import { toast } from "react-hot-toast";
import { Listbox, Menu, Transition } from "@headlessui/react";
import { useMutation, useQuery, useQueryClient, keepPreviousData, useSuspenseQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FormikValues } from "formik";
import { useCallback } from "react";
import {
ArrowsRightLeftIcon,
ArrowUpOnSquareIcon,
ChatBubbleBottomCenterTextIcon,
CheckIcon,
ChevronDownIcon,
PlusIcon,
DocumentDuplicateIcon,
EllipsisHorizontalIcon,
PencilSquareIcon,
ChatBubbleBottomCenterTextIcon,
TrashIcon,
ArrowUpOnSquareIcon
PlusIcon,
TrashIcon
} from "@heroicons/react/24/outline";
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
@ -29,6 +28,8 @@ import { classNames } from "@utils";
import { FilterAddForm } from "@forms";
import { useToggle } from "@hooks/hooks";
import { APIClient } from "@api/APIClient";
import { FilterKeys } from "@api/query_keys";
import { FiltersQueryOptions, IndexersOptionsQueryOptions } from "@api/queries";
import Toast from "@components/notifications/Toast";
import { EmptyListState } from "@components/emptystates";
import { DeleteModal } from "@components/modals";
@ -37,14 +38,6 @@ import { Importer } from "./Importer";
import { Tooltip } from "@components/tooltips/Tooltip";
import { Checkbox } from "@components/Checkbox";
export const filterKeys = {
all: ["filters"] as const,
lists: () => [...filterKeys.all, "list"] as const,
list: (indexers: string[], sortOrder: string) => [...filterKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...filterKeys.all, "detail"] as const,
detail: (id: number) => [...filterKeys.details(), id] as const
};
enum ActionType {
INDEXER_FILTER_CHANGE = "INDEXER_FILTER_CHANGE",
INDEXER_FILTER_RESET = "INDEXER_FILTER_RESET",
@ -192,11 +185,7 @@ function FilterList({ toggleCreateFilter }: any) {
filterListState
);
const { data, error } = useSuspenseQuery({
queryKey: filterKeys.list(indexerFilter, sortOrder),
queryFn: ({ queryKey }) => APIClient.filters.find(queryKey[2].indexers, queryKey[2].sortOrder),
refetchOnWindowFocus: false
});
const { data, error } = useQuery(FiltersQueryOptions(indexerFilter, sortOrder));
useEffect(() => {
FilterListContext.set({ indexerFilter, sortOrder, status });
@ -407,8 +396,8 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.filters.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: filterKeys.detail(filter.id) });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
queryClient.invalidateQueries({ queryKey: FilterKeys.detail(filter.id) });
toast.custom((t) => <Toast type="success" body={`Filter ${filter?.name} was deleted`} t={t} />);
}
@ -417,7 +406,7 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
const duplicateMutation = useMutation({
mutationFn: (id: number) => APIClient.filters.duplicate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
toast.custom((t) => <Toast type="success" body={`Filter ${filter?.name} duplicated`} t={t} />);
}
@ -459,7 +448,11 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
<Menu.Item>
{({ active }) => (
<Link
to={filter.id.toString()}
// to={filter.id.toString()}
to="/filters/$filterId"
params={{
filterId: filter.id
}}
className={classNames(
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
@ -600,8 +593,8 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
// We need to invalidate both keys here.
// The filters key is used on the /filters page,
// while the ["filter", filter.id] key is used on the details page.
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
queryClient.invalidateQueries({ queryKey: filterKeys.detail(filter.id) });
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
queryClient.invalidateQueries({ queryKey: FilterKeys.detail(filter.id) });
}
});
@ -629,7 +622,10 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
</span>
<div className="py-2 flex flex-col overflow-hidden w-full justify-center">
<Link
to={filter.id.toString()}
to="/filters/$filterId"
params={{
filterId: filter.id
}}
className="transition w-full break-words whitespace-wrap text-sm font-bold text-gray-800 dark:text-gray-100 hover:text-black dark:hover:text-gray-350"
>
{filter.name}
@ -645,7 +641,10 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
<Tooltip
label={
<Link
to={`${filter.id.toString()}/actions`}
to="/filters/$filterId/actions"
params={{
filterId: filter.id
}}
className="flex items-center cursor-pointer hover:text-black dark:hover:text-gray-300"
>
<span className={filter.actions_count === 0 || filter.actions_enabled_count === 0 ? "text-red-500 hover:text-red-400 dark:hover:text-red-400" : ""}>
@ -666,7 +665,10 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
</Tooltip>
) : (
<Link
to={`${filter.id.toString()}/actions`}
to="/filters/$filterId/actions"
params={{
filterId: filter.id
}}
className="flex items-center cursor-pointer hover:text-black dark:hover:text-gray-300"
>
<span>
@ -784,12 +786,9 @@ const ListboxFilter = ({
// a unique option from a list
const IndexerSelectFilter = ({ dispatch }: any) => {
const { data, isSuccess } = useQuery({
queryKey: ["filters", "indexers_options"],
queryFn: () => APIClient.indexers.getOptions(),
placeholderData: keepPreviousData,
staleTime: Infinity
});
const filterListState = FilterListContext.useValue();
const { data, isSuccess } = useQuery(IndexersOptionsQueryOptions());
const setFilter = (value: string) => {
if (value == undefined || value == "") {
@ -804,11 +803,11 @@ const IndexerSelectFilter = ({ dispatch }: any) => {
<ListboxFilter
id="1"
key="indexer-select"
label="Indexer"
currentValue={""}
label={data && filterListState.indexerFilter[0] ? `Indexer: ${data.find(i => i.identifier == filterListState.indexerFilter[0])?.name}` : "Indexer"}
currentValue={filterListState.indexerFilter[0] ?? ""}
onChange={setFilter}
>
<FilterOption label="All" />
<FilterOption label="All" value="" />
{isSuccess && data?.map((indexer, idx) => (
<FilterOption key={idx} label={indexer.name} value={indexer.identifier} />
))}
@ -830,7 +829,7 @@ const FilterOption = ({ label, value }: FilterOptionProps) => (
value={value}
>
{({ selected }) => (
<>
<div className="flex justify-between">
<span
className={classNames(
"block truncate",
@ -840,16 +839,18 @@ const FilterOption = ({ label, value }: FilterOptionProps) => (
{label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400">
<CheckIcon className="w-5 h-5" aria-hidden="true" />
</span>
) : null}
</>
</div>
)}
</Listbox.Option>
);
export const SortSelectFilter = ({ dispatch }: any) => {
const filterListState = FilterListContext.useValue();
const setFilter = (value: string) => {
if (value == undefined || value == "") {
dispatch({ type: ActionType.SORT_ORDER_RESET, payload: "" });
@ -870,8 +871,8 @@ export const SortSelectFilter = ({ dispatch }: any) => {
<ListboxFilter
id="sort"
key="sort-select"
label="Sort"
currentValue={""}
label={filterListState.sortOrder ? `Sort: ${options.find(o => o.value == filterListState.sortOrder)?.label}` : "Sort"}
currentValue={filterListState.sortOrder ?? ""}
onChange={setFilter}
>
<>

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Link } from "@tanstack/react-router";
import { FilterGetByIdRoute } from "@app/routes";
import { ExternalLink } from "@components/ExternalLink";
import Logo from "@app/logo.svg?react";
export const FilterNotFound = () => {
const { filterId } = FilterGetByIdRoute.useParams()
return (
<div className="mt-20 flex flex-col justify-center">
<div className="flex justify-center">
<Logo className="h-24 sm:h-48"/>
</div>
<h2 className="text-2xl text-center font-bold text-gray-900 dark:text-gray-200 my-8 px-2">
Status 404
</h2>
<h1 className="text-3xl text-center font-bold text-gray-900 dark:text-gray-200 my-8 px-2">
Filter with id <span className="text-blue-600 dark:text-blue-500">{filterId}</span> not found!
</h1>
<h3 className="text-xl text-center text-gray-700 dark:text-gray-400 mb-1 px-2">
In case you think this is a bug rather than too much brr,
</h3>
<h3 className="text-xl text-center text-gray-700 dark:text-gray-400 mb-1 px-2">
feel free to report this to our
{" "}
<ExternalLink
href="https://github.com/autobrr/autobrr"
className="text-gray-700 dark:text-gray-200 underline font-semibold underline-offset-2 decoration-sky-500 hover:decoration-2 hover:text-black hover:dark:text-gray-100"
>
GitHub page
</ExternalLink>
{" or to "}
<ExternalLink
href="https://discord.gg/WQ2eUycxyT"
className="text-gray-700 dark:text-gray-200 underline font-semibold underline-offset-2 decoration-purple-500 hover:decoration-2 hover:text-black hover:dark:text-gray-100"
>
our official Discord channel
</ExternalLink>
.
</h3>
<h3 className="text-xl text-center leading-6 text-gray-700 dark:text-gray-400 mb-8 px-2">
Otherwise, let us help you to get you back on track for more brr!
</h3>
<div className="flex justify-center">
<Link to="/filters">
<button
className="w-48 flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
>
Back to filters
</button>
</Link>
</div>
</div>
);
};

View file

@ -5,3 +5,4 @@
export { Filters } from "./List";
export { FilterDetails } from "./Details";
export { FilterNotFound } from "./NotFound";

View file

@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Field, FieldArray, useFormikContext } from "formik";
import type { FieldProps, FieldArrayRenderProps, FormikValues } from "formik";
import type { FieldProps, FieldArrayRenderProps } from "formik";
import { ChevronRightIcon, BoltIcon } from "@heroicons/react/24/solid";
import { classNames } from "@utils";
@ -25,18 +25,17 @@ import { TitleSubtitle } from "@components/headings";
import * as FilterSection from "./_components";
import * as FilterActions from "./action_components";
import { DownloadClientsQueryOptions } from "@api/queries";
interface FilterActionsProps {
filter: Filter;
values: FormikValues;
}
// interface FilterActionsProps {
// filter: Filter;
// values: FormikValues;
// }
export function Actions({ filter, values }: FilterActionsProps) {
const { data } = useQuery({
queryKey: ["filters", "download_clients"],
queryFn: () => APIClient.download_clients.getAll(),
refetchOnWindowFocus: false
});
export function Actions() {
const { values } = useFormikContext<Filter>();
const { data } = useQuery(DownloadClientsQueryOptions());
const newAction: Action = {
id: 0,
@ -63,7 +62,7 @@ export function Actions({ filter, values }: FilterActionsProps) {
reannounce_delete: false,
reannounce_interval: 7,
reannounce_max_attempts: 25,
filter_id: filter.id,
filter_id: values.id,
webhook_host: "",
webhook_type: "",
webhook_method: "",

View file

@ -1,4 +1,4 @@
import type { FormikValues } from "formik";
import { useFormikContext } from "formik";
import { DocsLink } from "@components/ExternalLink";
import { WarningAlert } from "@components/alerts";
@ -10,493 +10,533 @@ import { CollapsibleSection } from "./_components";
import * as Components from "./_components";
import { classNames } from "@utils";
type ValueConsumer = {
values: FormikValues;
};
// type ValueConsumer = {
// values: FormikValues;
// };
const Releases = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.use_regex || values.match_releases || values.except_releases}
title="Release Names"
subtitle="Match only certain release names and/or ignore other release names."
>
<Components.Layout>
<Components.HalfRow>
<Input.SwitchGroup name="use_regex" label="Use Regex" className="pt-2" />
</Components.HalfRow>
</Components.Layout>
const Releases = () => {
const { values } = useFormikContext<Filter>();
<Components.Layout>
<Components.HalfRow>
<Input.RegexTextAreaField
name="match_releases"
label="Match releases"
useRegex={values.use_regex}
columns={6}
placeholder="eg. *some?movie*,*some?show*s01*"
return (
<CollapsibleSection
//defaultOpen={values.use_regex || values.match_releases !== "" || values.except_releases !== ""}
title="Release Names"
subtitle="Match only certain release names and/or ignore other release names."
>
<Components.Layout>
<Components.HalfRow>
<Input.SwitchGroup name="use_regex" label="Use Regex" className="pt-2" />
</Components.HalfRow>
</Components.Layout>
<Components.Layout>
<Components.HalfRow>
<Input.RegexTextAreaField
name="match_releases"
label="Match releases"
useRegex={values.use_regex}
columns={6}
placeholder="eg. *some?movie*,*some?show*s01*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.HalfRow>
<Components.HalfRow>
<Input.RegexTextAreaField
name="except_releases"
label="Except releases"
useRegex={values.use_regex}
columns={6}
placeholder="eg. *bad?movie*,*bad?show*s03*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.HalfRow>
</Components.Layout>
{values.match_releases ? (
<WarningAlert
alert="Ask yourself:"
text={
<>
Do you have a good reason to use <strong>Match releases</strong> instead of one of the other tabs?
</>
}
colors="text-cyan-700 bg-cyan-100 dark:bg-cyan-200 dark:text-cyan-800"
/>
) : null}
{values.except_releases ? (
<WarningAlert
alert="Ask yourself:"
text={
<>
Do you have a good reason to use <strong>Except releases</strong> instead of one of the other tabs?
</>
}
colors="text-fuchsia-700 bg-fuchsia-100 dark:bg-fuchsia-200 dark:text-fuchsia-800"
/>
) : null}
</CollapsibleSection>
);
}
const Groups = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.match_release_groups !== "" || values.except_release_groups !== ""}
title="Groups"
subtitle="Match only certain groups and/or ignore other groups."
>
<Input.TextAreaAutoResize
name="match_release_groups"
label="Match release groups"
columns={6}
placeholder="eg. group1,group2"
tooltip={
<div>
<p>Comma separated list of release groups to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_release_groups"
label="Except release groups"
columns={6}
placeholder="eg. badgroup1,badgroup2"
tooltip={
<div>
<p>Comma separated list of release groups to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</CollapsibleSection>
);
}
const Categories = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.match_categories.length >0 || values.except_categories !== ""}
title="Categories"
subtitle="Match or exclude categories (if announced)"
>
<Input.TextAreaAutoResize
name="match_categories"
label="Match categories"
columns={6}
placeholder="eg. *category*,category1"
tooltip={
<div>
<p>Comma separated list of categories to match.</p>
<DocsLink href="https://autobrr.com/filters/categories" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_categories"
label="Except categories"
columns={6}
placeholder="eg. *category*"
tooltip={
<div>
<p>Comma separated list of categories to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters/categories" />
</div>
}
/>
</CollapsibleSection>
);
}
const Tags = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.tags !== "" || values.except_tags !== ""}
title="Tags"
subtitle="Match or exclude tags (if announced)"
>
<div className={classNames("sm:col-span-6", Components.LayoutClass, Components.TightGridGapClass)}>
<Input.TextAreaAutoResize
name="tags"
label="Match tags"
columns={8}
placeholder="eg. tag1,tag2"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<p>Comma separated list of tags to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.HalfRow>
<Components.HalfRow>
<Input.RegexTextAreaField
name="except_releases"
label="Except releases"
useRegex={values.use_regex}
columns={6}
placeholder="eg. *bad?movie*,*bad?show*s03*"
<Input.Select
name="tags_match_logic"
label="Match logic"
columns={4}
options={CONSTS.tagsMatchLogicOptions}
optionDefaultText="any"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<p>Logic used to match filter tags.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.HalfRow>
</div>
<div className={classNames("sm:col-span-6", Components.LayoutClass, Components.TightGridGapClass)}>
<Input.TextAreaAutoResize
name="except_tags"
label="Except tags"
columns={8}
placeholder="eg. tag1,tag2"
tooltip={
<div>
<p>Comma separated list of tags to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.Select
name="except_tags_match_logic"
label="Except logic"
columns={4}
options={CONSTS.tagsMatchLogicOptions}
optionDefaultText="any"
tooltip={
<div>
<p>Logic used to match except tags.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</div>
</CollapsibleSection>
);
}
</Components.Layout>
const Uploaders = () => {
// const { values } = useFormikContext<Filter>();
{values.match_releases ? (
<WarningAlert
alert="Ask yourself:"
text={
<>
Do you have a good reason to use <strong>Match releases</strong> instead of one of the other tabs?
</>
}
colors="text-cyan-700 bg-cyan-100 dark:bg-cyan-200 dark:text-cyan-800"
/>
) : null}
{values.except_releases ? (
<WarningAlert
alert="Ask yourself:"
text={
<>
Do you have a good reason to use <strong>Except releases</strong> instead of one of the other tabs?
</>
}
colors="text-fuchsia-700 bg-fuchsia-100 dark:bg-fuchsia-200 dark:text-fuchsia-800"
/>
) : null}
</CollapsibleSection>
);
const Groups = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.match_release_groups || values.except_release_groups}
title="Groups"
subtitle="Match only certain groups and/or ignore other groups."
>
<Input.TextAreaAutoResize
name="match_release_groups"
label="Match release groups"
columns={6}
placeholder="eg. group1,group2"
tooltip={
<div>
<p>Comma separated list of release groups to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_release_groups"
label="Except release groups"
columns={6}
placeholder="eg. badgroup1,badgroup2"
tooltip={
<div>
<p>Comma separated list of release groups to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</CollapsibleSection>
);
const Categories = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.match_categories || values.except_categories}
title="Categories"
subtitle="Match or exclude categories (if announced)"
>
<Input.TextAreaAutoResize
name="match_categories"
label="Match categories"
columns={6}
placeholder="eg. *category*,category1"
tooltip={
<div>
<p>Comma separated list of categories to match.</p>
<DocsLink href="https://autobrr.com/filters/categories" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_categories"
label="Except categories"
columns={6}
placeholder="eg. *category*"
tooltip={
<div>
<p>Comma separated list of categories to ignore (takes priority over Match releases).</p>
<DocsLink href="https://autobrr.com/filters/categories" />
</div>
}
/>
</CollapsibleSection>
);
const Tags = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.tags || values.except_tags}
title="Tags"
subtitle="Match or exclude tags (if announced)"
>
<div className={classNames("sm:col-span-6", Components.LayoutClass, Components.TightGridGapClass)}>
return (
<CollapsibleSection
//defaultOpen={values.match_uploaders !== "" || values.except_uploaders !== ""}
title="Uploaders"
subtitle="Match or ignore uploaders (if announced)"
>
<Input.TextAreaAutoResize
name="tags"
label="Match tags"
columns={8}
placeholder="eg. tag1,tag2"
name="match_uploaders"
label="Match uploaders"
columns={6}
placeholder="eg. uploader1,uploader2"
tooltip={
<div>
<p>Comma separated list of tags to match.</p>
<p>Comma separated list of uploaders to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.Select
name="tags_match_logic"
label="Match logic"
columns={4}
options={CONSTS.tagsMatchLogicOptions}
optionDefaultText="any"
tooltip={
<div>
<p>Logic used to match filter tags.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</div>
<div className={classNames("sm:col-span-6", Components.LayoutClass, Components.TightGridGapClass)}>
<Input.TextAreaAutoResize
name="except_tags"
label="Except tags"
columns={8}
placeholder="eg. tag1,tag2"
name="except_uploaders"
label="Except uploaders"
columns={6}
placeholder="eg. anonymous1,anonymous2"
tooltip={
<div>
<p>Comma separated list of tags to ignore (takes priority over Match releases).</p>
<p>Comma separated list of uploaders to ignore (takes priority over Match releases).
</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.Select
name="except_tags_match_logic"
label="Except logic"
columns={4}
options={CONSTS.tagsMatchLogicOptions}
optionDefaultText="any"
</CollapsibleSection>
);
}
const Language = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={(values.match_language && values.match_language.length > 0) || (values.except_language && values.except_language.length > 0)}
title="Language"
subtitle="Match or ignore languages (if announced)"
>
<Input.MultiSelect
name="match_language"
options={CONSTS.LANGUAGE_OPTIONS}
label="Match Language"
columns={6}
/>
<Input.MultiSelect
name="except_language"
options={CONSTS.LANGUAGE_OPTIONS}
label="Except Language"
columns={6}
/>
</CollapsibleSection>
);
}
const Origins = () => {
// const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={(values.origins && values.origins.length > 0 || values.except_origins && values.except_origins.length > 0)}
title="Origins"
subtitle="Match Internals, Scene, P2P, etc. (if announced)"
>
<Input.MultiSelect
name="origins"
options={CONSTS.ORIGIN_OPTIONS}
label="Match Origins"
columns={6}
/>
<Input.MultiSelect
name="except_origins"
options={CONSTS.ORIGIN_OPTIONS}
label="Except Origins"
columns={6}
/>
</CollapsibleSection>
);
}
const Freeleech = () => {
const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.freeleech || values.freeleech_percent !== ""}
title="Freeleech"
subtitle="Match based off freeleech (if announced)"
>
<Input.TextField
name="freeleech_percent"
label="Freeleech percent"
disabled={values.freeleech}
tooltip={
<div>
<p>Logic used to match except tags.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</div>
</CollapsibleSection>
);
const Uploaders = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.uploaders || values.except_uploaders}
title="Uploaders"
subtitle="Match or ignore uploaders (if announced)"
>
<Input.TextAreaAutoResize
name="match_uploaders"
label="Match uploaders"
columns={6}
placeholder="eg. uploader1,uploader2"
tooltip={
<div>
<p>Comma separated list of uploaders to match.</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
<Input.TextAreaAutoResize
name="except_uploaders"
label="Except uploaders"
columns={6}
placeholder="eg. anonymous1,anonymous2"
tooltip={
<div>
<p>Comma separated list of uploaders to ignore (takes priority over Match releases).
</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
</div>
}
/>
</CollapsibleSection>
);
const Language = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={(values.match_language && values.match_language.length > 0) || (values.except_language && values.except_language.length > 0)}
title="Language"
subtitle="Match or ignore languages (if announced)"
>
<Input.MultiSelect
name="match_language"
options={CONSTS.LANGUAGE_OPTIONS}
label="Match Language"
columns={6}
/>
<Input.MultiSelect
name="except_language"
options={CONSTS.LANGUAGE_OPTIONS}
label="Except Language"
columns={6}
/>
</CollapsibleSection>
);
const Origins = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={(values.origins && values.origins.length > 0 || values.except_origins && values.except_origins.length > 0)}
title="Origins"
subtitle="Match Internals, Scene, P2P, etc. (if announced)"
>
<Input.MultiSelect
name="origins"
options={CONSTS.ORIGIN_OPTIONS}
label="Match Origins"
columns={6}
/>
<Input.MultiSelect
name="except_origins"
options={CONSTS.ORIGIN_OPTIONS}
label="Except Origins"
columns={6}
/>
</CollapsibleSection>
);
const Freeleech = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.freeleech || values.freeleech_percent}
title="Freeleech"
subtitle="Match based off freeleech (if announced)"
>
<Input.TextField
name="freeleech_percent"
label="Freeleech percent"
disabled={values.freeleech}
tooltip={
<div>
<p>
<p>
Freeleech may be announced as a binary true/false value or as a
percentage (less likely), depending on the indexer. Use one <span className="font-bold">or</span> the other.
The Freeleech toggle overrides this field if it is toggled/true.
</p>
<br />
<p>
Refer to our documentation for more details:{" "}
<DocsLink href="https://autobrr.com/filters/freeleech" />
</p>
</div>
}
columns={6}
placeholder="eg. 50,75-100"
/>
<Components.HalfRow>
<Input.SwitchGroup
name="freeleech"
label="Freeleech"
className="py-0"
description="Cannot be used with Freeleech percent. Overrides Freeleech percent if toggled/true."
tooltip={
<div>
<p>
Freeleech may be announced as a binary true/false value (more likely) or as a
percentage, depending on the indexer. Use one <span className="font-bold">or</span> the other.
This field overrides Freeleech percent if it is toggled/true.
</p>
<br />
<p>
See who uses what in the documentation:{" "}
Refer to our documentation for more details:{" "}
<DocsLink href="https://autobrr.com/filters/freeleech" />
</p>
</div>
}
columns={6}
placeholder="eg. 50,75-100"
/>
</Components.HalfRow>
</CollapsibleSection>
);
<Components.HalfRow>
<Input.SwitchGroup
name="freeleech"
label="Freeleech"
className="py-0"
description="Cannot be used with Freeleech percent. Overrides Freeleech percent if toggled/true."
tooltip={
<div>
<p>
Freeleech may be announced as a binary true/false value (more likely) or as a
percentage, depending on the indexer. Use one <span className="font-bold">or</span> the other.
This field overrides Freeleech percent if it is toggled/true.
</p>
<br />
<p>
See who uses what in the documentation:{" "}
<DocsLink href="https://autobrr.com/filters/freeleech" />
</p>
</div>
}
/>
</Components.HalfRow>
</CollapsibleSection>
);
}
const FeedSpecific = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.use_regex_description || values.match_description || values.except_description}
title="RSS/Torznab/Newznab-specific"
subtitle={
<>These options are <span className="font-bold">only</span> for Feeds such as RSS, Torznab and Newznab</>
}
>
<Components.Layout>
<Input.SwitchGroup
name="use_regex_description"
label="Use Regex"
className="col-span-12 sm:col-span-6"
const FeedSpecific = () => {
const { values } = useFormikContext<Filter>();
return (
<CollapsibleSection
//defaultOpen={values.use_regex_description || values.match_description || values.except_description}
title="RSS/Torznab/Newznab-specific"
subtitle={
<>These options are <span className="font-bold">only</span> for Feeds such as RSS, Torznab and Newznab</>
}
>
<Components.Layout>
<Input.SwitchGroup
name="use_regex_description"
label="Use Regex"
className="col-span-12 sm:col-span-6"
/>
</Components.Layout>
<Input.RegexTextAreaField
name="match_description"
label="Match description"
useRegex={values.use_regex_description}
columns={6}
placeholder="eg. *some?movie*,*some?show*s01*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.Layout>
<Input.RegexTextAreaField
name="match_description"
label="Match description"
useRegex={values.use_regex_description}
columns={6}
placeholder="eg. *some?movie*,*some?show*s01*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
<Input.RegexTextAreaField
name="except_description"
label="Except description"
useRegex={values.use_regex_description}
columns={6}
placeholder="eg. *bad?movie*,*bad?show*s03*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
<Input.NumberField
name="min_seeders"
label="Min Seeders"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of min seeders as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="max_seeders"
label="Max Seeders"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of max seeders as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="min_leechers"
label="Min Leechers"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of min leechers as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="max_leechers"
label="Max Leechers"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of max leechers as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
</CollapsibleSection>
);
const RawReleaseTags = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={values.use_regex_release_tags || values.match_release_tags || values.except_release_tags}
title="Raw Release Tags"
subtitle={
<>
<span className="underline underline-offset-2">Advanced users only</span>
{": "}This is the <span className="font-bold">raw</span> releaseTags string from the announce.
</>
}
>
<WarningAlert
text={
<>These might not be what you think they are. For <span className="underline font-bold">very advanced</span> users who know how things are parsed.</>
}
/>
<Components.Layout>
<Input.SwitchGroup
name="use_regex_release_tags"
label="Use Regex"
className="col-span-12 sm:col-span-6"
<Input.RegexTextAreaField
name="except_description"
label="Except description"
useRegex={values.use_regex_description}
columns={6}
placeholder="eg. *bad?movie*,*bad?show*s03*"
tooltip={
<div>
<p>This field has full regex support (Golang flavour).</p>
<DocsLink href="https://autobrr.com/filters#advanced" />
<br />
<br />
<p>Remember to tick <b>Use Regex</b> below if using more than <code>*</code> and <code>?</code>.</p>
</div>
}
/>
</Components.Layout>
<Input.NumberField
name="min_seeders"
label="Min Seeders"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of min seeders as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="max_seeders"
label="Max Seeders"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of max seeders as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="min_leechers"
label="Min Leechers"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of min leechers as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="max_leechers"
label="Max Leechers"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of max leechers as specified by the respective unit. Only for Torznab</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
</CollapsibleSection>
);
}
const RawReleaseTags = () => {
const { values } = useFormikContext<Filter>();
<Input.RegexField
name="match_release_tags"
label="Match release tags"
useRegex={values.use_regex_release_tags}
columns={6}
placeholder="eg. *mkv*,*foreign*"
/>
<Input.RegexField
name="except_release_tags"
label="Except release tags"
useRegex={values.use_regex_release_tags}
columns={6}
placeholder="eg. *mkv*,*foreign*"
/>
</CollapsibleSection>
);
return (
<CollapsibleSection
//defaultOpen={values.use_regex_release_tags || values.match_release_tags || values.except_release_tags}
title="Raw Release Tags"
subtitle={
<>
<span className="underline underline-offset-2">Advanced users only</span>
{": "}This is the <span className="font-bold">raw</span> releaseTags string from the announce.
</>
}
>
<WarningAlert
text={
<>These might not be what you think they are. For <span className="underline font-bold">very advanced</span> users who know how things are parsed.</>
}
/>
export const Advanced = ({ values }: { values: FormikValues; }) => (
<div className="flex flex-col w-full gap-y-4 py-2 sm:-mx-1">
<Releases values={values} />
<Groups values={values} />
<Categories values={values} />
<Freeleech values={values} />
<Tags values={values}/>
<Uploaders values={values}/>
<Language values={values}/>
<Origins values={values} />
<FeedSpecific values={values} />
<RawReleaseTags values={values} />
</div>
);
<Components.Layout>
<Input.SwitchGroup
name="use_regex_release_tags"
label="Use Regex"
className="col-span-12 sm:col-span-6"
/>
</Components.Layout>
<Input.RegexField
name="match_release_tags"
label="Match release tags"
useRegex={values.use_regex_release_tags}
columns={6}
placeholder="eg. *mkv*,*foreign*"
/>
<Input.RegexField
name="except_release_tags"
label="Except release tags"
useRegex={values.use_regex_release_tags}
columns={6}
placeholder="eg. *mkv*,*foreign*"
/>
</CollapsibleSection>
);
}
export const Advanced = () => {
return (
<div className="flex flex-col w-full gap-y-4 py-2 sm:-mx-1">
<Releases />
<Groups />
<Categories />
<Freeleech />
<Tags />
<Uploaders />
<Language />
<Origins />
<FeedSpecific />
<RawReleaseTags />
</div>
);
}

View file

@ -1,25 +1,23 @@
import { useQuery } from "@tanstack/react-query";
import { useSuspenseQuery } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import { downloadsPerUnitOptions } from "@domain/constants";
import { IndexersOptionsQueryOptions } from "@api/queries";
import { DocsLink } from "@components/ExternalLink";
import * as Input from "@components/inputs";
import * as Components from "./_components";
const MapIndexer = (indexer: Indexer) => (
{ label: indexer.name, value: indexer.id } as Input.MultiSelectOption
);
export const General = () => {
const { isLoading, data } = useQuery({
queryKey: ["filters", "indexer_list"],
queryFn: APIClient.indexers.getOptions,
refetchOnWindowFocus: false
});
const indexersQuery = useSuspenseQuery(IndexersOptionsQueryOptions())
const indexerOptions = indexersQuery.data && indexersQuery.data.map(MapIndexer)
const indexerOptions = data?.map(MapIndexer) ?? [];
// const indexerOptions = data?.map(MapIndexer) ?? [];
return (
<Components.Page>
@ -27,9 +25,9 @@ export const General = () => {
<Components.Layout>
<Input.TextField name="name" label="Filter name" columns={6} placeholder="eg. Filter 1" />
{!isLoading && (
{/*{!isLoading && (*/}
<Input.IndexerMultiSelect name="indexers" options={indexerOptions} label="Indexers" columns={6} />
)}
{/*)}*/}
</Components.Layout>
</Components.Section>

View file

@ -1,4 +1,4 @@
import type { FormikValues } from "formik";
import { useFormikContext } from "formik";
import { DocsLink } from "@components/ExternalLink";
import * as Input from "@components/inputs";
@ -6,182 +6,186 @@ import * as Input from "@components/inputs";
import * as CONSTS from "@domain/constants";
import * as Components from "./_components";
export const Music = ({ values }: { values: FormikValues; }) => (
<Components.Page>
<Components.Section>
<Components.Layout>
<Input.TextAreaAutoResize
name="artists"
label="Artists"
columns={6}
placeholder="eg. Artist One"
tooltip={
<div>
<p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
<Input.TextAreaAutoResize
name="albums"
label="Albums"
columns={6}
placeholder="eg. That Album"
tooltip={
<div>
<p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
</Components.Layout>
</Components.Section>
export const Music = () => {
const { values } = useFormikContext<Filter>();
<Components.Section
title="Release details"
subtitle="Type (Album, Single, EP, etc.) and year of release (if announced)"
>
<Components.Layout>
<Input.MultiSelect
name="match_release_types"
options={CONSTS.RELEASE_TYPE_MUSIC_OPTIONS}
label="Music Type"
columns={6}
tooltip={
<div>
<p>Will only match releases with any of the selected types.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Input.TextField
name="years"
label="Years"
columns={6}
placeholder="eg. 2018,2019-2021"
tooltip={
<div>
<p>This field takes a range of years and/or comma separated single years.</p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
</Components.Layout>
</Components.Section>
<Components.Section
title="Quality"
subtitle="Format, source, log, etc."
>
<Components.Layout>
return (
<Components.Page>
<Components.Section>
<Components.Layout>
<Input.MultiSelect
name="formats"
options={CONSTS.FORMATS_OPTIONS}
label="Format"
columns={4}
disabled={values.perfect_flac}
<Input.TextAreaAutoResize
name="artists"
label="Artists"
columns={6}
placeholder="eg. Artist One"
tooltip={
<div>
<p>Will only match releases with any of the selected formats. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
<p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
<Input.MultiSelect
name="quality"
options={CONSTS.QUALITY_MUSIC_OPTIONS}
label="Quality"
columns={4}
disabled={values.perfect_flac}
<Input.TextAreaAutoResize
name="albums"
label="Albums"
columns={6}
placeholder="eg. That Album"
tooltip={
<div>
<p>Will only match releases with any of the selected qualities. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Input.MultiSelect
name="media"
options={CONSTS.SOURCES_MUSIC_OPTIONS}
label="Media"
columns={4}
disabled={values.perfect_flac}
tooltip={
<div>
<p>Will only match releases with any of the selected sources. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
<p>You can use basic filtering like wildcards <code>*</code> or replace single characters with <code>?</code></p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
</Components.Layout>
</Components.Section>
<Components.Layout className="items-end sm:!gap-x-6">
<Components.Row className="sm:col-span-4">
<Input.SwitchGroup
name="cue"
label="Cue"
description="Must include CUE info"
<Components.Section
title="Release details"
subtitle="Type (Album, Single, EP, etc.) and year of release (if announced)"
>
<Components.Layout>
<Input.MultiSelect
name="match_release_types"
options={CONSTS.RELEASE_TYPE_MUSIC_OPTIONS}
label="Music Type"
columns={6}
tooltip={
<div>
<p>Will only match releases with any of the selected types.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Input.TextField
name="years"
label="Years"
columns={6}
placeholder="eg. 2018,2019-2021"
tooltip={
<div>
<p>This field takes a range of years and/or comma separated single years.</p>
<DocsLink href="https://autobrr.com/filters#music" />
</div>
}
/>
</Components.Layout>
</Components.Section>
<Components.Section
title="Quality"
subtitle="Format, source, log, etc."
>
<Components.Layout>
<Components.Layout>
<Input.MultiSelect
name="formats"
options={CONSTS.FORMATS_OPTIONS}
label="Format"
columns={4}
disabled={values.perfect_flac}
className="sm:col-span-4"
/>
</Components.Row>
<Components.Row className="sm:col-span-4">
<Input.SwitchGroup
name="log"
label="Log"
description="Must include LOG info"
disabled={values.perfect_flac}
className="sm:col-span-4"
/>
</Components.Row>
<Components.Row className="sm:col-span-4">
<Input.NumberField
name="log_score"
label="Log score"
placeholder="eg. 100"
min={0}
max={100}
disabled={values.perfect_flac || !values.log}
tooltip={
<div>
<p>Log scores go from 0 to 100. This is overridden by Perfect FLAC.</p>
<p>Will only match releases with any of the selected formats. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
</Components.Row>
</Components.Layout>
</Components.Layout>
<Input.MultiSelect
name="quality"
options={CONSTS.QUALITY_MUSIC_OPTIONS}
label="Quality"
columns={4}
disabled={values.perfect_flac}
tooltip={
<div>
<p>Will only match releases with any of the selected qualities. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Input.MultiSelect
name="media"
options={CONSTS.SOURCES_MUSIC_OPTIONS}
label="Media"
columns={4}
disabled={values.perfect_flac}
tooltip={
<div>
<p>Will only match releases with any of the selected sources. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
</Components.Layout>
<div className="col-span-12 flex items-center justify-center">
<span className="border-b border-gray-150 dark:border-gray-750 w-full" />
<span className="flex mx-2 shrink-0 text-lg font-bold uppercase tracking-wide text-gray-700 dark:text-gray-200">
<Components.Layout className="items-end sm:!gap-x-6">
<Components.Row className="sm:col-span-4">
<Input.SwitchGroup
name="cue"
label="Cue"
description="Must include CUE info"
disabled={values.perfect_flac}
className="sm:col-span-4"
/>
</Components.Row>
<Components.Row className="sm:col-span-4">
<Input.SwitchGroup
name="log"
label="Log"
description="Must include LOG info"
disabled={values.perfect_flac}
className="sm:col-span-4"
/>
</Components.Row>
<Components.Row className="sm:col-span-4">
<Input.NumberField
name="log_score"
label="Log score"
placeholder="eg. 100"
min={0}
max={100}
disabled={values.perfect_flac || !values.log}
tooltip={
<div>
<p>Log scores go from 0 to 100. This is overridden by Perfect FLAC.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
</Components.Row>
</Components.Layout>
</Components.Layout>
<div className="col-span-12 flex items-center justify-center">
<span className="border-b border-gray-150 dark:border-gray-750 w-full" />
<span className="flex mx-2 shrink-0 text-lg font-bold uppercase tracking-wide text-gray-700 dark:text-gray-200">
OR
</span>
<span className="border-b border-gray-150 dark:border-gray-750 w-full" />
</div>
<span className="border-b border-gray-150 dark:border-gray-750 w-full" />
</div>
<Components.Layout className="sm:!gap-x-6">
<Input.SwitchGroup
name="perfect_flac"
label="Perfect FLAC"
description="Override all options about quality, source, format, and cue/log/log score."
className="py-2 col-span-12 sm:col-span-6"
tooltip={
<div>
<p>Override all options about quality, source, format, and CUE/LOG/LOG score.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<Components.Layout className="sm:!gap-x-6">
<Input.SwitchGroup
name="perfect_flac"
label="Perfect FLAC"
description="Override all options about quality, source, format, and cue/log/log score."
className="py-2 col-span-12 sm:col-span-6"
tooltip={
<div>
<p>Override all options about quality, source, format, and CUE/LOG/LOG score.</p>
<DocsLink href="https://autobrr.com/filters/music#quality" />
</div>
}
/>
<span className="col-span-12 sm:col-span-6 self-center ml-0 text-center sm:text-left text-sm text-gray-500 dark:text-gray-425 underline underline-offset-2">
<span className="col-span-12 sm:col-span-6 self-center ml-0 text-center sm:text-left text-sm text-gray-500 dark:text-gray-425 underline underline-offset-2">
This is what you want in 90% of cases (instead of options above).
</span>
</Components.Layout>
</Components.Section>
</Components.Page>
);
</Components.Layout>
</Components.Section>
</Components.Page>
);
}

View file

@ -1,4 +1,4 @@
import { Link } from "react-router-dom";
import { Link } from "@tanstack/react-router";
import { DocsLink } from "@components/ExternalLink";
import { ActionContentLayoutOptions, ActionPriorityOptions } from "@domain/constants";

View file

@ -4,15 +4,15 @@
*/
import * as React from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Listbox, Transition } from "@headlessui/react";
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/solid";
import { APIClient } from "@api/APIClient";
import { classNames } from "@utils";
import { PushStatusOptions } from "@domain/constants";
import { FilterProps } from "react-table";
import { DebounceInput } from "react-debounce-input";
import { ReleasesIndexersQueryOptions } from "@api/queries";
interface ListboxFilterProps {
id: string;
@ -54,7 +54,7 @@ const ListboxFilter = ({
<Listbox.Options
className="absolute z-10 w-full mt-1 overflow-auto text-base bg-white dark:bg-gray-800 rounded-md shadow-lg max-h-60 border border-opacity-5 border-black dark:border-gray-700 dark:border-opacity-40 focus:outline-none sm:text-sm"
>
<FilterOption label="All" />
<FilterOption label="All" value="" />
{children}
</Listbox.Options>
</Transition>
@ -67,12 +67,7 @@ const ListboxFilter = ({
export const IndexerSelectColumnFilter = ({
column: { filterValue, setFilter, id }
}: FilterProps<object>) => {
const { data, isSuccess } = useQuery({
queryKey: ["indexer_options"],
queryFn: () => APIClient.release.indexerOptions(),
placeholderData: keepPreviousData,
staleTime: Infinity
});
const { data, isSuccess } = useQuery(ReleasesIndexersQueryOptions());
// Render a multi-select box
return (
@ -80,10 +75,10 @@ export const IndexerSelectColumnFilter = ({
id={id}
key={id}
label={filterValue ?? "Indexer"}
currentValue={filterValue}
currentValue={filterValue ?? ""}
onChange={setFilter}
>
{isSuccess && data?.map((indexer, idx) => (
{isSuccess && data && data?.map((indexer, idx) => (
<FilterOption key={idx} label={indexer} value={indexer} />
))}
</ListboxFilter>
@ -138,7 +133,7 @@ export const PushStatusSelectColumnFilter = ({
<ListboxFilter
id={id}
label={label ?? "Push status"}
currentValue={filterValue}
currentValue={filterValue ?? ""}
onChange={setFilter}
>
{PushStatusOptions.map((status, idx) => (

View file

@ -4,33 +4,28 @@
*/
import React, { useState } from "react";
import { useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Column, useFilters, usePagination, useSortBy, useTable } from "react-table";
import {
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
ChevronLeftIcon,
ChevronRightIcon
ChevronRightIcon,
EyeIcon,
EyeSlashIcon
} from "@heroicons/react/24/solid";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
import { ReleasesIndexRoute } from "@app/routes";
import { ReleasesListQueryOptions } from "@api/queries";
import { RandomLinuxIsos } from "@utils";
import { APIClient } from "@api/APIClient";
import { EmptyListState } from "@components/emptystates";
import * as Icons from "@components/Icons";
import { RingResizeSpinner } from "@components/Icons";
import * as DataTable from "@components/data-table";
import { IndexerSelectColumnFilter, PushStatusSelectColumnFilter, SearchColumnFilter } from "./Filters";
export const releaseKeys = {
all: ["releases"] as const,
lists: () => [...releaseKeys.all, "list"] as const,
list: (pageIndex: number, pageSize: number, filters: ReleaseFilter[]) => [...releaseKeys.lists(), { pageIndex, pageSize, filters }] as const,
details: () => [...releaseKeys.all, "detail"] as const,
detail: (id: number) => [...releaseKeys.details(), id] as const
};
import { IndexerSelectColumnFilter, PushStatusSelectColumnFilter, SearchColumnFilter } from "./ReleaseFilters";
import { EmptyListState } from "@components/emptystates";
type TableState = {
queryPageIndex: number;
@ -79,10 +74,28 @@ const TableReducer = (state: TableState, action: Actions): TableState => {
}
};
const EmptyReleaseList = () => (
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<table className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
<tr>
<th>
<div className="flex items-center justify-between">
<span className="h-10"/>
</div>
</th>
</tr>
</thead>
</table>
<div className="flex items-center justify-center py-52">
<EmptyListState text="No results"/>
</div>
</div>
);
export const ReleaseTable = () => {
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const filterTypeFromUrl = queryParams.get("filter");
const search = ReleasesIndexRoute.useSearch()
const columns = React.useMemo(() => [
{
Header: "Age",
@ -116,14 +129,14 @@ export const ReleaseTable = () => {
}
] as Column<Release>[], []);
if (search.action_status != "") {
initialState.queryFilters = [{id: "action_status", value: search.action_status! }]
}
const [{ queryPageIndex, queryPageSize, totalCount, queryFilters }, dispatch] =
React.useReducer(TableReducer, initialState);
const { isLoading, error, data, isSuccess } = useQuery({
queryKey: releaseKeys.list(queryPageIndex, queryPageSize, queryFilters),
queryFn: () => APIClient.release.findQuery(queryPageIndex * queryPageSize, queryPageSize, queryFilters),
staleTime: 5000
});
const { isLoading, error, data, isSuccess } = useQuery(ReleasesListQueryOptions(queryPageIndex * queryPageSize, queryPageSize, queryFilters));
const [modifiedData, setModifiedData] = useState<Release[]>([]);
const [showLinuxIsos, setShowLinuxIsos] = useState(false);
@ -207,10 +220,10 @@ export const ReleaseTable = () => {
}, [filters]);
React.useEffect(() => {
if (filterTypeFromUrl != null) {
dispatch({ type: ActionType.FILTER_CHANGED, payload: [{ id: "action_status", value: filterTypeFromUrl! }] });
if (search.action_status != null) {
dispatch({ type: ActionType.FILTER_CHANGED, payload: [{ id: "action_status", value: search.action_status! }] });
}
}, [filterTypeFromUrl]);
}, [search.action_status]);
if (error) {
return <p>Error</p>;
@ -218,167 +231,33 @@ export const ReleaseTable = () => {
if (isLoading) {
return (
<div className="flex flex-col animate-pulse">
<div>
<div className="flex mb-6 flex-col sm:flex-row">
{headerGroups.map((headerGroup) =>
headerGroup.headers.map((column) => (
{ headerGroups.map((headerGroup) => headerGroup.headers.map((column) => (
column.Filter ? (
<React.Fragment key={column.id}>{column.render("Filter")}</React.Fragment>
<React.Fragment key={ column.id }>{ column.render("Filter") }</React.Fragment>
) : null
))
)}
) }
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-lg rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-800">
<tr>
<th
scope="col"
className="first:pl-5 pl-3 pr-3 py-3 first:rounded-tl-md last:rounded-tr-md text-xs font-medium tracking-wider text-left text-gray-500 uppercase group"
>
<div className="flex items-center justify-between">
{/* Add a sort direction indicator */}
<span className="h-4">
</span>
</div>
</th>
</tr>
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-lg rounded-md mt-4">
<table className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
<tr>
<th>
<div className="flex items-center justify-between">
<span className="h-10"/>
</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-150 dark:divide-gray-700">
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr className="justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap text-center">
<p className="text-black dark:text-white">Loading release table...</p>
</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
<tr
className="flex justify-between py-3 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
</tr>
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between flex-1 sm:hidden">
<DataTable.Button onClick={() => previousPage()} disabled={!canPreviousPage}>Previous</DataTable.Button>
<DataTable.Button onClick={() => nextPage()} disabled={!canNextPage}>Next</DataTable.Button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-baseline gap-x-2">
<span className="text-sm text-gray-700 dark:text-gray-500">
Page <span className="font-medium">{pageIndex + 1}</span> of <span className="font-medium">{pageOptions.length}</span>
</span>
<label>
<span className="sr-only bg-gray-700">Items Per Page</span>
<select
className="py-1 pl-2 pr-8 text-sm block w-full border-gray-300 rounded-md shadow-sm cursor-pointer dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100 dark:hover:text-gray-500 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
value={pageSize}
onChange={e => {
setPageSize(Number(e.target.value));
}}
>
{[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
Show {pageSize}
</option>
))}
</select>
</label>
</div>
<div>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<DataTable.PageButton
className="rounded-l-md"
onClick={() => gotoPage(0)}
disabled={!canPreviousPage}
>
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">First</span>
<ChevronDoubleLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
onClick={() => previousPage()}
disabled={!canPreviousPage}
>
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Previous</span>
<ChevronLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
onClick={() => nextPage()}
disabled={!canNextPage}>
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Next</span>
<ChevronRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
className="rounded-r-md"
onClick={() => gotoPage(pageCount - 1)}
disabled={!canNextPage}
>
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Last</span>
<ChevronDoubleRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true"/>
</DataTable.PageButton>
</nav>
</div>
</div>
<div className="flex items-center justify-center py-64">
<RingResizeSpinner className="text-blue-500 size-24"/>
</div>
</div>
</div>
);
}
if (!data) {
return <EmptyListState text="No recent activity" />;
)
}
// Render the UI for your table
@ -394,18 +273,21 @@ export const ReleaseTable = () => {
)}
</div>
<div className="relative">
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850">
{displayData.length === 0
? <EmptyReleaseList/>
: (
<div className="bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 shadow-table rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-100 dark:bg-gray-850">
{headerGroups.map((headerGroup) => {
const { key: rowKey, ...rowRest } = headerGroup.getHeaderGroupProps();
const {key: rowKey, ...rowRest} = headerGroup.getHeaderGroupProps();
return (
<tr key={rowKey} {...rowRest}>
{headerGroup.headers.map((column) => {
const { key: columnKey, ...columnRest } = column.getHeaderProps(column.getSortByToggleProps());
const {key: columnKey, ...columnRest} = column.getHeaderProps(column.getSortByToggleProps());
return (
// Add the sorting props to control sorting. For this example
// we can add them into the header props
// Add the sorting props to control sorting. For this example
// we can add them into the header props
<th
key={`${rowKey}-${columnKey}`}
scope="col"
@ -418,12 +300,12 @@ export const ReleaseTable = () => {
<span>
{column.isSorted ? (
column.isSortedDesc ? (
<Icons.SortDownIcon className="w-4 h-4 text-gray-400" />
<Icons.SortDownIcon className="w-4 h-4 text-gray-400"/>
) : (
<Icons.SortUpIcon className="w-4 h-4 text-gray-400" />
<Icons.SortUpIcon className="w-4 h-4 text-gray-400"/>
)
) : (
<Icons.SortIcon className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100" />
<Icons.SortIcon className="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100"/>
)}
</span>
</div>
@ -433,19 +315,19 @@ export const ReleaseTable = () => {
</tr>
);
})}
</thead>
<tbody
{...getTableBodyProps()}
className="divide-y divide-gray-150 dark:divide-gray-750"
>
</thead>
<tbody
{...getTableBodyProps()}
className="divide-y divide-gray-150 dark:divide-gray-750"
>
{page.map((row) => {
prepareRow(row);
const { key: bodyRowKey, ...bodyRowRest } = row.getRowProps();
const {key: bodyRowKey, ...bodyRowRest} = row.getRowProps();
return (
<tr key={bodyRowKey} {...bodyRowRest}>
{row.cells.map((cell) => {
const { key: cellRowKey, ...cellRowRest } = cell.getCellProps();
const {key: cellRowKey, ...cellRowRest} = cell.getCellProps();
return (
<td
key={cellRowKey}
@ -460,88 +342,90 @@ export const ReleaseTable = () => {
</tr>
);
})}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between flex-1 sm:hidden">
<DataTable.Button onClick={() => previousPage()} disabled={!canPreviousPage}>Previous</DataTable.Button>
<DataTable.Button onClick={() => nextPage()} disabled={!canNextPage}>Next</DataTable.Button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-baseline gap-x-2">
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between flex-1 sm:hidden">
<DataTable.Button onClick={() => previousPage()} disabled={!canPreviousPage}>Previous</DataTable.Button>
<DataTable.Button onClick={() => nextPage()} disabled={!canNextPage}>Next</DataTable.Button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-baseline gap-x-2">
<span className="text-sm text-gray-700 dark:text-gray-500">
Page <span className="font-medium">{pageIndex + 1}</span> of <span className="font-medium">{pageOptions.length}</span>
Page <span className="font-medium">{pageIndex + 1}</span> of <span
className="font-medium">{pageOptions.length}</span>
</span>
<label>
<span className="sr-only bg-gray-700">Items Per Page</span>
<select
className="py-1 pl-2 pr-8 text-sm block w-full border-gray-300 rounded-md shadow-sm cursor-pointer transition-colors dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:text-gray-200 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
value={pageSize}
onChange={e => {
setPageSize(Number(e.target.value));
}}
>
{[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
{pageSize} entries
</option>
))}
</select>
</label>
</div>
<div>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<DataTable.PageButton
className="rounded-l-md"
onClick={() => gotoPage(0)}
disabled={!canPreviousPage}
>
<span className="sr-only">First</span>
<ChevronDoubleLeftIcon className="w-4 h-4" aria-hidden="true" />
</DataTable.PageButton>
<DataTable.PageButton
className="pl-1 pr-2"
onClick={() => previousPage()}
disabled={!canPreviousPage}
>
<ChevronLeftIcon className="w-4 h-4 mr-1" aria-hidden="true" />
<span>Prev</span>
</DataTable.PageButton>
<DataTable.PageButton
className="pl-2 pr-1"
onClick={() => nextPage()}
disabled={!canNextPage}>
<span>Next</span>
<ChevronRightIcon className="w-4 h-4 ml-1" aria-hidden="true" />
</DataTable.PageButton>
<DataTable.PageButton
className="rounded-r-md"
onClick={() => gotoPage(pageCount - 1)}
disabled={!canNextPage}
>
<ChevronDoubleRightIcon className="w-4 h-4" aria-hidden="true" />
<span className="sr-only">Last</span>
</DataTable.PageButton>
</nav>
<label>
<span className="sr-only bg-gray-700">Items Per Page</span>
<select
className="py-1 pl-2 pr-8 text-sm block w-full border-gray-300 rounded-md shadow-sm cursor-pointer transition-colors dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:text-gray-200 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
value={pageSize}
onChange={e => {
setPageSize(Number(e.target.value));
}}
>
{[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
{pageSize} entries
</option>
))}
</select>
</label>
</div>
<div>
<nav className="inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<DataTable.PageButton
className="rounded-l-md"
onClick={() => gotoPage(0)}
disabled={!canPreviousPage}
>
<span className="sr-only">First</span>
<ChevronDoubleLeftIcon className="w-4 h-4" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
className="pl-1 pr-2"
onClick={() => previousPage()}
disabled={!canPreviousPage}
>
<ChevronLeftIcon className="w-4 h-4 mr-1" aria-hidden="true"/>
<span>Prev</span>
</DataTable.PageButton>
<DataTable.PageButton
className="pl-2 pr-1"
onClick={() => nextPage()}
disabled={!canNextPage}>
<span>Next</span>
<ChevronRightIcon className="w-4 h-4 ml-1" aria-hidden="true"/>
</DataTable.PageButton>
<DataTable.PageButton
className="rounded-r-md"
onClick={() => gotoPage(pageCount - 1)}
disabled={!canNextPage}
>
<ChevronDoubleRightIcon className="w-4 h-4" aria-hidden="true"/>
<span className="sr-only">Last</span>
</DataTable.PageButton>
</nav>
</div>
</div>
</div>
<div className="absolute -bottom-11 right-0 p-2">
<button
onClick={toggleReleaseNames}
className="p-2 absolute bottom-0 right-0 bg-gray-750 text-white rounded-full opacity-10 hover:opacity-100 transition-opacity duration-300"
aria-label="Toggle view"
title="Go incognito"
>
{showLinuxIsos ? (
<EyeIcon className="h-4 w-4"/>
) : (
<EyeSlashIcon className="h-4 w-4"/>
)}
</button>
</div>
</div>
<div className="absolute -bottom-11 right-0 p-2">
<button
onClick={toggleReleaseNames}
className="p-2 absolute bottom-0 right-0 bg-gray-750 text-white rounded-full opacity-10 hover:opacity-100 transition-opacity duration-300"
aria-label="Toggle view"
title="Go incognito"
>
{showLinuxIsos ? (
<EyeIcon className="h-4 w-4" />
) : (
<EyeSlashIcon className="h-4 w-4" />
)}
</button>
</div>
</div>
)}
</div>
</div>
);

View file

@ -4,15 +4,17 @@
*/
import { useMutation } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import Toast from "@components/notifications/Toast";
import { Section } from "./_components";
import { Form, Formik } from "formik";
import { PasswordField, TextField } from "@components/inputs";
import { AuthContext } from "@utils/Context";
import toast from "react-hot-toast";
import { UserIcon } from "@heroicons/react/24/solid";
import { SettingsAccountRoute } from "@app/routes";
import { AuthContext } from "@utils/Context";
import { APIClient } from "@api/APIClient";
import { Section } from "./_components";
import { PasswordField, TextField } from "@components/inputs";
import Toast from "@components/notifications/Toast";
const AccountSettings = () => (
<Section
title="Account"
@ -33,8 +35,7 @@ interface InputValues {
}
function Credentials() {
const [ getAuthContext ] = AuthContext.use();
const ctx = SettingsAccountRoute.useRouteContext()
const validate = (values: InputValues) => {
const errors: Record<string, string> = {};
@ -51,7 +52,8 @@ function Credentials() {
const logoutMutation = useMutation({
mutationFn: APIClient.auth.logout,
onSuccess: () => {
AuthContext.reset();
AuthContext.logout();
toast.custom((t) => (
<Toast type="success" body="User updated successfully. Please sign in again!" t={t} />
));
@ -76,7 +78,7 @@ function Credentials() {
<div className="px-2 pb-6 bg-white dark:bg-gray-800">
<Formik
initialValues={{
username: getAuthContext.username,
username: ctx.auth.username!,
newUsername: "",
oldPassword: "",
newPassword: "",

View file

@ -13,33 +13,19 @@ import { DeleteModal } from "@components/modals";
import { APIKeyAddForm } from "@forms/settings/APIKeyAddForm";
import Toast from "@components/notifications/Toast";
import { APIClient } from "@api/APIClient";
import { ApikeysQueryOptions } from "@api/queries";
import { ApiKeys } from "@api/query_keys";
import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import { EmptySimple } from "@components/emptystates";
import { Section } from "./_components";
import { PlusIcon } from "@heroicons/react/24/solid";
export const apiKeys = {
all: ["api_keys"] as const,
lists: () => [...apiKeys.all, "list"] as const,
details: () => [...apiKeys.all, "detail"] as const,
// detail: (id: number) => [...apiKeys.details(), id] as const
detail: (id: string) => [...apiKeys.details(), id] as const
};
function APISettings() {
const [addFormIsOpen, toggleAddForm] = useToggle(false);
const { isError, error, data } = useSuspenseQuery({
queryKey: apiKeys.lists(),
queryFn: APIClient.apikeys.getAll,
retry: false,
refetchOnWindowFocus: false
});
if (isError) {
console.log(error);
}
const apikeysQuery = useSuspenseQuery(ApikeysQueryOptions())
return (
<Section
@ -58,7 +44,7 @@ function APISettings() {
>
<APIKeyAddForm isOpen={addFormIsOpen} toggle={toggleAddForm} />
{data && data.length > 0 ? (
{apikeysQuery.data && apikeysQuery.data.length > 0 ? (
<ul className="min-w-full relative">
<li className="hidden sm:grid grid-cols-12 gap-4 mb-2 border-b border-gray-200 dark:border-gray-700">
<div className="col-span-3 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
@ -69,7 +55,7 @@ function APISettings() {
</div>
</li>
{data.map((k, idx) => <APIListItem key={idx} apikey={k} />)}
{apikeysQuery.data.map((k, idx) => <APIListItem key={idx} apikey={k} />)}
</ul>
) : (
<EmptySimple
@ -96,8 +82,8 @@ function APIListItem({ apikey }: ApiKeyItemProps) {
const deleteMutation = useMutation({
mutationFn: (key: string) => APIClient.apikeys.delete(key),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: apiKeys.lists() });
queryClient.invalidateQueries({ queryKey: apiKeys.detail(apikey.key) });
queryClient.invalidateQueries({ queryKey: ApiKeys.lists() });
queryClient.invalidateQueries({ queryKey: ApiKeys.detail(apikey.key) });
toast.custom((t) => (
<Toast

View file

@ -3,10 +3,13 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { SettingsIndexRoute } from "@app/routes";
import { APIClient } from "@api/APIClient";
import { ConfigQueryOptions, UpdatesQueryOptions } from "@api/queries";
import { SettingsKeys } from "@api/query_keys";
import { SettingsContext } from "@utils/Context";
import { Checkbox } from "@components/Checkbox";
import Toast from "@components/notifications/Toast";
@ -17,34 +20,23 @@ import { Section, RowItem } from "./_components";
function ApplicationSettings() {
const [settings, setSettings] = SettingsContext.use();
const { isError:isConfigError, error: configError, data } = useQuery({
queryKey: ["config"],
queryFn: APIClient.config.get,
retry: false,
refetchOnWindowFocus: false
});
const ctx = SettingsIndexRoute.useRouteContext()
const queryClient = ctx.queryClient
const { isError:isConfigError, error: configError, data } = useQuery(ConfigQueryOptions());
if (isConfigError) {
console.log(configError);
}
const { isError, error, data: updateData } = useQuery({
queryKey: ["updates"],
queryFn: APIClient.updates.getLatestRelease,
retry: false,
refetchOnWindowFocus: false,
enabled: data?.check_for_updates === true
});
const { isError, error, data: updateData } = useQuery(UpdatesQueryOptions(data?.check_for_updates === true));
if (isError) {
console.log(error);
}
const queryClient = useQueryClient();
const checkUpdateMutation = useMutation({
mutationFn: APIClient.updates.check,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["updates"] });
queryClient.invalidateQueries({ queryKey: SettingsKeys.updates() });
}
});
@ -52,7 +44,7 @@ function ApplicationSettings() {
mutationFn: (value: boolean) => APIClient.config.update({ check_for_updates: value }).then(() => value),
onSuccess: (_, value: boolean) => {
toast.custom(t => <Toast type="success" body={`${value ? "You will now be notified of new updates." : "You will no longer be notified of new updates."}`} t={t} />);
queryClient.invalidateQueries({ queryKey: ["config"] });
queryClient.invalidateQueries({ queryKey: SettingsKeys.config() });
checkUpdateMutation.mutate();
}
});

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useState, useMemo } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { PlusIcon } from "@heroicons/react/24/solid";
import toast from "react-hot-toast";
@ -12,20 +12,14 @@ import { useToggle } from "@hooks/hooks";
import { DownloadClientAddForm, DownloadClientUpdateForm } from "@forms";
import { EmptySimple } from "@components/emptystates";
import { APIClient } from "@api/APIClient";
import { DownloadClientKeys } from "@api/query_keys";
import { DownloadClientsQueryOptions } from "@api/queries";
import { ActionTypeNameMap } from "@domain/constants";
import Toast from "@components/notifications/Toast";
import { Checkbox } from "@components/Checkbox";
import { Section } from "./_components";
export const clientKeys = {
all: ["download_clients"] as const,
lists: () => [...clientKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...clientKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...clientKeys.all, "detail"] as const,
detail: (id: number) => [...clientKeys.details(), id] as const
};
interface DLSettingsItemProps {
client: DownloadClient;
}
@ -97,7 +91,7 @@ function ListItem({ client }: DLSettingsItemProps) {
mutationFn: (client: DownloadClient) => APIClient.download_clients.update(client).then(() => client),
onSuccess: (client: DownloadClient) => {
toast.custom(t => <Toast type="success" body={`${client.name} was ${client.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
}
});
@ -140,17 +134,9 @@ function ListItem({ client }: DLSettingsItemProps) {
function DownloadClientSettings() {
const [addClientIsOpen, toggleAddClient] = useToggle(false);
const { error, data } = useSuspenseQuery({
queryKey: clientKeys.lists(),
queryFn: APIClient.download_clients.getAll,
refetchOnWindowFocus: false
});
const downloadClientsQuery = useSuspenseQuery(DownloadClientsQueryOptions())
const sortedClients = useSort(data || []);
if (error) {
return <p>Failed to fetch download clients</p>;
}
const sortedClients = useSort(downloadClientsQuery.data || []);
return (
<Section

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Fragment, useRef, useState, useMemo } from "react";
import { Fragment, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { Menu, Transition } from "@headlessui/react";
import { toast } from "react-hot-toast";
@ -11,12 +11,14 @@ import {
ArrowsRightLeftIcon,
DocumentTextIcon,
EllipsisHorizontalIcon,
PencilSquareIcon,
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/notifications/Toast";
@ -29,14 +31,6 @@ import { ExternalLink } from "@components/ExternalLink";
import { Section } from "./_components";
import { Checkbox } from "@components/Checkbox";
export const feedKeys = {
all: ["feeds"] as const,
lists: () => [...feedKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...feedKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...feedKeys.all, "detail"] as const,
detail: (id: number) => [...feedKeys.details(), id] as const
};
interface SortConfig {
key: keyof ListItemProps["feed"] | "enabled";
direction: "ascending" | "descending";
@ -97,20 +91,16 @@ function useSort(items: ListItemProps["feed"][], config?: SortConfig) {
}
function FeedSettings() {
const { data } = useSuspenseQuery({
queryKey: feedKeys.lists(),
queryFn: APIClient.feeds.find,
refetchOnWindowFocus: false
});
const feedsQuery = useSuspenseQuery(FeedsQueryOptions())
const sortedFeeds = useSort(data || []);
const sortedFeeds = useSort(feedsQuery.data || []);
return (
<Section
title="Feeds"
description="Manage RSS, Newznab, and Torznab feeds."
>
{data && data.length > 0 ? (
{feedsQuery.data && feedsQuery.data.length > 0 ? (
<ul className="min-w-full relative">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wider">
<div
@ -163,8 +153,8 @@ function ListItem({ feed }: ListItemProps) {
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) });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.detail(feed.id) });
toast.custom((t) => <Toast type="success" body={`${feed.name} was ${!enabled ? "disabled" : "enabled"} successfully.`} t={t} />);
}
@ -240,8 +230,8 @@ const FeedItemDropdown = ({
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.feeds.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: feedKeys.detail(feed.id) });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.detail(feed.id) });
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} was deleted`} t={t} />);
}
@ -257,7 +247,7 @@ const FeedItemDropdown = ({
const forceRunMutation = useMutation({
mutationFn: (id: number) => APIClient.feeds.forceRun(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} was force run successfully.`} t={t} />);
toggleForceRunModal();
},

View file

@ -3,13 +3,15 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useState, useMemo } from "react";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { PlusIcon } from "@heroicons/react/24/solid";
import { useToggle } from "@hooks/hooks";
import { APIClient } from "@api/APIClient";
import { IndexerKeys } from "@api/query_keys";
import { IndexersQueryOptions } from "@api/queries";
import { Checkbox } from "@components/Checkbox";
import Toast from "@components/notifications/Toast";
import { EmptySimple } from "@components/emptystates";
@ -18,14 +20,6 @@ import { componentMapType } from "@forms/settings/DownloadClientForms";
import { Section } from "./_components";
export const indexerKeys = {
all: ["indexers"] as const,
lists: () => [...indexerKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...indexerKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...indexerKeys.all, "detail"] as const,
detail: (id: number) => [...indexerKeys.details(), id] as const
};
interface SortConfig {
key: keyof ListItemProps["indexer"] | "enabled";
direction: "ascending" | "descending";
@ -123,7 +117,7 @@ const ListItem = ({ indexer }: ListItemProps) => {
const updateMutation = useMutation({
mutationFn: (enabled: boolean) => APIClient.indexers.toggleEnable(indexer.id, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: indexerKeys.lists() });
queryClient.invalidateQueries({ queryKey: IndexerKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${indexer.name} was updated successfully`} t={t} />);
}
});
@ -169,17 +163,13 @@ const ListItem = ({ indexer }: ListItemProps) => {
function IndexerSettings() {
const [addIndexerIsOpen, toggleAddIndexer] = useToggle(false);
const { error, data } = useSuspenseQuery({
queryKey: indexerKeys.lists(),
queryFn: APIClient.indexers.getAll,
refetchOnWindowFocus: false
});
const indexersQuery = useSuspenseQuery(IndexersQueryOptions())
const indexers = indexersQuery.data
const sortedIndexers = useSort(indexers || []);
const sortedIndexers = useSort(data || []);
if (error) {
return (<p>An error has occurred</p>);
}
// if (error) {
// return (<p>An error has occurred</p>);
// }
return (
<Section

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Fragment, useRef, useState, useMemo, useEffect, MouseEvent } from "react";
import { Fragment, MouseEvent, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { LockClosedIcon, LockOpenIcon, PlusIcon } from "@heroicons/react/24/solid";
import { Menu, Transition } from "@headlessui/react";
@ -22,23 +22,16 @@ import { classNames, IsEmptyDate, simplifyDate } from "@utils";
import { IrcNetworkAddForm, IrcNetworkUpdateForm } from "@forms";
import { useToggle } from "@hooks/hooks";
import { APIClient } from "@api/APIClient";
import { IrcKeys } from "@api/query_keys";
import { IrcQueryOptions } from "@api/queries";
import { EmptySimple } from "@components/emptystates";
import { DeleteModal } from "@components/modals";
import Toast from "@components/notifications/Toast";
import { SettingsContext } from "@utils/Context";
import { Checkbox } from "@components/Checkbox";
// import { useForm } from "react-hook-form";
import { Section } from "./_components";
export const ircKeys = {
all: ["irc_networks"] as const,
lists: () => [...ircKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...ircKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...ircKeys.all, "detail"] as const,
detail: (id: number) => [...ircKeys.details(), id] as const
};
interface SortConfig {
key: keyof ListItemProps["network"] | "enabled";
direction: "ascending" | "descending";
@ -98,14 +91,9 @@ const IrcSettings = () => {
const [expandNetworks, toggleExpand] = useToggle(false);
const [addNetworkIsOpen, toggleAddNetwork] = useToggle(false);
const { data } = useSuspenseQuery({
queryKey: ircKeys.lists(),
queryFn: APIClient.irc.getNetworks,
refetchOnWindowFocus: false,
refetchInterval: 3000 // Refetch every 3 seconds
});
const ircQuery = useSuspenseQuery(IrcQueryOptions())
const sortedNetworks = useSort(data || []);
const sortedNetworks = useSort(ircQuery.data || []);
return (
<Section
@ -168,7 +156,7 @@ const IrcSettings = () => {
</div>
</div>
{data && data.length > 0 ? (
{ircQuery.data && ircQuery.data.length > 0 ? (
<ul className="mt-6 min-w-full relative text-sm">
<li className="grid grid-cols-12 gap-4 border-b border-gray-200 dark:border-gray-700 text-xs font-medium text-gray-500 dark:text-gray-400">
<div className="flex col-span-2 md:col-span-1 pl-2 sm:px-3 py-3 text-left uppercase tracking-wider cursor-pointer"
@ -218,7 +206,7 @@ const ListItem = ({ network, expanded }: ListItemProps) => {
const updateMutation = useMutation({
mutationFn: (network: IrcNetwork) => APIClient.irc.updateNetwork(network).then(() => network),
onSuccess: (network: IrcNetwork) => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
toast.custom(t => <Toast type="success" body={`${network.name} was ${network.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
}
});
@ -431,8 +419,8 @@ const ListItemDropdown = ({
const deleteMutation = useMutation({
mutationFn: (id: number) => APIClient.irc.deleteNetwork(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: ircKeys.detail(network.id) });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.detail(network.id) });
toast.custom((t) => <Toast type="success" body={`Network ${network.name} was deleted`} t={t} />);
@ -443,8 +431,8 @@ const ListItemDropdown = ({
const restartMutation = useMutation({
mutationFn: (id: number) => APIClient.irc.restartNetwork(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: ircKeys.detail(network.id) });
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
queryClient.invalidateQueries({ queryKey: IrcKeys.detail(network.id) });
toast.custom((t) => <Toast type="success" body={`${network.name} was successfully restarted`} t={t} />);
}

View file

@ -3,12 +3,15 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { toast } from "react-hot-toast";
import { Link } from "react-router-dom";
import Select from "react-select";
import { APIClient } from "@api/APIClient";
import { ConfigQueryOptions } from "@api/queries";
import { SettingsKeys } from "@api/query_keys";
import { SettingsLogRoute } from "@app/routes";
import Toast from "@components/notifications/Toast";
import { LogLevelOptions, SelectOption } from "@domain/constants";
@ -56,25 +59,19 @@ const SelectWrapper = ({ id, value, onChange, options }: SelectWrapperProps) =>
);
function LogSettings() {
const { isError, error, isLoading, data } = useSuspenseQuery({
queryKey: ["config"],
queryFn: APIClient.config.get,
retry: false,
refetchOnWindowFocus: false
});
const ctx = SettingsLogRoute.useRouteContext()
const queryClient = ctx.queryClient
if (isError) {
console.log(error);
}
const configQuery = useSuspenseQuery(ConfigQueryOptions())
const queryClient = useQueryClient();
const config = configQuery.data
const setLogLevelUpdateMutation = useMutation({
mutationFn: (value: string) => APIClient.config.update({ log_level: value }),
onSuccess: () => {
toast.custom((t) => <Toast type="success" body={"Config successfully updated!"} t={t} />);
queryClient.invalidateQueries({ queryKey: ["config"] });
queryClient.invalidateQueries({ queryKey: SettingsKeys.config() });
}
});
@ -86,7 +83,7 @@ function LogSettings() {
Configure log level, log size rotation, etc. You can download your old log files
{" "}
<Link
to="/logs"
to="/settings/logs"
className="text-gray-700 dark:text-gray-200 underline font-semibold underline-offset-2 decoration-blue-500 decoration hover:text-black hover:dark:text-gray-100"
>
on the Logs page
@ -96,9 +93,9 @@ function LogSettings() {
>
<div className="-mx-4 lg:col-span-9">
<div className="divide-y divide-gray-200 dark:divide-gray-750">
{!isLoading && data && (
{!configQuery.isLoading && config && (
<form className="divide-y divide-gray-200 dark:divide-gray-750" action="#" method="POST">
<RowItem label="Path" value={data?.log_path} title="Set in config.toml" emptyText="Not set!"/>
<RowItem label="Path" value={config?.log_path} title="Set in config.toml" emptyText="Not set!"/>
<RowItem
className="sm:col-span-1"
label="Level"
@ -106,14 +103,14 @@ function LogSettings() {
value={
<SelectWrapper
id="log_level"
value={data?.log_level}
value={config?.log_level}
options={LogLevelOptions}
onChange={(value: SelectOption) => setLogLevelUpdateMutation.mutate(value.value)}
/>
}
/>
<RowItem label="Max Size" value={data?.log_max_size} title="Set in config.toml" rightSide="MB"/>
<RowItem label="Max Backups" value={data?.log_max_backups} title="Set in config.toml"/>
<RowItem label="Max Size" value={config?.log_max_size} title="Set in config.toml" rightSide="MB"/>
<RowItem label="Max Backups" value={config?.log_max_backups} title="Set in config.toml"/>
</form>
)}

View file

@ -4,35 +4,33 @@
*/
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { PlusIcon } from "@heroicons/react/24/solid";
import toast from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { NotificationKeys } from "@api/query_keys";
import { NotificationsQueryOptions } from "@api/queries";
import { EmptySimple } from "@components/emptystates";
import { useToggle } from "@hooks/hooks";
import { NotificationAddForm, NotificationUpdateForm } from "@forms/settings/NotificationForms";
import { componentMapType } from "@forms/settings/DownloadClientForms";
import Toast from "@components/notifications/Toast";
import toast from "react-hot-toast";
import { Section } from "./_components";
import { PlusIcon } from "@heroicons/react/24/solid";
import {
DiscordIcon,
GotifyIcon,
LunaSeaIcon,
NotifiarrIcon,
NtfyIcon,
PushoverIcon,
Section,
TelegramIcon
} from "./_components";
import { Checkbox } from "@components/Checkbox";
import { DiscordIcon, GotifyIcon, LunaSeaIcon, NotifiarrIcon, NtfyIcon, PushoverIcon, TelegramIcon } from "./_components";
export const notificationKeys = {
all: ["notifications"] as const,
lists: () => [...notificationKeys.all, "list"] as const,
details: () => [...notificationKeys.all, "detail"] as const,
detail: (id: number) => [...notificationKeys.details(), id] as const
};
function NotificationSettings() {
const [addNotificationsIsOpen, toggleAddNotifications] = useToggle(false);
const { data } = useSuspenseQuery({
queryKey: notificationKeys.lists(),
queryFn: APIClient.notifications.getAll,
refetchOnWindowFocus: false
}
);
const notificationsQuery = useSuspenseQuery(NotificationsQueryOptions())
return (
<Section
@ -51,7 +49,7 @@ function NotificationSettings() {
>
<NotificationAddForm isOpen={addNotificationsIsOpen} toggle={toggleAddNotifications} />
{data && data.length > 0 ? (
{notificationsQuery.data && notificationsQuery.data.length > 0 ? (
<ul className="min-w-full">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
<div className="col-span-2 sm:col-span-1 pl-1 sm:pl-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Enabled</div>
@ -60,7 +58,7 @@ function NotificationSettings() {
<div className="hidden md:flex col-span-3 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Events</div>
</li>
{data.map((n) => <ListItem key={n.id} notification={n} />)}
{notificationsQuery.data.map((n) => <ListItem key={n.id} notification={n} />)}
</ul>
) : (
<EmptySimple title="No notifications" subtitle="" buttonText="Create new notification" buttonAction={toggleAddNotifications} />
@ -94,7 +92,7 @@ function ListItem({ notification }: ListItemProps) {
mutationFn: (notification: ServiceNotification) => APIClient.notifications.update(notification).then(() => notification),
onSuccess: (notification: ServiceNotification) => {
toast.custom(t => <Toast type="success" body={`${notification.name} was ${notification.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
}
});

View file

@ -8,8 +8,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { ReleaseKeys } from "@api/query_keys";
import Toast from "@components/notifications/Toast";
import { releaseKeys } from "@screens/releases/ReleaseTable";
import { useToggle } from "@hooks/hooks";
import { DeleteModal } from "@components/modals";
import { Section } from "./_components";
@ -74,7 +74,7 @@ function DeleteReleases() {
}
// Invalidate filters just in case, most likely not necessary but can't hurt.
queryClient.invalidateQueries({ queryKey: releaseKeys.lists() });
queryClient.invalidateQueries({ queryKey: ReleaseKeys.lists() });
}
});