mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
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:
parent
cc9656cd41
commit
1a23b69bcf
64 changed files with 2543 additions and 2091 deletions
|
@ -3,60 +3,34 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { QueryClient, QueryClientProvider, useQueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { toast, Toaster } from "react-hot-toast";
|
||||
|
||||
import { LocalRouter } from "./domain/routes";
|
||||
import { AuthContext, SettingsContext } from "./utils/Context";
|
||||
import { ErrorPage } from "./components/alerts";
|
||||
import Toast from "./components/notifications/Toast";
|
||||
import { RouterProvider } from "@tanstack/react-router"
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { Portal } from "react-portal";
|
||||
import { Router } from "@app/routes";
|
||||
import { routerBasePath } from "@utils";
|
||||
import { queryClient } from "@api/QueryClient";
|
||||
import { AuthContext } from "@utils/Context";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// The retries will have exponential delay.
|
||||
// See https://tanstack.com/query/v4/docs/guides/query-retries#retry-delay
|
||||
// delay = Math.min(1000 * 2 ** attemptIndex, 30000)
|
||||
retry: true,
|
||||
throwOnError: true,
|
||||
},
|
||||
mutations: {
|
||||
onError: (error) => {
|
||||
// Use a format string to convert the error object to a proper string without much hassle.
|
||||
const message = (
|
||||
typeof (error) === "object" && typeof ((error as Error).message) ?
|
||||
(error as Error).message :
|
||||
`${error}`
|
||||
);
|
||||
toast.custom((t) => <Toast type="error" body={message} t={t} />);
|
||||
}
|
||||
}
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof Router
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { reset } = useQueryErrorResetBoundary();
|
||||
|
||||
const authContext = AuthContext.useValue();
|
||||
const settings = SettingsContext.useValue();
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
FallbackComponent={ErrorPage}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Portal>
|
||||
<Toaster position="top-right" />
|
||||
</Portal>
|
||||
<LocalRouter isLoggedIn={authContext.isLoggedIn} />
|
||||
{settings.debug ? (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
) : null}
|
||||
<RouterProvider
|
||||
basepath={routerBasePath()}
|
||||
router={Router}
|
||||
context={{
|
||||
auth: AuthContext,
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -4,7 +4,6 @@
|
|||
*/
|
||||
|
||||
import { baseUrl, sseBaseUrl } from "@utils";
|
||||
import { AuthContext } from "@utils/Context";
|
||||
import { GithubRelease } from "@app/types/Update";
|
||||
|
||||
type RequestBody = BodyInit | object | Record<string, unknown> | null;
|
||||
|
@ -30,7 +29,8 @@ export async function HttpClient<T = unknown>(
|
|||
): Promise<T> {
|
||||
const init: RequestInit = {
|
||||
method: config.method,
|
||||
headers: { "Accept": "*/*" }
|
||||
headers: { "Accept": "*/*", 'x-requested-with': 'XMLHttpRequest' },
|
||||
credentials: "include",
|
||||
};
|
||||
|
||||
if (config.body) {
|
||||
|
@ -87,22 +87,17 @@ export async function HttpClient<T = unknown>(
|
|||
return Promise.resolve<T>({} as T);
|
||||
}
|
||||
case 401: {
|
||||
// Remove auth info from localStorage
|
||||
AuthContext.reset();
|
||||
|
||||
// Show an error toast to notify the user what occurred
|
||||
// return Promise.reject(new Error(`[401] Unauthorized: "${endpoint}"`));
|
||||
return Promise.reject(response);
|
||||
// return Promise.reject(new Error(`[401] Unauthorized: "${endpoint}"`));
|
||||
}
|
||||
case 403: {
|
||||
// Remove auth info from localStorage
|
||||
AuthContext.reset();
|
||||
|
||||
// Show an error toast to notify the user what occurred
|
||||
return Promise.reject(response);
|
||||
}
|
||||
case 404: {
|
||||
return Promise.reject(new Error(`[404] Not found: "${endpoint}"`));
|
||||
const isJson = response.headers.get("Content-Type")?.includes("application/json");
|
||||
const json = isJson ? await response.json() : null;
|
||||
return Promise.reject<T>(json as T);
|
||||
// return Promise.reject(new Error(`[404] Not Found: "${endpoint}"`));
|
||||
}
|
||||
case 500: {
|
||||
const health = await window.fetch(`${baseUrl()}api/healthz/liveness`);
|
||||
|
@ -326,6 +321,8 @@ export const APIClient = {
|
|||
if (filter.id == "indexer") {
|
||||
params["indexer"].push(filter.value);
|
||||
} else if (filter.id === "action_status") {
|
||||
params["push_status"].push(filter.value); // push_status is the correct value here otherwise the releases table won't load when filtered by push status
|
||||
} else if (filter.id === "push_status") {
|
||||
params["push_status"].push(filter.value);
|
||||
} else if (filter.id == "name") {
|
||||
params["q"].push(filter.value);
|
||||
|
|
64
web/src/api/QueryClient.tsx
Normal file
64
web/src/api/QueryClient.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-hot-toast";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
|
||||
const MAX_RETRIES = 6;
|
||||
const HTTP_STATUS_TO_NOT_RETRY = [400, 401, 403, 404];
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error ) => {
|
||||
console.error("query client error: ", error);
|
||||
|
||||
toast.custom((t) => <Toast type="error" body={error?.message} t={t}/>);
|
||||
|
||||
// @ts-expect-error TS2339: Property status does not exist on type Error
|
||||
if (error?.status === 401 || error?.status === 403) {
|
||||
// @ts-expect-error TS2339: Property status does not exist on type Error
|
||||
console.error("bad status, redirect to login", error?.status)
|
||||
// Redirect to login page
|
||||
window.location.href = "/login";
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// The retries will have exponential delay.
|
||||
// See https://tanstack.com/query/v4/docs/guides/query-retries#retry-delay
|
||||
// delay = Math.min(1000 * 2 ** attemptIndex, 30000)
|
||||
// retry: false,
|
||||
throwOnError: true,
|
||||
retry: (failureCount, error) => {
|
||||
console.debug("retry count:", failureCount)
|
||||
console.error("retry err: ", error)
|
||||
|
||||
// @ts-expect-error TS2339: ignore
|
||||
if (HTTP_STATUS_TO_NOT_RETRY.includes(error.status)) {
|
||||
// @ts-expect-error TS2339: ignore
|
||||
console.log(`retry: Aborting retry due to ${error.status} status`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount <= MAX_RETRIES;
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
onError: (error) => {
|
||||
// Use a format string to convert the error object to a proper string without much hassle.
|
||||
const message = (
|
||||
typeof (error) === "object" && typeof ((error as Error).message) ?
|
||||
(error as Error).message :
|
||||
`${error}`
|
||||
);
|
||||
toast.custom((t) => <Toast type="error" body={message} t={t}/>);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
135
web/src/api/queries.ts
Normal file
135
web/src/api/queries.ts
Normal file
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import {
|
||||
ApiKeys,
|
||||
DownloadClientKeys,
|
||||
FeedKeys,
|
||||
FilterKeys,
|
||||
IndexerKeys,
|
||||
IrcKeys, NotificationKeys,
|
||||
ReleaseKeys,
|
||||
SettingsKeys
|
||||
} from "@api/query_keys";
|
||||
|
||||
export const FiltersQueryOptions = (indexers: string[], sortOrder: string) =>
|
||||
queryOptions({
|
||||
queryKey: FilterKeys.list(indexers, sortOrder),
|
||||
queryFn: () => APIClient.filters.find(indexers, sortOrder),
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
|
||||
export const FilterByIdQueryOptions = (filterId: number) =>
|
||||
queryOptions({
|
||||
queryKey: FilterKeys.detail(filterId),
|
||||
queryFn: async ({queryKey}) => await APIClient.filters.getByID(queryKey[2]),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
export const ConfigQueryOptions = (enabled: boolean = true) =>
|
||||
queryOptions({
|
||||
queryKey: SettingsKeys.config(),
|
||||
queryFn: () => APIClient.config.get(),
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: enabled,
|
||||
});
|
||||
|
||||
export const UpdatesQueryOptions = (enabled: boolean) =>
|
||||
queryOptions({
|
||||
queryKey: SettingsKeys.updates(),
|
||||
queryFn: () => APIClient.updates.getLatestRelease(),
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: enabled,
|
||||
});
|
||||
|
||||
export const IndexersQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: IndexerKeys.lists(),
|
||||
queryFn: () => APIClient.indexers.getAll()
|
||||
});
|
||||
|
||||
export const IndexersOptionsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: IndexerKeys.options(),
|
||||
queryFn: () => APIClient.indexers.getOptions(),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity
|
||||
});
|
||||
|
||||
export const IndexersSchemaQueryOptions = (enabled: boolean) =>
|
||||
queryOptions({
|
||||
queryKey: IndexerKeys.schema(),
|
||||
queryFn: () => APIClient.indexers.getSchema(),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
enabled: enabled
|
||||
});
|
||||
|
||||
export const IrcQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: IrcKeys.lists(),
|
||||
queryFn: () => APIClient.irc.getNetworks(),
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: 3000 // Refetch every 3 seconds
|
||||
});
|
||||
|
||||
export const FeedsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: FeedKeys.lists(),
|
||||
queryFn: () => APIClient.feeds.find(),
|
||||
});
|
||||
|
||||
export const DownloadClientsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: DownloadClientKeys.lists(),
|
||||
queryFn: () => APIClient.download_clients.getAll(),
|
||||
});
|
||||
|
||||
export const NotificationsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: NotificationKeys.lists(),
|
||||
queryFn: () => APIClient.notifications.getAll()
|
||||
});
|
||||
|
||||
export const ApikeysQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ApiKeys.lists(),
|
||||
queryFn: () => APIClient.apikeys.getAll(),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
export const ReleasesListQueryOptions = (offset: number, limit: number, filters: ReleaseFilter[]) =>
|
||||
queryOptions({
|
||||
queryKey: ReleaseKeys.list(offset, limit, filters),
|
||||
queryFn: () => APIClient.release.findQuery(offset, limit, filters),
|
||||
staleTime: 5000
|
||||
});
|
||||
|
||||
export const ReleasesLatestQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ReleaseKeys.latestActivity(),
|
||||
queryFn: () => APIClient.release.findRecent(),
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
|
||||
export const ReleasesStatsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ReleaseKeys.stats(),
|
||||
queryFn: () => APIClient.release.stats(),
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
|
||||
// ReleasesIndexersQueryOptions get basic list of used indexers by identifier
|
||||
export const ReleasesIndexersQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ReleaseKeys.indexers(),
|
||||
queryFn: () => APIClient.release.indexerOptions(),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: Infinity
|
||||
});
|
77
web/src/api/query_keys.ts
Normal file
77
web/src/api/query_keys.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
export const SettingsKeys = {
|
||||
all: ["settings"] as const,
|
||||
updates: () => [...SettingsKeys.all, "updates"] as const,
|
||||
config: () => [...SettingsKeys.all, "config"] as const,
|
||||
lists: () => [...SettingsKeys.all, "list"] as const,
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
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,
|
||||
indexers: () => [...ReleaseKeys.all, "indexers"] as const,
|
||||
stats: () => [...ReleaseKeys.all, "stats"] as const,
|
||||
latestActivity: () => [...ReleaseKeys.all, "latest-activity"] as const,
|
||||
};
|
||||
|
||||
export const ApiKeys = {
|
||||
all: ["api_keys"] as const,
|
||||
lists: () => [...ApiKeys.all, "list"] as const,
|
||||
details: () => [...ApiKeys.all, "detail"] as const,
|
||||
detail: (id: string) => [...ApiKeys.details(), id] as const
|
||||
};
|
||||
|
||||
export const DownloadClientKeys = {
|
||||
all: ["download_clients"] as const,
|
||||
lists: () => [...DownloadClientKeys.all, "list"] as const,
|
||||
// list: (indexers: string[], sortOrder: string) => [...clientKeys.lists(), { indexers, sortOrder }] as const,
|
||||
details: () => [...DownloadClientKeys.all, "detail"] as const,
|
||||
detail: (id: number) => [...DownloadClientKeys.details(), id] as const
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
export const IndexerKeys = {
|
||||
all: ["indexers"] as const,
|
||||
schema: () => [...IndexerKeys.all, "indexer-definitions"] as const,
|
||||
options: () => [...IndexerKeys.all, "options"] 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
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
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
|
||||
};
|
|
@ -1,32 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { RingResizeSpinner } from "@components/Icons";
|
||||
import { classNames } from "@utils";
|
||||
|
||||
const SIZE = {
|
||||
small: "w-6 h-6",
|
||||
medium: "w-8 h-8",
|
||||
large: "w-12 h-12",
|
||||
xlarge: "w-24 h-24"
|
||||
} as const;
|
||||
|
||||
interface SectionLoaderProps {
|
||||
$size: keyof typeof SIZE;
|
||||
}
|
||||
|
||||
export const SectionLoader = ({ $size }: SectionLoaderProps) => {
|
||||
if ($size === "xlarge") {
|
||||
return (
|
||||
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
|
||||
<RingResizeSpinner className={classNames(SIZE[$size], "mx-auto my-36 text-blue-500")} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<RingResizeSpinner className={classNames(SIZE[$size], "text-blue-500")} />
|
||||
);
|
||||
}
|
||||
};
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ExternalLink } from "@components/ExternalLink";
|
||||
|
||||
import Logo from "@app/logo.svg?react";
|
||||
|
@ -12,8 +12,11 @@ export const NotFound = () => {
|
|||
return (
|
||||
<div className="min-h-screen flex flex-col justify-center ">
|
||||
<div className="flex justify-center">
|
||||
<Logo className="h-24 sm:h-48" />
|
||||
<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">
|
||||
404 Page not found
|
||||
</h2>
|
||||
<h1 className="text-3xl text-center font-bold text-gray-900 dark:text-gray-200 my-8 px-2">
|
||||
Oops, looks like there was a little too much brr!
|
||||
</h1>
|
||||
|
|
|
@ -9,7 +9,6 @@ import { formatDistanceToNowStrict } from "date-fns";
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { CellProps } from "react-table";
|
||||
import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/solid";
|
||||
import { ExternalLink } from "../ExternalLink";
|
||||
import {
|
||||
ClockIcon,
|
||||
XMarkIcon,
|
||||
|
@ -19,8 +18,9 @@ import {
|
|||
} from "@heroicons/react/24/outline";
|
||||
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import {classNames, humanFileSize, simplifyDate} from "@utils";
|
||||
import { filterKeys } from "@screens/filters/List";
|
||||
import { FilterKeys } from "@api/query_keys";
|
||||
import { classNames, humanFileSize, simplifyDate } from "@utils";
|
||||
import { ExternalLink } from "../ExternalLink";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { RingResizeSpinner } from "@components/Icons";
|
||||
import { Tooltip } from "@components/tooltips/Tooltip";
|
||||
|
@ -164,7 +164,7 @@ const RetryActionButton = ({ status }: RetryActionButtonProps) => {
|
|||
mutationFn: (vars: RetryAction) => APIClient.release.replayAction(vars.releaseId, vars.actionId),
|
||||
onSuccess: () => {
|
||||
// Invalidate filters just in case, most likely not necessary but can't hurt.
|
||||
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
|
||||
|
||||
toast.custom((t) => (
|
||||
<Toast type="success" body={`${status?.action} replayed`} t={t} />
|
||||
|
|
|
@ -23,3 +23,11 @@ export const DEBUG: FC<DebugProps> = ({ values }) => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function LogDebug(...data: any[]): void {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(...data)
|
||||
}
|
||||
|
|
|
@ -5,11 +5,11 @@
|
|||
|
||||
import toast from "react-hot-toast";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
import { Bars3Icon, XMarkIcon, MegaphoneIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { AuthContext } from "@utils/Context";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
|
||||
import { LeftNav } from "./LeftNav";
|
||||
|
@ -17,37 +17,35 @@ import { RightNav } from "./RightNav";
|
|||
import { MobileNav } from "./MobileNav";
|
||||
import { ExternalLink } from "@components/ExternalLink";
|
||||
|
||||
export const Header = () => {
|
||||
const { isError:isConfigError, error: configError, data: config } = useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: () => APIClient.config.get(),
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
import { AuthIndexRoute } from "@app/routes";
|
||||
import { ConfigQueryOptions, UpdatesQueryOptions } from "@api/queries";
|
||||
|
||||
export const Header = () => {
|
||||
const router = useRouter()
|
||||
const { auth } = AuthIndexRoute.useRouteContext()
|
||||
|
||||
const { isError:isConfigError, error: configError, data: config } = useQuery(ConfigQueryOptions(true));
|
||||
if (isConfigError) {
|
||||
console.log(configError);
|
||||
}
|
||||
|
||||
const { isError, error, data } = useQuery({
|
||||
queryKey: ["updates"],
|
||||
queryFn: () => APIClient.updates.getLatestRelease(),
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: config?.check_for_updates === true
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
console.log(error);
|
||||
const { isError: isUpdateError, error, data } = useQuery(UpdatesQueryOptions(config?.check_for_updates === true));
|
||||
if (isUpdateError) {
|
||||
console.log("update error", error);
|
||||
}
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: APIClient.auth.logout,
|
||||
onSuccess: () => {
|
||||
AuthContext.reset();
|
||||
toast.custom((t) => (
|
||||
<Toast type="success" body="You have been logged out. Goodbye!" t={t} />
|
||||
));
|
||||
auth.logout()
|
||||
|
||||
router.history.push("/")
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("logout error", err)
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -62,7 +60,7 @@ export const Header = () => {
|
|||
<div className="border-b border-gray-300 dark:border-gray-775">
|
||||
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
|
||||
<LeftNav />
|
||||
<RightNav logoutMutation={logoutMutation.mutate} />
|
||||
<RightNav logoutMutation={logoutMutation.mutate} auth={auth} />
|
||||
<div className="-mr-2 flex sm:hidden">
|
||||
{/* Mobile menu button */}
|
||||
<Disclosure.Button className="bg-gray-200 dark:bg-gray-800 inline-flex items-center justify-center p-2 rounded-md text-gray-600 dark:text-gray-400 hover:text-white hover:bg-gray-700">
|
||||
|
@ -94,7 +92,7 @@ export const Header = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<MobileNav logoutMutation={logoutMutation.mutate} />
|
||||
<MobileNav logoutMutation={logoutMutation.mutate} auth={auth} />
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
|
|
|
@ -3,7 +3,10 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
// import { Link, NavLink } from "react-router-dom";
|
||||
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
import { classNames } from "@utils";
|
||||
|
@ -23,22 +26,27 @@ export const LeftNav = () => (
|
|||
<div className="sm:ml-3 hidden sm:block">
|
||||
<div className="flex items-baseline space-x-4">
|
||||
{NAV_ROUTES.map((item, itemIdx) => (
|
||||
<NavLink
|
||||
<Link
|
||||
key={item.name + itemIdx}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
classNames(
|
||||
"hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium",
|
||||
"transition-colors duration-200",
|
||||
isActive
|
||||
? "text-black dark:text-gray-50 font-bold"
|
||||
: "text-gray-600 dark:text-gray-500"
|
||||
)
|
||||
}
|
||||
end={item.path === "/"}
|
||||
params={{}}
|
||||
>
|
||||
{item.name}
|
||||
</NavLink>
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<>
|
||||
<span className={
|
||||
classNames(
|
||||
"hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium",
|
||||
"transition-colors duration-200",
|
||||
isActive
|
||||
? "text-black dark:text-gray-50 font-bold"
|
||||
: "text-gray-600 dark:text-gray-500"
|
||||
)
|
||||
}>{item.name}</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Link>
|
||||
))}
|
||||
<ExternalLink
|
||||
href="https://autobrr.com"
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {Link} from "@tanstack/react-router";
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
|
||||
import { classNames } from "@utils";
|
||||
|
@ -15,21 +15,28 @@ export const MobileNav = (props: RightNavProps) => (
|
|||
<Disclosure.Panel className="border-b border-gray-300 dark:border-gray-700 md:hidden">
|
||||
<div className="px-2 py-3 space-y-1 sm:px-3">
|
||||
{NAV_ROUTES.map((item) => (
|
||||
<NavLink
|
||||
<Link
|
||||
key={item.path}
|
||||
activeOptions={{ exact: item.exact }}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
classNames(
|
||||
"shadow-sm border bg-gray-100 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base",
|
||||
isActive
|
||||
search={{}}
|
||||
params={{}}
|
||||
>
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<span className={
|
||||
classNames(
|
||||
"shadow-sm border bg-gray-100 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-white block px-3 py-2 rounded-md text-base",
|
||||
isActive
|
||||
? "underline underline-offset-2 decoration-2 decoration-sky-500 font-bold text-black"
|
||||
: "font-medium"
|
||||
)
|
||||
}
|
||||
end={item.path === "/"}
|
||||
>
|
||||
{item.name}
|
||||
</NavLink>
|
||||
)
|
||||
}>
|
||||
{item.name}
|
||||
</span>
|
||||
)
|
||||
}}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
|
@ -4,18 +4,16 @@
|
|||
*/
|
||||
|
||||
import { Fragment } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { UserIcon } from "@heroicons/react/24/solid";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
|
||||
import { classNames } from "@utils";
|
||||
import { AuthContext } from "@utils/Context";
|
||||
|
||||
import { RightNavProps } from "./_shared";
|
||||
import { Cog6ToothIcon, ArrowLeftOnRectangleIcon } from "@heroicons/react/24/outline";
|
||||
import {Link} from "@tanstack/react-router";
|
||||
|
||||
export const RightNav = (props: RightNavProps) => {
|
||||
const authContext = AuthContext.useValue();
|
||||
return (
|
||||
<div className="hidden sm:block">
|
||||
<div className="ml-4 flex items-center sm:ml-6">
|
||||
|
@ -34,7 +32,7 @@ export const RightNav = (props: RightNavProps) => {
|
|||
<span className="sr-only">
|
||||
Open user menu for{" "}
|
||||
</span>
|
||||
{authContext.username}
|
||||
{props.auth.username}
|
||||
</span>
|
||||
<UserIcon
|
||||
className="inline ml-1 h-5 w-5"
|
||||
|
|
|
@ -3,17 +3,21 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { AuthCtx } from "@utils/Context";
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
path: string;
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
export interface RightNavProps {
|
||||
logoutMutation: () => void;
|
||||
auth: AuthCtx
|
||||
}
|
||||
|
||||
export const NAV_ROUTES: Array<NavItem> = [
|
||||
{ name: "Dashboard", path: "/" },
|
||||
{ name: "Dashboard", path: "/", exact: true },
|
||||
{ name: "Filters", path: "/filters" },
|
||||
{ name: "Releases", path: "/releases" },
|
||||
{ name: "Settings", path: "/settings" },
|
||||
|
|
|
@ -8,7 +8,7 @@ import { FC, Fragment, MutableRefObject, useState } from "react";
|
|||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
import { SectionLoader } from "@components/SectionLoader";
|
||||
import { RingResizeSpinner } from "@components/Icons";
|
||||
|
||||
interface ModalUpperProps {
|
||||
title: string;
|
||||
|
@ -58,7 +58,7 @@ const ModalUpper = ({ title, text }: ModalUpperProps) => (
|
|||
const ModalLower = ({ isOpen, isLoading, toggle, deleteAction }: ModalLowerProps) => (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
{isLoading ? (
|
||||
<SectionLoader $size="small" />
|
||||
<RingResizeSpinner className="text-blue-500 size-6" />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
|
@ -221,7 +221,7 @@ export const ForceRunModal: FC<ForceRunModalProps> = (props: ForceRunModalProps)
|
|||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
{props.isLoading ? (
|
||||
<SectionLoader $size="small" />
|
||||
<RingResizeSpinner className="text-blue-500 size-6" />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { baseUrl } from "@utils";
|
||||
|
||||
import { Header } from "@components/header";
|
||||
import { SectionLoader } from "@components/SectionLoader";
|
||||
import { NotFound } from "@components/alerts/NotFound";
|
||||
|
||||
import { Logs } from "@screens/Logs";
|
||||
import { Releases } from "@screens/Releases";
|
||||
import { Settings } from "@screens/Settings";
|
||||
import { Dashboard } from "@screens/Dashboard";
|
||||
import { Login, Onboarding } from "@screens/auth";
|
||||
import { Filters, FilterDetails } from "@screens/filters";
|
||||
import * as SettingsSubPage from "@screens/settings/index";
|
||||
|
||||
const BaseLayout = () => (
|
||||
<div className="min-h-screen">
|
||||
<Header />
|
||||
<Suspense fallback={<SectionLoader $size="xlarge" />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
|
||||
<BrowserRouter basename={baseUrl()}>
|
||||
{isLoggedIn ? (
|
||||
<Routes>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
<Route element={<BaseLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="logs" element={<Logs />} />
|
||||
<Route path="releases" element={<Releases />} />
|
||||
<Route path="filters">
|
||||
<Route index element={<Filters />} />
|
||||
<Route path=":filterId/*" element={<FilterDetails />} />
|
||||
</Route>
|
||||
<Route path="settings" element={<Settings />}>
|
||||
<Route index element={<SettingsSubPage.Application />} />
|
||||
<Route path="logs" element={<SettingsSubPage.Logs />} />
|
||||
<Route path="api-keys" element={<SettingsSubPage.Api />} />
|
||||
<Route path="indexers" element={<SettingsSubPage.Indexer />} />
|
||||
<Route path="feeds" element={<SettingsSubPage.Feed />} />
|
||||
<Route path="irc" element={<SettingsSubPage.Irc />} />
|
||||
<Route path="clients" element={<SettingsSubPage.DownloadClient />} />
|
||||
<Route path="notifications" element={<SettingsSubPage.Notification />} />
|
||||
<Route path="releases" element={<SettingsSubPage.Release />} />
|
||||
<Route path="regex-playground" element={<SettingsSubPage.RegexPlayground />} />
|
||||
<Route path="account" element={<SettingsSubPage.Account />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
) : (
|
||||
<Routes>
|
||||
<Route path="/onboard" element={<Onboarding />} />
|
||||
<Route path="*" element={<Login />} />
|
||||
</Routes>
|
||||
)}
|
||||
</BrowserRouter>
|
||||
);
|
|
@ -5,17 +5,18 @@
|
|||
|
||||
import { Fragment } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { XMarkIcon } from "@heroicons/react/24/solid";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import type { FieldProps } from "formik";
|
||||
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { FilterKeys } from "@api/query_keys";
|
||||
import { DEBUG } from "@components/debug";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { filterKeys } from "@screens/filters/List";
|
||||
|
||||
|
||||
interface filterAddFormProps {
|
||||
isOpen: boolean;
|
||||
|
@ -28,13 +29,12 @@ export function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
|
|||
const mutation = useMutation({
|
||||
mutationFn: (filter: Filter) => APIClient.filters.create(filter),
|
||||
onSuccess: (filter) => {
|
||||
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: FilterKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`Filter ${filter.name} was added`} t={t} />);
|
||||
|
||||
toggle();
|
||||
if (filter.id) {
|
||||
navigate(filter.id.toString());
|
||||
navigate({ to: "/filters/$filterId", params: { filterId: filter.id }})
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -12,9 +12,9 @@ import type { FieldProps } from "formik";
|
|||
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
|
||||
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { ApiKeys } from "@api/query_keys";
|
||||
import { DEBUG } from "@components/debug";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { apiKeys } from "@screens/settings/Api";
|
||||
|
||||
interface apiKeyAddFormProps {
|
||||
isOpen: boolean;
|
||||
|
@ -27,7 +27,7 @@ export function APIKeyAddForm({ isOpen, toggle }: apiKeyAddFormProps) {
|
|||
const mutation = useMutation({
|
||||
mutationFn: (apikey: APIKey) => APIClient.apikeys.create(apikey),
|
||||
onSuccess: (_, key) => {
|
||||
queryClient.invalidateQueries({ queryKey: apiKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: ApiKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`API key ${key.name} was added`} t={t}/>);
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import { toast } from "react-hot-toast";
|
|||
import { classNames, sleep } from "@utils";
|
||||
import { DEBUG } from "@components/debug";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { DownloadClientKeys } from "@api/query_keys";
|
||||
import { DownloadClientTypeOptions, DownloadRuleConditionOptions } from "@domain/constants";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { useToggle } from "@hooks/hooks";
|
||||
|
@ -24,7 +25,6 @@ import {
|
|||
SwitchGroupWide,
|
||||
TextFieldWide
|
||||
} from "@components/inputs";
|
||||
import { clientKeys } from "@screens/settings/DownloadClient";
|
||||
import { DocsLink, ExternalLink } from "@components/ExternalLink";
|
||||
import { SelectFieldBasic } from "@components/inputs/select_wide";
|
||||
|
||||
|
@ -693,7 +693,7 @@ export function DownloadClientAddForm({ isOpen, toggle }: formProps) {
|
|||
const addMutation = useMutation({
|
||||
mutationFn: (client: DownloadClient) => APIClient.download_clients.create(client),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
|
||||
toast.custom((t) => <Toast type="success" body="Client was added" t={t} />);
|
||||
|
||||
toggle();
|
||||
|
@ -865,8 +865,8 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
|
|||
const mutation = useMutation({
|
||||
mutationFn: (client: DownloadClient) => APIClient.download_clients.update(client),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: clientKeys.detail(client.id) });
|
||||
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.detail(client.id) });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`${client.name} was updated successfully`} t={t} />);
|
||||
toggle();
|
||||
|
@ -878,8 +878,8 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
|
|||
const deleteMutation = useMutation({
|
||||
mutationFn: (clientID: number) => APIClient.download_clients.delete(clientID),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: clientKeys.detail(client.id) });
|
||||
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: DownloadClientKeys.detail(client.id) });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`${client.name} was deleted.`} t={t} />);
|
||||
toggleDeleteModal();
|
||||
|
|
|
@ -9,6 +9,7 @@ import { toast } from "react-hot-toast";
|
|||
import { useFormikContext } from "formik";
|
||||
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { FeedKeys } from "@api/query_keys";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { SlideOver } from "@components/panels";
|
||||
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
||||
|
@ -17,7 +18,7 @@ import { componentMapType } from "./DownloadClientForms";
|
|||
import { sleep } from "@utils";
|
||||
import { ImplementationBadges } from "@screens/settings/Indexer";
|
||||
import { FeedDownloadTypeOptions } from "@domain/constants";
|
||||
import { feedKeys } from "@screens/settings/Feed";
|
||||
|
||||
|
||||
interface UpdateProps {
|
||||
isOpen: boolean;
|
||||
|
@ -50,7 +51,7 @@ export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
|
|||
const mutation = useMutation({
|
||||
mutationFn: (feed: Feed) => APIClient.feeds.update(feed),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`${feed.name} was updated successfully`} t={t} />);
|
||||
toggle();
|
||||
|
@ -62,7 +63,7 @@ export function FeedUpdateForm({ isOpen, toggle, feed }: UpdateProps) {
|
|||
const deleteMutation = useMutation({
|
||||
mutationFn: (feedID: number) => APIClient.feeds.delete(feedID),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`${feed.name} was deleted.`} t={t} />);
|
||||
}
|
||||
|
|
|
@ -15,13 +15,13 @@ import { Dialog, Transition } from "@headlessui/react";
|
|||
import { classNames, sleep } from "@utils";
|
||||
import { DEBUG } from "@components/debug";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { FeedKeys, IndexerKeys, ReleaseKeys } from "@api/query_keys";
|
||||
import { IndexersSchemaQueryOptions } from "@api/queries";
|
||||
import { SlideOver } from "@components/panels";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
||||
import { SelectFieldBasic, SelectFieldCreatable } from "@components/inputs/select_wide";
|
||||
import { FeedDownloadTypeOptions } from "@domain/constants";
|
||||
import { feedKeys } from "@screens/settings/Feed";
|
||||
import { indexerKeys } from "@screens/settings/Indexer";
|
||||
import { DocsLink } from "@components/ExternalLink";
|
||||
import * as common from "@components/inputs/common";
|
||||
|
||||
|
@ -263,17 +263,14 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
|
|||
const [indexer, setIndexer] = useState<IndexerDefinition>({} as IndexerDefinition);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = useQuery({
|
||||
queryKey: ["indexerDefinition"],
|
||||
queryFn: APIClient.indexers.getSchema,
|
||||
enabled: isOpen,
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
const { data } = useQuery(IndexersSchemaQueryOptions(isOpen));
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (indexer: Indexer) => APIClient.indexers.create(indexer),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: indexerKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: IndexerKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: IndexerKeys.options() });
|
||||
queryClient.invalidateQueries({ queryKey: ReleaseKeys.indexers() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body="Indexer was added" t={t} />);
|
||||
sleep(1500);
|
||||
|
@ -291,7 +288,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
|
|||
const feedMutation = useMutation({
|
||||
mutationFn: (feed: FeedCreate) => APIClient.feeds.create(feed),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: feedKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: FeedKeys.lists() });
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -738,7 +735,7 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
|
|||
const mutation = useMutation({
|
||||
mutationFn: (indexer: Indexer) => APIClient.indexers.update(indexer),
|
||||
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} />);
|
||||
sleep(1500);
|
||||
|
@ -755,7 +752,9 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
|
|||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => APIClient.indexers.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: indexerKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: IndexerKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: IndexerKeys.options() });
|
||||
queryClient.invalidateQueries({ queryKey: ReleaseKeys.indexers() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`${indexer.name} was deleted.`} t={t} />);
|
||||
|
||||
|
|
|
@ -14,8 +14,8 @@ import Select from "react-select";
|
|||
import { Dialog } from "@headlessui/react";
|
||||
|
||||
import { IrcAuthMechanismTypeOptions, OptionBasicTyped } from "@domain/constants";
|
||||
import { ircKeys } from "@screens/settings/Irc";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { IrcKeys } from "@api/query_keys";
|
||||
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
|
||||
import { SlideOver } from "@components/panels";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
|
@ -132,7 +132,7 @@ export function IrcNetworkAddForm({ isOpen, toggle }: AddFormProps) {
|
|||
const mutation = useMutation({
|
||||
mutationFn: (network: IrcNetwork) => APIClient.irc.createNetwork(network),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body="IRC Network added. Please allow up to 30 seconds for the network to come online." t={t} />);
|
||||
toggle();
|
||||
|
@ -288,7 +288,7 @@ export function IrcNetworkUpdateForm({
|
|||
const updateMutation = useMutation({
|
||||
mutationFn: (network: IrcNetwork) => APIClient.irc.updateNetwork(network),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`${network.name} was updated successfully`} t={t} />);
|
||||
|
||||
|
@ -301,7 +301,7 @@ export function IrcNetworkUpdateForm({
|
|||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => APIClient.irc.deleteNetwork(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: IrcKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`${network.name} was deleted.`} t={t} />);
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { notificationKeys } from "@screens/settings/Notifications";
|
||||
import { NotificationKeys } from "@api/query_keys";
|
||||
import { EventOptions, NotificationTypeOptions, SelectOption } from "@domain/constants";
|
||||
import { DEBUG } from "@components/debug";
|
||||
import { SlideOver } from "@components/panels";
|
||||
|
@ -294,7 +294,7 @@ export function NotificationAddForm({ isOpen, toggle }: AddProps) {
|
|||
const createMutation = useMutation({
|
||||
mutationFn: (notification: ServiceNotification) => APIClient.notifications.create(notification),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body="Notification added!" t={t} />);
|
||||
toggle();
|
||||
|
@ -565,7 +565,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
|
|||
const mutation = useMutation({
|
||||
mutationFn: (notification: ServiceNotification) => APIClient.notifications.update(notification),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`${notification.name} was updated successfully`} t={t}/>);
|
||||
toggle();
|
||||
|
@ -577,7 +577,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
|
|||
const deleteMutation = useMutation({
|
||||
mutationFn: (notificationID: number) => APIClient.notifications.delete(notificationID),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: notificationKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: NotificationKeys.lists() });
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`${notification.name} was deleted.`} t={t}/>);
|
||||
}
|
||||
|
|
377
web/src/routes.tsx
Normal file
377
web/src/routes.tsx
Normal file
|
@ -0,0 +1,377 @@
|
|||
/*
|
||||
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import {
|
||||
createRootRouteWithContext,
|
||||
createRoute,
|
||||
createRouter,
|
||||
ErrorComponent,
|
||||
notFound,
|
||||
Outlet,
|
||||
redirect,
|
||||
} from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Actions, Advanced, External, General, MoviesTv, Music } from "@screens/filters/sections";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { Login, Onboarding } from "@screens/auth";
|
||||
import ReleaseSettings from "@screens/settings/Releases";
|
||||
import { NotFound } from "@components/alerts/NotFound";
|
||||
import { FilterDetails, FilterNotFound, Filters } from "@screens/filters";
|
||||
import { Settings } from "@screens/Settings";
|
||||
import {
|
||||
ApikeysQueryOptions,
|
||||
ConfigQueryOptions,
|
||||
DownloadClientsQueryOptions,
|
||||
FeedsQueryOptions,
|
||||
FilterByIdQueryOptions,
|
||||
IndexersQueryOptions,
|
||||
IrcQueryOptions,
|
||||
NotificationsQueryOptions
|
||||
} from "@api/queries";
|
||||
import LogSettings from "@screens/settings/Logs";
|
||||
import NotificationSettings from "@screens/settings/Notifications";
|
||||
import ApplicationSettings from "@screens/settings/Application";
|
||||
import { Logs } from "@screens/Logs";
|
||||
import IrcSettings from "@screens/settings/Irc";
|
||||
import { Header } from "@components/header";
|
||||
import { RingResizeSpinner } from "@components/Icons";
|
||||
import APISettings from "@screens/settings/Api";
|
||||
import { Releases } from "@screens/Releases";
|
||||
import IndexerSettings from "@screens/settings/Indexer";
|
||||
import DownloadClientSettings from "@screens/settings/DownloadClient";
|
||||
import FeedSettings from "@screens/settings/Feed";
|
||||
import { Dashboard } from "@screens/Dashboard";
|
||||
import AccountSettings from "@screens/settings/Account";
|
||||
import { AuthContext, AuthCtx, localStorageUserKey, SettingsContext } from "@utils/Context";
|
||||
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { queryClient } from "@api/QueryClient";
|
||||
|
||||
const DashboardRoute = createRoute({
|
||||
getParentRoute: () => AuthIndexRoute,
|
||||
path: '/',
|
||||
loader: () => {
|
||||
// https://tanstack.com/router/v1/docs/guide/deferred-data-loading#deferred-data-loading-with-defer-and-await
|
||||
// TODO load stats
|
||||
|
||||
// TODO load recent releases
|
||||
|
||||
return {}
|
||||
},
|
||||
component: Dashboard,
|
||||
});
|
||||
|
||||
const FiltersRoute = createRoute({
|
||||
getParentRoute: () => AuthIndexRoute,
|
||||
path: 'filters'
|
||||
});
|
||||
|
||||
const FilterIndexRoute = createRoute({
|
||||
getParentRoute: () => FiltersRoute,
|
||||
path: '/',
|
||||
component: Filters,
|
||||
});
|
||||
|
||||
export const FilterGetByIdRoute = createRoute({
|
||||
getParentRoute: () => FiltersRoute,
|
||||
path: '$filterId',
|
||||
parseParams: (params) => ({
|
||||
filterId: z.number().int().parse(Number(params.filterId)),
|
||||
}),
|
||||
stringifyParams: ({filterId}) => ({filterId: `${filterId}`}),
|
||||
loader: async ({context, params}) => {
|
||||
try {
|
||||
const filter = await context.queryClient.ensureQueryData(FilterByIdQueryOptions(params.filterId))
|
||||
return { filter }
|
||||
} catch (e) {
|
||||
throw notFound()
|
||||
}
|
||||
},
|
||||
component: FilterDetails,
|
||||
notFoundComponent: () => {
|
||||
return <FilterNotFound />
|
||||
},
|
||||
});
|
||||
|
||||
export const FilterGeneralRoute = createRoute({
|
||||
getParentRoute: () => FilterGetByIdRoute,
|
||||
path: '/',
|
||||
component: General
|
||||
});
|
||||
|
||||
export const FilterMoviesTvRoute = createRoute({
|
||||
getParentRoute: () => FilterGetByIdRoute,
|
||||
path: 'movies-tv',
|
||||
component: MoviesTv
|
||||
});
|
||||
|
||||
export const FilterMusicRoute = createRoute({
|
||||
getParentRoute: () => FilterGetByIdRoute,
|
||||
path: 'music',
|
||||
component: Music
|
||||
});
|
||||
|
||||
export const FilterAdvancedRoute = createRoute({
|
||||
getParentRoute: () => FilterGetByIdRoute,
|
||||
path: 'advanced',
|
||||
component: Advanced
|
||||
});
|
||||
|
||||
export const FilterExternalRoute = createRoute({
|
||||
getParentRoute: () => FilterGetByIdRoute,
|
||||
path: 'external',
|
||||
component: External
|
||||
});
|
||||
|
||||
export const FilterActionsRoute = createRoute({
|
||||
getParentRoute: () => FilterGetByIdRoute,
|
||||
path: 'actions',
|
||||
component: Actions
|
||||
});
|
||||
|
||||
const ReleasesRoute = createRoute({
|
||||
getParentRoute: () => AuthIndexRoute,
|
||||
path: 'releases'
|
||||
});
|
||||
|
||||
// type ReleasesSearch = z.infer<typeof releasesSearchSchema>
|
||||
|
||||
export const ReleasesIndexRoute = createRoute({
|
||||
getParentRoute: () => ReleasesRoute,
|
||||
path: '/',
|
||||
component: Releases,
|
||||
validateSearch: (search) => z.object({
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
filter: z.string().optional(),
|
||||
q: z.string().optional(),
|
||||
action_status: z.enum(['PUSH_APPROVED', 'PUSH_REJECTED', 'PUSH_ERROR', '']).optional(),
|
||||
// filters: z.array().catch(''),
|
||||
// sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),
|
||||
}).parse(search),
|
||||
});
|
||||
|
||||
export const SettingsRoute = createRoute({
|
||||
getParentRoute: () => AuthIndexRoute,
|
||||
path: 'settings',
|
||||
pendingMs: 3000,
|
||||
component: Settings
|
||||
});
|
||||
|
||||
export const SettingsIndexRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: '/',
|
||||
component: ApplicationSettings
|
||||
});
|
||||
|
||||
export const SettingsLogRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'logs',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(ConfigQueryOptions()),
|
||||
component: LogSettings
|
||||
});
|
||||
|
||||
export const SettingsIndexersRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'indexers',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(IndexersQueryOptions()),
|
||||
component: IndexerSettings
|
||||
});
|
||||
|
||||
export const SettingsIrcRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'irc',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(IrcQueryOptions()),
|
||||
component: IrcSettings
|
||||
});
|
||||
|
||||
export const SettingsFeedsRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'feeds',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(FeedsQueryOptions()),
|
||||
component: FeedSettings
|
||||
});
|
||||
|
||||
export const SettingsClientsRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'clients',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(DownloadClientsQueryOptions()),
|
||||
component: DownloadClientSettings
|
||||
});
|
||||
|
||||
export const SettingsNotificationsRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'notifications',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(NotificationsQueryOptions()),
|
||||
component: NotificationSettings
|
||||
});
|
||||
|
||||
export const SettingsApiRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'api',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(ApikeysQueryOptions()),
|
||||
component: APISettings
|
||||
});
|
||||
|
||||
export const SettingsReleasesRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'releases',
|
||||
component: ReleaseSettings
|
||||
});
|
||||
|
||||
export const SettingsAccountRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'account',
|
||||
component: AccountSettings
|
||||
});
|
||||
|
||||
export const LogsRoute = createRoute({
|
||||
getParentRoute: () => AuthIndexRoute,
|
||||
path: 'logs',
|
||||
component: Logs
|
||||
});
|
||||
|
||||
export const OnboardRoute = createRoute({
|
||||
getParentRoute: () => RootRoute,
|
||||
path: 'onboard',
|
||||
beforeLoad: async () => {
|
||||
// Check if onboarding is available for this instance
|
||||
// and redirect if needed
|
||||
try {
|
||||
await APIClient.auth.canOnboard()
|
||||
} catch (e) {
|
||||
console.error("onboarding not available, redirect to login")
|
||||
|
||||
throw redirect({
|
||||
to: LoginRoute.to,
|
||||
})
|
||||
}
|
||||
},
|
||||
component: Onboarding
|
||||
});
|
||||
|
||||
export const LoginRoute = createRoute({
|
||||
getParentRoute: () => RootRoute,
|
||||
path: 'login',
|
||||
validateSearch: z.object({
|
||||
redirect: z.string().optional(),
|
||||
}),
|
||||
beforeLoad: ({ navigate}) => {
|
||||
// handle canOnboard
|
||||
APIClient.auth.canOnboard().then(() => {
|
||||
console.info("onboarding available, redirecting")
|
||||
|
||||
navigate({ to: OnboardRoute.to })
|
||||
}).catch(() => {
|
||||
console.info("onboarding not available, please login")
|
||||
})
|
||||
},
|
||||
}).update({component: Login});
|
||||
|
||||
export const AuthRoute = createRoute({
|
||||
getParentRoute: () => RootRoute,
|
||||
id: 'auth',
|
||||
// Before loading, authenticate the user via our auth context
|
||||
// This will also happen during prefetching (e.g. hovering over links, etc.)
|
||||
beforeLoad: ({context, location}) => {
|
||||
// If the user is not logged in, check for item in localStorage
|
||||
if (!context.auth.isLoggedIn) {
|
||||
const storage = localStorage.getItem(localStorageUserKey);
|
||||
if (storage) {
|
||||
try {
|
||||
const json = JSON.parse(storage);
|
||||
if (json === null) {
|
||||
console.warn(`JSON localStorage value for '${localStorageUserKey}' context state is null`);
|
||||
} else {
|
||||
context.auth.isLoggedIn = json.isLoggedIn
|
||||
context.auth.username = json.username
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`auth Failed to merge ${localStorageUserKey} context state: ${e}`);
|
||||
}
|
||||
} else {
|
||||
// If the user is logged out, redirect them to the login page
|
||||
throw redirect({
|
||||
to: LoginRoute.to,
|
||||
search: {
|
||||
// Use the current location to power a redirect after login
|
||||
// (Do not use `router.state.resolvedLocation` as it can
|
||||
// potentially lag behind the actual current location)
|
||||
redirect: location.href,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, return the user in context
|
||||
return {
|
||||
username: AuthContext.username,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function AuthenticatedLayout() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header/>
|
||||
<Outlet/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AuthIndexRoute = createRoute({
|
||||
getParentRoute: () => AuthRoute,
|
||||
component: AuthenticatedLayout,
|
||||
id: 'authenticated-routes',
|
||||
});
|
||||
|
||||
export const RootComponent = () => {
|
||||
const settings = SettingsContext.useValue();
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Outlet/>
|
||||
{settings.debug ? (
|
||||
<>
|
||||
<TanStackRouterDevtools/>
|
||||
<ReactQueryDevtools initialIsOpen={false}/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RootRoute = createRootRouteWithContext<{
|
||||
auth: AuthCtx,
|
||||
queryClient: QueryClient
|
||||
}>()({
|
||||
component: RootComponent,
|
||||
notFoundComponent: NotFound,
|
||||
});
|
||||
|
||||
const filterRouteTree = FiltersRoute.addChildren([FilterIndexRoute, FilterGetByIdRoute.addChildren([FilterGeneralRoute, FilterMoviesTvRoute, FilterMusicRoute, FilterAdvancedRoute, FilterExternalRoute, FilterActionsRoute])])
|
||||
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsReleasesRoute, SettingsAccountRoute])
|
||||
const authenticatedTree = AuthRoute.addChildren([AuthIndexRoute.addChildren([DashboardRoute, filterRouteTree, ReleasesRoute.addChildren([ReleasesIndexRoute]), settingsRouteTree, LogsRoute])])
|
||||
const routeTree = RootRoute.addChildren([
|
||||
authenticatedTree,
|
||||
LoginRoute,
|
||||
OnboardRoute
|
||||
]);
|
||||
|
||||
export const Router = createRouter({
|
||||
routeTree,
|
||||
defaultPendingComponent: () => (
|
||||
<div className="absolute top-1/4 left-1/2 !border-0">
|
||||
<RingResizeSpinner className="text-blue-500 size-24"/>
|
||||
</div>
|
||||
),
|
||||
defaultErrorComponent: ({error}) => <ErrorComponent error={error}/>,
|
||||
context: {
|
||||
auth: undefined!, // We'll inject this when we render
|
||||
queryClient
|
||||
},
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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">
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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() });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
<>
|
||||
|
|
61
web/src/screens/filters/NotFound.tsx
Normal file
61
web/src/screens/filters/NotFound.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -5,3 +5,4 @@
|
|||
|
||||
export { Filters } from "./List";
|
||||
export { FilterDetails } from "./Details";
|
||||
export { FilterNotFound } from "./NotFound";
|
|
@ -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: "",
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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) => (
|
|
@ -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 "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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 "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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 "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap"> </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 "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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"> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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 "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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 "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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 "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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 "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </td>
|
||||
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap "> </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>
|
||||
);
|
||||
|
|
|
@ -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: "",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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} />);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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() });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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() });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -3,13 +3,8 @@
|
|||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { newRidgeState } from "react-ridge-state";
|
||||
import type { StateWithValue } from "react-ridge-state";
|
||||
|
||||
interface AuthInfo {
|
||||
username: string;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
import { newRidgeState } from "react-ridge-state";
|
||||
|
||||
interface SettingsType {
|
||||
debug: boolean;
|
||||
|
@ -26,11 +21,16 @@ export type FilterListState = {
|
|||
status: string;
|
||||
};
|
||||
|
||||
// interface AuthInfo {
|
||||
// username: string;
|
||||
// isLoggedIn: boolean;
|
||||
// }
|
||||
|
||||
// Default values
|
||||
const AuthContextDefaults: AuthInfo = {
|
||||
username: "",
|
||||
isLoggedIn: false
|
||||
};
|
||||
// const AuthContextDefaults: AuthInfo = {
|
||||
// username: "",
|
||||
// isLoggedIn: false
|
||||
// };
|
||||
|
||||
const SettingsContextDefaults: SettingsType = {
|
||||
debug: false,
|
||||
|
@ -53,7 +53,7 @@ function ContextMerger<T extends {}>(
|
|||
defaults: T,
|
||||
ctxState: StateWithValue<T>
|
||||
) {
|
||||
let values = defaults;
|
||||
let values = structuredClone(defaults);
|
||||
|
||||
const storage = localStorage.getItem(key);
|
||||
if (storage) {
|
||||
|
@ -62,25 +62,28 @@ function ContextMerger<T extends {}>(
|
|||
if (json === null) {
|
||||
console.warn(`JSON localStorage value for '${key}' context state is null`);
|
||||
} else {
|
||||
values = { ...defaults, ...json };
|
||||
values = { ...values, ...json };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to merge ${key} context state: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ctxState.set(values);
|
||||
}
|
||||
|
||||
const SettingsKey = "autobrr_settings";
|
||||
const FilterListKey = "autobrr_filter_list";
|
||||
|
||||
export const InitializeGlobalContext = () => {
|
||||
ContextMerger<AuthInfo>("auth", AuthContextDefaults, AuthContext);
|
||||
// ContextMerger<AuthInfo>(localStorageUserKey, AuthContextDefaults, AuthContextt);
|
||||
ContextMerger<SettingsType>(
|
||||
"settings",
|
||||
SettingsKey,
|
||||
SettingsContextDefaults,
|
||||
SettingsContext
|
||||
);
|
||||
ContextMerger<FilterListState>(
|
||||
"filterList",
|
||||
FilterListKey,
|
||||
FilterListContextDefaults,
|
||||
FilterListContext
|
||||
);
|
||||
|
@ -98,16 +101,16 @@ function DefaultSetter<T>(name: string, newState: T, prevState: T) {
|
|||
}
|
||||
}
|
||||
|
||||
export const AuthContext = newRidgeState<AuthInfo>(AuthContextDefaults, {
|
||||
onSet: (newState, prevState) => DefaultSetter("auth", newState, prevState)
|
||||
});
|
||||
// export const AuthContextt = newRidgeState<AuthInfo>(AuthContextDefaults, {
|
||||
// onSet: (newState, prevState) => DefaultSetter(localStorageUserKey, newState, prevState)
|
||||
// });
|
||||
|
||||
export const SettingsContext = newRidgeState<SettingsType>(
|
||||
SettingsContextDefaults,
|
||||
{
|
||||
onSet: (newState, prevState) => {
|
||||
document.documentElement.classList.toggle("dark", newState.darkTheme);
|
||||
DefaultSetter("settings", newState, prevState);
|
||||
DefaultSetter(SettingsKey, newState, prevState);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -115,6 +118,32 @@ export const SettingsContext = newRidgeState<SettingsType>(
|
|||
export const FilterListContext = newRidgeState<FilterListState>(
|
||||
FilterListContextDefaults,
|
||||
{
|
||||
onSet: (newState, prevState) => DefaultSetter("filterList", newState, prevState)
|
||||
onSet: (newState, prevState) => DefaultSetter(FilterListKey, newState, prevState)
|
||||
}
|
||||
);
|
||||
|
||||
export type AuthCtx = {
|
||||
isLoggedIn: boolean
|
||||
username?: string
|
||||
login: (username: string) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const localStorageUserKey = "autobrr_user_auth"
|
||||
|
||||
export const AuthContext: AuthCtx = {
|
||||
isLoggedIn: false,
|
||||
username: undefined,
|
||||
login: (username: string) => {
|
||||
AuthContext.isLoggedIn = true
|
||||
AuthContext.username = username
|
||||
|
||||
localStorage.setItem(localStorageUserKey, JSON.stringify(AuthContext));
|
||||
},
|
||||
logout: () => {
|
||||
AuthContext.isLoggedIn = false
|
||||
AuthContext.username = undefined
|
||||
|
||||
localStorage.removeItem(localStorageUserKey);
|
||||
},
|
||||
}
|
|
@ -12,7 +12,7 @@ export function sleep(ms: number) {
|
|||
|
||||
// get baseUrl sent from server rendered index template
|
||||
export function baseUrl() {
|
||||
let baseUrl = "";
|
||||
let baseUrl = "/";
|
||||
if (window.APP.baseUrl) {
|
||||
if (window.APP.baseUrl === "{{.BaseUrl}}") {
|
||||
baseUrl = "/";
|
||||
|
@ -23,6 +23,20 @@ export function baseUrl() {
|
|||
return baseUrl;
|
||||
}
|
||||
|
||||
// get routerBasePath sent from server rendered index template
|
||||
// routerBasePath is used for RouterProvider and does not need work with trailing slash
|
||||
export function routerBasePath() {
|
||||
let baseUrl = "";
|
||||
if (window.APP.baseUrl) {
|
||||
if (window.APP.baseUrl === "{{.BaseUrl}}") {
|
||||
baseUrl = "";
|
||||
} else {
|
||||
baseUrl = window.APP.baseUrl;
|
||||
}
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
// get sseBaseUrl for SSE
|
||||
export function sseBaseUrl() {
|
||||
if (process.env.NODE_ENV === "development")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue