enhancement(web): ui overhaul (#1155)

* Various WebUI changes and fixes.

* feat(tooltip): make tooltip display upwards

* fix(tooltip): place tooltip to the right

* fix(web): add missing ml-px to SwitchGroup header

current: https://i.imgur.com/2WXstPV.png
new: https://i.imgur.com/QGQ49mP.png

* fix(web): collapse sections

* fix(web):  improve freeleech section

* fix(web): rename action to action_components

Renamed the 'action' folder to 'action_components' to resolve import issues due to case sensitivity.

* fix(web): align CollapsibleSection

Old Advanced tab: https://i.imgur.com/MXaJ5eJ.png
New Advanced tab: https://i.imgur.com/4nPJJRw.png
Music tab for comparison: https://i.imgur.com/I59X7ot.png

* fix(web): remove invalid CSS class

* revert: vertical padding on switchgroup

added py-0 on the freeleech part instead

* feat(settings): add back log files

* fix(settings): irc channels and font sizes

* fix(components): radio select roundness

* fix(styling): various minor changes

* fix(filters): remove jitter fields

---------

Co-authored-by: ze0s <43699394+zze0s@users.noreply.github.com>
Co-authored-by: soup <soup@r4tio.dev>
Co-authored-by: ze0s <ze0s@riseup.net>
This commit is contained in:
stacksmash76 2023-11-18 13:46:16 +00:00 committed by GitHub
parent a274d9ddce
commit e842a7bd42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
84 changed files with 4378 additions and 4361 deletions

View file

@ -280,7 +280,7 @@ func (r *FilterRepo) FindByID(ctx context.Context, filterID int) (*domain.Filter
// filter external
var extName, extType, extExecCmd, extExecArgs, extWebhookHost, extWebhookMethod, extWebhookHeaders, extWebhookData, extWebhookRetryStatus sql.NullString
var extId, extIndex, extWebhookStatus, extWebhookRetryAttempts, extWebhookDelaySeconds, extWebhookRetryJitterSeconds, extExecStatus sql.NullInt32
var extId, extIndex, extWebhookStatus, extWebhookRetryAttempts, extWebhookDelaySeconds, extExecStatus sql.NullInt32
var extEnabled sql.NullBool
if err := rows.Scan(
@ -360,7 +360,6 @@ func (r *FilterRepo) FindByID(ctx context.Context, filterID int) (*domain.Filter
&extWebhookRetryStatus,
&extWebhookRetryAttempts,
&extWebhookDelaySeconds,
&extWebhookRetryJitterSeconds,
); err != nil {
return nil, errors.Wrap(err, "error scanning row")
}
@ -551,7 +550,7 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, indexer string
// filter external
var extName, extType, extExecCmd, extExecArgs, extWebhookHost, extWebhookMethod, extWebhookHeaders, extWebhookData, extWebhookRetryStatus sql.NullString
var extId, extIndex, extWebhookStatus, extWebhookRetryAttempts, extWebhookDelaySeconds, extWebhookRetryJitterSeconds, extExecStatus, extFilterId sql.NullInt32
var extId, extIndex, extWebhookStatus, extWebhookRetryAttempts, extWebhookDelaySeconds, extExecStatus, extFilterId sql.NullInt32
var extEnabled sql.NullBool
if err := rows.Scan(
@ -631,7 +630,6 @@ func (r *FilterRepo) findByIndexerIdentifier(ctx context.Context, indexer string
&extWebhookRetryStatus,
&extWebhookRetryAttempts,
&extWebhookDelaySeconds,
&extWebhookRetryJitterSeconds,
&extFilterId,
); err != nil {
return nil, errors.Wrap(err, "error scanning row")
@ -756,7 +754,7 @@ func (r *FilterRepo) FindExternalFiltersByID(ctx context.Context, filterId int)
// filter external
var extExecCmd, extExecArgs, extWebhookHost, extWebhookMethod, extWebhookHeaders, extWebhookData, extWebhookRetryStatus sql.NullString
var extWebhookStatus, extWebhookRetryAttempts, extWebhookDelaySeconds, extWebhookRetryJitterSeconds, extExecStatus sql.NullInt32
var extWebhookStatus, extWebhookRetryAttempts, extWebhookDelaySeconds, extExecStatus sql.NullInt32
if err := rows.Scan(
&external.ID,
@ -775,7 +773,6 @@ func (r *FilterRepo) FindExternalFiltersByID(ctx context.Context, filterId int)
&extWebhookRetryStatus,
&extWebhookRetryAttempts,
&extWebhookDelaySeconds,
&extWebhookRetryJitterSeconds,
); err != nil {
return nil, errors.Wrap(err, "error scanning row")
}

View file

@ -123,7 +123,7 @@ func (h filterHandler) getByID(w http.ResponseWriter, r *http.Request) {
return
}
h.encoder.StatusInternalError(w)
h.encoder.Error(w, err)
return
}

View file

@ -19,7 +19,7 @@ module.exports = {
// Allow only double quotes and backticks
quotes: ["error", "double"],
// Warn if a line isn't indented with a multiple of 2
indent: ["warn", 2, { "SwitchCase": 1 }],
indent: ["warn", 2, { "SwitchCase": 0 }],
// Don't enforce any particular brace style
curly: "off",
// Allow only vars starting with _ to be ununsed vars
@ -64,7 +64,7 @@ module.exports = {
"@typescript-eslint/quotes": ["error", "double"],
semi: "off",
"@typescript-eslint/semi": ["warn", "always"],
indent: ["warn", 2, { "SwitchCase": 1 }],
indent: ["warn", 2, { "SwitchCase": 0 }],
"@typescript-eslint/indent": "off",
"@typescript-eslint/comma-dangle": "warn",
"keyword-spacing": "off",

View file

@ -70,6 +70,7 @@
"react-table": "^7.8.0",
"react-textarea-autosize": "^8.5.3",
"stacktracey": "^2.1.8",
"tailwind-lerp-colors": "1.2.1",
"tailwindcss": "^3.3.3",
"typescript": "^5.2.2",
"vite": "^4.5.0",

16
web/pnpm-lock.yaml generated
View file

@ -131,6 +131,9 @@ dependencies:
stacktracey:
specifier: ^2.1.8
version: 2.1.8
tailwind-lerp-colors:
specifier: 1.2.1
version: 1.2.1
tailwindcss:
specifier: ^3.3.3
version: 3.3.3
@ -2794,6 +2797,10 @@ packages:
fsevents: 2.3.3
dev: false
/chroma-js@2.4.2:
resolution: {integrity: sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==}
dev: false
/client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
dev: false
@ -5277,6 +5284,15 @@ packages:
resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==}
dev: false
/tailwind-lerp-colors@1.2.1:
resolution: {integrity: sha512-VC09rQv7bAjw2MEIeXxqgXKRQ5rW9z+1N+JAPvc/ARbQSIdmmBaweh3vR5YpxsXXW12SUylJutufoojFKmJeeQ==}
dependencies:
chroma-js: 2.4.2
tailwindcss: 3.3.3
transitivePeerDependencies:
- ts-node
dev: false
/tailwindcss@3.3.3:
resolution: {integrity: sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==}
engines: {node: '>=14.0.0'}

View file

@ -82,27 +82,27 @@ export async function HttpClient<T = unknown>(
const response = await window.fetch(`${baseUrl()}${endpoint}`, init);
switch (response.status) {
case 204:
case 204:
// 204 contains no data, but indicates success
return Promise.resolve<T>({} as T);
case 401:
return Promise.resolve<T>({} as T);
case 401:
// Remove auth info from localStorage
AuthContext.reset();
AuthContext.reset();
// Show an error toast to notify the user what occurred
return Promise.reject(new Error(`[401] Unauthorized: "${endpoint}"`));
case 404:
return Promise.reject(new Error(`[404] Not found: "${endpoint}"`));
case 500:
const health = await window.fetch(`${baseUrl()}api/healthz/liveness`);
if (!health.ok) {
return Promise.reject(
new Error(`[500] Offline (Internal server error): "${endpoint}"`, { cause: "OFFLINE" })
);
}
break;
default:
break;
// Show an error toast to notify the user what occurred
return Promise.reject(new Error(`[401] Unauthorized: "${endpoint}"`));
case 404:
return Promise.reject(new Error(`[404] Not found: "${endpoint}"`));
case 500:
const health = await window.fetch(`${baseUrl()}api/healthz/liveness`);
if (!health.ok) {
return Promise.reject(
new Error(`[500] Offline (Internal server error): "${endpoint}"`, { cause: "OFFLINE" })
);
}
break;
default:
break;
}
const isJson = response.headers.get("Content-Type")?.includes("application/json");
@ -249,7 +249,7 @@ export const APIClient = {
}),
toggleEnable: (id: number, enabled: boolean) => appClient.Patch(`api/indexer/${id}/enabled`, {
body: { enabled }
}),
})
},
irc: {
getNetworks: () => appClient.Get<IrcNetworkWithHealth[]>("api/irc"),

View file

@ -4,36 +4,82 @@
*/
import { Switch } from "@headlessui/react";
import { classNames } from "@utils";
interface CheckboxProps {
value: boolean;
setValue: (newValue: boolean) => void;
label: string;
description?: string;
value: boolean;
setValue: (newValue: boolean) => void;
label?: string;
description?: string;
className?: string;
disabled?: boolean;
}
export const Checkbox = ({ label, description, value, setValue }: CheckboxProps) => (
<Switch.Group as="li" className="py-4 flex items-center justify-between">
<div className="flex flex-col">
<Switch.Label as="p" className="text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white" passive>
{label}
</Switch.Label>
{description === undefined ? null : (
<Switch.Description className="text-sm text-gray-500 dark:text-gray-400">
{description}
</Switch.Description>
)}
</div>
export const Checkbox = ({
label,
description,
value,
className,
setValue,
disabled
}: CheckboxProps) => (
<Switch.Group
as="div"
className={classNames(className ?? "py-2", "flex items-center justify-between")}
onClick={(e) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
}}
>
{(label || description) ? (
<div className="flex flex-col mr-4">
{label ? (
<Switch.Label as="p" className="text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white" passive>
{label}
</Switch.Label>
) : null}
{description ? (
<Switch.Description className="text-sm text-gray-500 dark:text-gray-400">
{description}
</Switch.Description>
) : null}
</div>
) : null}
<Switch
checked={value}
onChange={setValue}
className={
`${value ? "bg-blue-500" : "bg-gray-200 dark:bg-gray-700"
} ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
onChange={(newValue) => {
!disabled && setValue(newValue);
}}
className={classNames(
disabled
? "cursor-not-allowed bg-gray-450 dark:bg-gray-700 border-gray-375 dark:border-gray-800"
: (
value
? "cursor-pointer bg-blue-600 border-blue-525"
: "cursor-pointer bg-gray-300 dark:bg-gray-700 border-gray-375 dark:border-gray-600"
),
"border relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors"
)}
>
<span
className={`${value ? "translate-x-5" : "translate-x-0"} inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200`}
/>
className={classNames(
value ? "translate-x-6" : "translate-x-[0.15rem]",
disabled ? "bg-gray-650 dark:bg-gray-800" : "bg-white",
"inline-flex items-center align-center h-4 w-4 transform rounded-full transition ring-0 shadow"
)}
>
{value
? (
<svg className={classNames(
disabled ? "text-white dark:text-gray-300" : "text-blue-500", "w-4 h-4"
)} fill="currentColor" viewBox="0 0 12 12"><path d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z"></path></svg>
)
: (
<svg className={classNames(
disabled ? "text-white dark:text-gray-300" : "text-gray-600", "w-4 h-4"
)} fill="none" viewBox="0 0 12 12"><path d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"></path></svg>
)}
</span>
</Switch>
</Switch.Group>
);
);

View file

@ -16,5 +16,5 @@ export const ExternalLink = ({ href, className, children }: ExternalLinkProps) =
);
export const DocsLink = ({ href }: { href: string; }) => (
<ExternalLink href={href} className="text-blue-400 visited:text-blue-400">{href}</ExternalLink>
<ExternalLink href={href} className="text-blue-700 dark:text-blue-400 visited:text-blue-700 dark:visited:text-blue-400">{href}</ExternalLink>
);

View file

@ -29,4 +29,4 @@ export const SectionLoader = ({ $size }: SectionLoaderProps) => {
<RingResizeSpinner className={classNames(SIZE[$size], "text-blue-500")} />
);
}
}
};

View file

@ -20,10 +20,10 @@ export const ErrorPage = ({ error, resetErrorBoundary }: FallbackProps) => {
const parseTitle = () => {
switch (error?.cause) {
case "OFFLINE":
return "Connection to Autobrr failed! Check the application state and verify your connectivity.";
default:
return "We caught an unrecoverable error!";
case "OFFLINE":
return "Connection to Autobrr failed! Check the application state and verify your connectivity.";
default:
return "We caught an unrecoverable error!";
}
};

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { classNames } from "@utils";
interface WarningAlertProps {
text: string | JSX.Element;
alert?: string;
colors?: string;
className?: string;
}
export const WarningAlert = ({ text, alert, colors, className }: WarningAlertProps) => (
<div
className={classNames(
className ?? "",
"col-span-12 flex items-center px-4 py-3 text-md font-medium rounded-lg",
colors ?? "text-amber-800 bg-amber-100 border border-amber-700 dark:border-none dark:bg-amber-200 dark:text-amber-800"
)}
role="alert">
<svg aria-hidden="true" className="flex-shrink-0 inline w-5 h-5 mr-3" fill="currentColor"
viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"></path>
</svg>
<span className="sr-only">Info</span>
<div>
<span className="font-extrabold">{alert ?? "Warning!"}</span>
{" "}{text}
</div>
</div>
);

View file

@ -3,34 +3,5 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
interface props {
title?: string;
text: string;
}
export function AlertWarning({ title, text }: props) {
return (
<div className="my-4 rounded-md bg-yellow-50 dark:bg-yellow-100 p-4 border border-yellow-300 dark:border-none">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationTriangleIcon
className="h-5 w-5 text-yellow-400 dark:text-yellow-600"
aria-hidden="true"
/>
</div>
<div className="ml-3">
{title ? (
<h3 className="mb-1 text-md font-medium text-yellow-800">{title}</h3>
) : null}
<div className="text-sm text-yellow-800">
<p>{text}</p>
</div>
</div>
</div>
</div>
);
}
export { ErrorPage } from "./ErrorPage";
export * from "./ErrorPage";
export * from "./Warning";

View file

@ -32,11 +32,14 @@ export const PageButton = ({ children, className, disabled, onClick }: ButtonPro
type="button"
className={classNames(
className ?? "",
"cursor-pointer inline-flex items-center p-1.5 border border-gray-300 dark:border-gray-700 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-600"
disabled
? "cursor-not-allowed text-gray-500 dark:text-gray-500 border-gray-300 dark:border-gray-700 dark:bg-gray-800"
: "cursor-pointer text-gray-500 dark:text-gray-350 border-gray-300 dark:border-gray-700 dark:bg-gray-850 hover:bg-gray-100 dark:hover:bg-gray-700",
"inline-flex items-center p-1.5 border text-sm font-medium transition"
)}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
);

View file

@ -8,7 +8,7 @@ import { toast } from "react-hot-toast";
import { formatDistanceToNowStrict } from "date-fns";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/solid";
import { ClockIcon, ExclamationCircleIcon, NoSymbolIcon } from "@heroicons/react/24/outline";
import { ClockIcon, XMarkIcon, NoSymbolIcon } from "@heroicons/react/24/outline";
import { APIClient } from "@api/APIClient";
import { classNames, simplifyDate } from "@utils";
@ -95,7 +95,7 @@ const RetryActionButton = ({ status }: RetryActionButtonProps) => {
};
return (
<button className="flex items-center px-1.5 py-1 ml-2 border-gray-500 bg-gray-700 rounded hover:bg-gray-600" onClick={replayAction}>
<button className="flex items-center px-1.5 py-1 ml-2 rounded transition border-gray-500 bg-gray-250 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600" onClick={replayAction}>
<span className="mr-1.5">Retry</span>
{mutation.isLoading
? <RingResizeSpinner className="text-blue-500 w-4 h-4 iconHeight" aria-hidden="true" />
@ -117,8 +117,8 @@ interface StatusCellMapEntry {
const StatusCellMap: Record<string, StatusCellMapEntry> = {
"PUSH_ERROR": {
colors: "bg-pink-100 text-pink-800 hover:bg-pink-300",
icon: <ExclamationCircleIcon className="h-5 w-5" aria-hidden="true" />,
colors: "bg-red-100 text-red-800 hover:bg-red-275",
icon: <XMarkIcon className="h-5 w-5" aria-hidden="true" />,
textFormatter: (status: ReleaseActionStatus) => (
<>
<span>
@ -159,7 +159,7 @@ const StatusCellMap: Record<string, StatusCellMapEntry> = {
)
},
"PUSH_APPROVED": {
colors: "bg-green-100 text-green-800 hover:bg-green-300",
colors: "bg-green-175 text-green-900 hover:bg-green-300",
icon: <CheckIcon className="h-5 w-5" aria-hidden="true" />,
textFormatter: (status: ReleaseActionStatus) => (
<>

View file

@ -7,7 +7,7 @@ import { PlusIcon } from "@heroicons/react/24/solid";
interface EmptySimpleProps {
title: string;
subtitle: string;
subtitle?: string;
buttonText?: string;
buttonAction?: () => void;
}
@ -20,7 +20,9 @@ export const EmptySimple = ({
}: EmptySimpleProps) => (
<div className="text-center py-8">
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">{title}</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-200">{subtitle}</p>
{subtitle ? (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-200">{subtitle}</p>
) : null}
{buttonText && buttonAction ? (
<div className="mt-6">
<button
@ -57,4 +59,4 @@ export function EmptyListState({ text, buttonText, buttonOnClick }: EmptyListSta
)}
</div>
);
}
}

View file

@ -67,12 +67,12 @@ export const KeyField = ({ value }: KeyFieldProps) => {
type={isVisible ? "text" : "password"}
value={value}
readOnly={true}
className="focus:outline-none dark:focus:border-blue-500 focus:border-blue-500 dark:focus:ring-blue-500 block w-full rounded-none rounded-l-md sm:text-sm border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
className="focus:outline-none dark:focus:border-blue-500 focus:border-blue-500 dark:focus:ring-blue-500 block w-full rounded-none rounded-l-md sm:text-sm border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-815 dark:text-gray-100"
/>
</div>
<button
type="button"
className="-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 dark:border-gray-700 hover:bg-gray-100 text-sm font-medium text-gray-700 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
className="-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 dark:border-gray-700 hover:bg-gray-100 text-sm font-medium text-gray-700 bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700 focus:outline-none"
onClick={toggleVisibility}
title="show"
>
@ -80,7 +80,7 @@ export const KeyField = ({ value }: KeyFieldProps) => {
</button>
<button
type="button"
className="-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 dark:border-gray-700 hover:bg-gray-100 text-sm font-medium rounded-r-md text-gray-700 dark:text-gray-100 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
className="-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 dark:border-gray-700 hover:bg-gray-100 text-sm font-medium rounded-r-md text-gray-700 dark:text-gray-100 bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700 focus:outline-none"
onClick={handleCopyClick}
title="Copy to clipboard"
>

View file

@ -48,12 +48,12 @@ export const Header = () => {
return (
<Disclosure
as="nav"
className="bg-gradient-to-b from-gray-100 dark:from-[#141414]"
className="bg-gradient-to-b from-gray-100 dark:from-gray-925"
>
{({ open }) => (
<>
<div className="max-w-screen-xl mx-auto sm:px-6 lg:px-8">
<div className="border-b border-gray-300 dark:border-gray-700">
<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} />

View file

@ -12,6 +12,7 @@ import { classNames } from "@utils";
import { AuthContext } from "@utils/Context";
import { RightNavProps } from "./_shared";
import { Cog6ToothIcon, ArrowLeftOnRectangleIcon } from "@heroicons/react/24/outline";
export const RightNav = (props: RightNavProps) => {
const authContext = AuthContext.useValue();
@ -23,10 +24,10 @@ export const RightNav = (props: RightNavProps) => {
<>
<Menu.Button
className={classNames(
open ? "bg-gray-200 dark:bg-gray-800" : "",
"text-gray-600 dark:text-gray-500 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",
open ? "bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-white" : "hover:text-gray-900 dark:hover:text-white",
"text-gray-600 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800 px-3 py-2 rounded-2xl text-sm font-medium",
"max-w-xs rounded-full flex items-center text-sm px-3 py-2",
"transition-colors duration-200"
"transition duration-200"
)}
>
<span className="hidden text-sm font-medium sm:block">
@ -52,7 +53,7 @@ export const RightNav = (props: RightNavProps) => {
>
<Menu.Items
static
className="origin-top-right absolute right-0 mt-2 w-48 z-10 rounded-md shadow-lg py-1 bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none"
className="origin-top-right absolute right-0 mt-2 w-48 z-10 divide-y divide-gray-100 dark:divide-gray-750 rounded-md shadow-lg bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 focus:outline-none"
>
<Menu.Item>
{({ active }) => (
@ -62,9 +63,13 @@ export const RightNav = (props: RightNavProps) => {
active
? "bg-gray-100 dark:bg-gray-600"
: "",
"block px-4 py-2 text-sm text-gray-900 dark:text-gray-200"
"flex items-center transition rounded-t-md px-2 py-2 text-sm text-gray-900 dark:text-gray-200"
)}
>
<Cog6ToothIcon
className="w-5 h-5 mr-1 text-gray-700 dark:text-gray-400"
aria-hidden="true"
/>
Settings
</Link>
)}
@ -80,9 +85,13 @@ export const RightNav = (props: RightNavProps) => {
active
? "bg-gray-100 dark:bg-gray-600"
: "",
"block w-full px-4 py-2 text-sm text-gray-900 dark:text-gray-200 text-left"
"flex items-center transition rounded-b-md w-full px-2 py-2 text-sm text-gray-900 dark:text-gray-200 text-left"
)}
>
<ArrowLeftOnRectangleIcon
className="w-5 h-5 mr-1 text-gray-700 dark:text-gray-400"
aria-hidden="true"
/>
Log out
</button>
)}
@ -95,4 +104,4 @@ export const RightNav = (props: RightNavProps) => {
</div>
</div>
);
}
};

View file

@ -3,16 +3,15 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { FC } from "react";
interface Props {
title: string;
subtitle: string;
title: string;
subtitle: string | React.ReactNode;
className?: string;
}
export const TitleSubtitle: FC<Props> = ({ title, subtitle }) => (
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">{title}</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{subtitle}</p>
export const TitleSubtitle = ({ title, subtitle, className }: Props) => (
<div className={className}>
<h2 className="text-lg leading-5 capitalize font-bold text-gray-900 dark:text-gray-100">{title}</h2>
<p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">{subtitle}</p>
</div>
);
);

View file

@ -4,73 +4,87 @@
*/
import { Field, FieldProps } from "formik";
import { components } from "react-select";
import type {
InputProps,
ControlProps,
MenuProps,
OptionProps,
IndicatorSeparatorProps,
DropdownIndicatorProps
} from "react-select";
import { classNames } from "@utils";
import { DocsTooltip } from "@components/tooltips/DocsTooltip";
interface ErrorFieldProps {
name: string;
classNames?: string;
name: string;
classNames?: string;
}
const ErrorField = ({ name, classNames }: ErrorFieldProps) => (
<div>
<Field name={name} subscribe={{ touched: true, error: true }}>
{({ meta: { touched, error } }: FieldProps) =>
touched && error ? <span className={classNames}>{error}</span> : null
}
</Field>
</div>
export const ErrorField = ({ name, classNames }: ErrorFieldProps) => (
<Field name={name} subscribe={{ touched: true, error: true }}>
{({ meta: { touched, error } }: FieldProps) =>
touched && error ? <span className={classNames}>{error}</span> : null
}
</Field>
);
interface RequiredFieldProps {
required?: boolean
}
const RequiredField = ({ required }: RequiredFieldProps) => (
export const RequiredField = ({ required }: RequiredFieldProps) => (
<>
{required && <span className="ml-1 text-red-500">*</span>}
</>
);
interface CheckboxFieldProps {
name: string;
label: string;
sublabel?: string;
disabled?: boolean;
tooltip?: JSX.Element;
}
const CheckboxField = ({
name,
label,
sublabel,
tooltip,
disabled
}: CheckboxFieldProps) => (
<div className="relative flex items-start">
<div className="flex items-center h-5">
<Field
id={name}
name={name}
type="checkbox"
className={classNames(
"focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded",
disabled ? "bg-gray-200 dark:bg-gray-700 dark:border-gray-700" : ""
)}
disabled={disabled}
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor={name} className="flex mb-2 text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</div>
</label>
<p className="text-gray-500">{sublabel}</p>
</div>
</div>
export const SelectInput = (props: InputProps) => (
<components.Input
{...props}
inputClassName="outline-none border-none shadow-none focus:ring-transparent"
className="text-gray-400 dark:text-gray-100"
children={props.children}
/>
);
export { ErrorField, RequiredField, CheckboxField };
export const SelectControl = (props: ControlProps) => (
<components.Control
{...props}
className="p-1 block w-full !bg-gray-100 dark:!bg-gray-850 border border-gray-300 dark:border-gray-775 dark:hover:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
children={props.children}
/>
);
export const SelectMenu = (props: MenuProps) => (
<components.Menu
{...props}
className="dark:bg-gray-800 border border-gray-300 dark:border-gray-700 dark:text-gray-400 rounded-md shadow-sm cursor-pointer"
children={props.children}
/>
);
export const SelectOption = (props: OptionProps) => (
<components.Option
{...props}
className={classNames(
"transition dark:hover:bg-gray-900 dark:focus:bg-gray-900",
props.isSelected ? "dark:bg-gray-875 dark:text-gray-200" : "dark:bg-gray-800 dark:text-gray-400"
)}
children={props.children}
/>
);
export const IndicatorSeparator = (props: IndicatorSeparatorProps) => (
<components.IndicatorSeparator
{...props}
className="!bg-gray-400 dark:!bg-gray-700"
/>
);
export const DropdownIndicator = (props: DropdownIndicatorProps) => (
<components.DropdownIndicator
{...props}
className="!text-gray-400 dark:!text-gray-300"
/>
);

View file

@ -3,10 +3,10 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
export { ErrorField, CheckboxField } from "./common";
export { TextField, NumberField, PasswordField, RegexField } from "./input";
export { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, SwitchGroupWideRed, TextFieldWide } from "./input_wide";
export { RadioFieldsetWide } from "./radio";
export { MultiSelect, Select, SelectWide, DownloadClientSelect, IndexerMultiSelect } from "./select";
export { SwitchGroup } from "./switch";
export * from "./common";
export * from "./input";
export * from "./input_wide";
export * from "./radio";
export * from "./select";
export * from "./switch";

View file

@ -18,6 +18,7 @@ interface TextFieldProps {
name: string;
defaultValue?: string;
label?: string;
required?: boolean;
placeholder?: string;
columns?: COL_WIDTHS;
autoComplete?: string;
@ -30,6 +31,7 @@ export const TextField = ({
name,
defaultValue,
label,
required,
placeholder,
columns,
autoComplete,
@ -39,25 +41,27 @@ export const TextField = ({
}: TextFieldProps) => (
<div
className={classNames(
"col-span-12",
hidden ? "hidden" : "",
columns ? `col-span-${columns}` : "col-span-12"
columns ? `sm:col-span-${columns}` : ""
)}
>
{label && (
<label htmlFor={name} className="flex float-left mb-2 text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</div>
<label htmlFor={name} className="flex ml-px text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
{required ? (
<span className="ml-1 text-red-500">*</span>
) : null}
</label>
)}
<Field name={name}>
<Field name={name} defaultValue={defaultValue}>
{({
field,
meta
}: FieldProps) => (
<div>
<>
<input
{...field}
name={name}
@ -65,9 +69,13 @@ export const TextField = ({
defaultValue={defaultValue}
autoComplete={autoComplete}
className={classNames(
meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700",
disabled ? "bg-gray-100 dark:bg-gray-700 cursor-not-allowed" : "dark:bg-gray-800",
"mt-2 block w-full dark:text-gray-100 rounded-md"
meta.touched && meta.error
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
disabled
? "bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed"
: "bg-gray-100 dark:bg-gray-815 dark:text-gray-100",
"mt-1 block border w-full dark:text-gray-100 rounded-md"
)}
disabled={disabled}
placeholder={placeholder}
@ -76,7 +84,7 @@ export const TextField = ({
{meta.touched && meta.error && (
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
)}
</div>
</>
)}
</Field>
</div>
@ -174,20 +182,19 @@ export const RegexField = ({
return (
<div
className={classNames(
"col-span-12",
hidden ? "hidden" : "",
columns ? `col-span-${columns}` : "col-span-12"
columns ? `sm:col-span-${columns}` : ""
)}
>
{label && (
<label
htmlFor={name}
className="flex float-left mb-2 text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide"
className="flex ml-px text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide"
>
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</div>
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</label>
)}
<Field
@ -204,15 +211,15 @@ export const RegexField = ({
autoComplete={autoComplete}
className={classNames(
useRegex && meta.error
? "focus:ring-red-500 focus:border-red-500 border-red-500"
: "focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700",
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
disabled
? "bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
: "dark:bg-gray-800",
? "bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed"
: "bg-gray-100 dark:bg-gray-815 dark:text-gray-100",
useRegex
? "pr-10"
: "",
"mt-2 block w-full dark:text-gray-100 rounded-md"
"mt-1 block w-full dark:text-gray-100 rounded-md"
)}
disabled={disabled}
placeholder={placeholder}
@ -221,9 +228,9 @@ export const RegexField = ({
<div className="relative">
<div className="flex float-right items-center">
{!meta.error ? (
<CheckCircleIcon className="dark:bg-gray-800 bg-white h-8 w-8 mb-2.5 pl-1 text-green-500 right-2 absolute transform -translate-y-1/2" aria-hidden="true" style={{ overflow: "hidden" }} />
<CheckCircleIcon className="h-8 w-8 mb-2.5 pl-1 text-green-500 right-2 absolute transform -translate-y-1/2" aria-hidden="true" style={{ overflow: "hidden" }} />
) : (
<XCircleIcon className="dark:bg-gray-800 bg-white h-8 w-8 mb-2.5 pl-1 text-red-500 right-2 absolute transform -translate-y-1/2" aria-hidden="true" style={{ overflow: "hidden" }} />
<XCircleIcon className="h-8 w-8 mb-2.5 pl-1 text-red-500 right-2 absolute transform -translate-y-1/2" aria-hidden="true" style={{ overflow: "hidden" }} />
)}
</div>
</div>
@ -315,20 +322,22 @@ export const RegexTextAreaField = ({
return (
<div
className={classNames(
"col-span-12",
hidden ? "hidden" : "",
columns ? `col-span-${columns}` : "col-span-12"
columns ? `sm:col-span-${columns}` : ""
)}
>
{label && (
<label
htmlFor={name}
className="flex float-left mb-2 text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide"
className={classNames(
tooltip ? "z-10" : "",
"flex ml-px text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide"
)}
>
<div className="flex z-10">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</div>
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</label>
)}
<Field
@ -346,15 +355,15 @@ export const RegexTextAreaField = ({
autoComplete={autoComplete}
className={classNames(
useRegex && meta.error
? "focus:ring-red-500 focus:border-red-500 border-red-500"
: "focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700",
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
disabled
? "bg-gray-100 dark:bg-gray-700 cursor-not-allowed"
: "dark:bg-gray-800",
? "bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed"
: "bg-gray-100 dark:bg-gray-815 dark:text-gray-100",
useRegex
? "pr-10"
: "",
"mt-2 block w-full dark:text-gray-100 rounded-md"
"mt-1 block w-full dark:text-gray-100 rounded-md"
)}
placeholder={placeholder}
disabled={disabled}
@ -364,9 +373,9 @@ export const RegexTextAreaField = ({
<div className="relative">
<div className="flex float-right items-center">
{!meta.error ? (
<CheckCircleIcon className="dark:bg-gray-800 bg-white h-8 w-8 mb-2.5 pl-1 text-green-500 right-2 absolute transform -translate-y-1/2" aria-hidden="true" style={{ overflow: "hidden" }} />
<CheckCircleIcon className="h-8 w-8 mb-2.5 pl-1 text-green-500 right-2 absolute transform -translate-y-1/2" aria-hidden="true" style={{ overflow: "hidden" }} />
) : (
<XCircleIcon className="dark:bg-gray-800 bg-white h-8 w-8 mb-2.5 pl-1 text-red-500 right-2 absolute transform -translate-y-1/2" aria-hidden="true" style={{ overflow: "hidden" }} />
<XCircleIcon className="h-8 w-8 mb-2.5 pl-1 text-red-500 right-2 absolute transform -translate-y-1/2" aria-hidden="true" style={{ overflow: "hidden" }} />
)}
</div>
</div>
@ -406,17 +415,16 @@ export const TextArea = ({
}: TextAreaProps) => (
<div
className={classNames(
"col-span-12",
hidden ? "hidden" : "",
columns ? `col-span-${columns}` : "col-span-12"
columns ? `sm:col-span-${columns}` : ""
)}
>
{label && (
<label htmlFor={name} className="flex float-left mb-2 text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</div>
<label htmlFor={name} className="flex ml-px text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</label>
)}
<Field name={name}>
@ -432,9 +440,13 @@ export const TextArea = ({
defaultValue={defaultValue}
autoComplete={autoComplete}
className={classNames(
meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700",
disabled ? "bg-gray-100 dark:bg-gray-700 cursor-not-allowed" : "dark:bg-gray-800",
"mt-2 block w-full dark:text-gray-100 rounded-md"
meta.touched && meta.error
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
disabled
? "bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed"
: "bg-gray-100 dark:bg-gray-815 dark:text-gray-100",
"mt-1 block border w-full dark:text-gray-100 rounded-md"
)}
placeholder={placeholder}
disabled={disabled}
@ -460,7 +472,7 @@ interface TextAreaAutoResizeProps {
hidden?: boolean;
disabled?: boolean;
tooltip?: JSX.Element;
className?: string;
}
export const TextAreaAutoResize = ({
@ -473,21 +485,22 @@ export const TextAreaAutoResize = ({
autoComplete,
hidden,
tooltip,
disabled
disabled,
className = ""
}: TextAreaAutoResizeProps) => (
<div
className={classNames(
className,
"col-span-12",
hidden ? "hidden" : "",
columns ? `col-span-${columns}` : "col-span-12"
columns ? `sm:col-span-${columns}` : ""
)}
>
{label && (
<label htmlFor={name} className="flex float-left mb-2 text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</div>
<label htmlFor={name} className="flex ml-px text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</label>
)}
<Field name={name}>
@ -504,9 +517,13 @@ export const TextAreaAutoResize = ({
defaultValue={defaultValue}
autoComplete={autoComplete}
className={classNames(
meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700",
disabled ? "bg-gray-100 dark:bg-gray-700 cursor-not-allowed" : "dark:bg-gray-800",
"mt-2 block w-full dark:text-gray-100 rounded-md"
meta.touched && meta.error
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
disabled
? "bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed"
: "bg-gray-100 dark:bg-gray-815 dark:text-gray-100",
"mt-1 block w-full dark:text-gray-100 rounded-md"
)}
placeholder={placeholder}
disabled={disabled}
@ -548,11 +565,12 @@ export const PasswordField = ({
return (
<div
className={classNames(
columns ? `col-span-${columns}` : "col-span-12"
"col-span-12",
columns ? `sm:col-span-${columns}` : ""
)}
>
{label && (
<label htmlFor={name} className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
<label htmlFor={name} className="block ml-px text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">
{label} {required && <span className="text-gray-500">*</span>}
</label>
)}
@ -571,9 +589,9 @@ export const PasswordField = ({
autoComplete={autoComplete}
className={classNames(
meta.touched && meta.error
? "focus:ring-red-500 focus:border-red-500 border-red-500"
: "focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700",
"mt-2 block w-full dark:bg-gray-800 dark:text-gray-100 rounded-md"
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
"mt-1 block w-full rounded-md bg-gray-100 dark:bg-gray-850 dark:text-gray-100"
)}
placeholder={placeholder}
/>
@ -608,6 +626,7 @@ interface NumberFieldProps {
min?: number;
max?: number;
tooltip?: JSX.Element;
className?: string;
isDecimal?: boolean;
}
@ -621,18 +640,17 @@ export const NumberField = ({
tooltip,
disabled,
required,
isDecimal
isDecimal,
className = ""
}: NumberFieldProps) => (
<div className="col-span-12 sm:col-span-6">
<div className={classNames(className, "col-span-12 sm:col-span-6")}>
<label
htmlFor={name}
className="flex float-left mb-2 text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide"
className="flex ml-px text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide"
>
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</div>
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</label>
<Field name={name} type="number">
@ -648,10 +666,12 @@ export const NumberField = ({
required={required}
className={classNames(
meta.touched && meta.error
? "focus:ring-red-500 focus:border-red-500 border-red-500"
: "focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300",
"mt-2 block w-full border border-gray-300 dark:border-gray-700 dark:text-gray-100 rounded-md",
disabled ? "bg-gray-100 dark:bg-gray-700 cursor-not-allowed" : "dark:bg-gray-800"
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
"mt-1 block w-full border rounded-md",
disabled
? "bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed"
: "bg-gray-100 dark:bg-gray-815 dark:text-gray-100"
)}
placeholder={placeholder}
disabled={disabled}

View file

@ -3,16 +3,20 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import type { FieldProps, FieldValidator } from "formik";
import { Field } from "formik";
import Select from "react-select";
import { Switch } from "@headlessui/react";
import type { FieldProps, FieldValidator } from "formik";
import { classNames } from "@utils";
import { useToggle } from "@hooks/hooks";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid";
import { Switch } from "@headlessui/react";
import { ErrorField, RequiredField } from "./common";
import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select";
import { SelectFieldProps } from "./select";
import * as common from "./common";
import { DocsTooltip } from "@components/tooltips/DocsTooltip";
import { Checkbox } from "@components/Checkbox";
interface TextFieldWideProps {
name: string;
@ -41,12 +45,12 @@ export const TextFieldWide = ({
}: TextFieldWideProps) => (
<div hidden={hidden} className="space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div>
<label htmlFor={name} className="flex text-sm font-medium text-gray-900 dark:text-white sm:mt-px sm:pt-2">
<label htmlFor={name} className="flex ml-px text-sm font-medium text-gray-900 dark:text-white sm:mt-px sm:pt-2">
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
<RequiredField required={required} />
<common.RequiredField required={required} />
</div>
</label>
</div>
@ -64,7 +68,12 @@ export const TextFieldWide = ({
type="text"
value={field.value ? field.value : defaultValue ?? ""}
onChange={field.onChange}
className={classNames(meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700", "block w-full shadow-sm dark:bg-gray-800 sm:text-sm dark:text-white rounded-md")}
className={classNames(
meta.touched && meta.error
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
"block w-full shadow-sm sm:text-sm rounded-md border py-2.5 bg-gray-100 dark:bg-gray-850 dark:text-gray-100"
)}
placeholder={placeholder}
hidden={hidden}
required={required}
@ -76,22 +85,22 @@ export const TextFieldWide = ({
{help && (
<p className="mt-2 text-sm text-gray-500" id={`${name}-description`}>{help}</p>
)}
<ErrorField name={name} classNames="block text-red-500 mt-2" />
<common.ErrorField name={name} classNames="block text-red-500 mt-2" />
</div>
</div>
);
interface PasswordFieldWideProps {
name: string;
label?: string;
placeholder?: string;
defaultValue?: string;
help?: string;
required?: boolean;
autoComplete?: string;
defaultVisible?: boolean;
tooltip?: JSX.Element;
validate?: FieldValidator;
name: string;
label?: string;
placeholder?: string;
defaultValue?: string;
help?: string;
required?: boolean;
autoComplete?: string;
defaultVisible?: boolean;
tooltip?: JSX.Element;
validate?: FieldValidator;
}
export const PasswordFieldWide = ({
@ -111,12 +120,12 @@ export const PasswordFieldWide = ({
return (
<div className="space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div>
<label htmlFor={name} className="flex text-sm font-medium text-gray-900 dark:text-white sm:mt-px sm:pt-2">
<label htmlFor={name} className="flex ml-px text-sm font-medium text-gray-900 dark:text-white sm:mt-px sm:pt-2">
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
<RequiredField required={required} />
<common.RequiredField required={required} />
</div>
</label>
</div>
@ -134,7 +143,12 @@ export const PasswordFieldWide = ({
value={field.value ? field.value : defaultValue ?? ""}
onChange={field.onChange}
type={isVisible ? "text" : "password"}
className={classNames(meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700", "block w-full pr-10 dark:bg-gray-800 shadow-sm dark:text-gray-100 sm:text-sm rounded-md")}
className={classNames(
meta.touched && meta.error
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
"block w-full shadow-sm sm:text-sm rounded-md border py-2.5 bg-gray-100 dark:bg-gray-850 dark:text-gray-100"
)}
placeholder={placeholder}
required={required}
autoComplete={autoComplete}
@ -149,20 +163,20 @@ export const PasswordFieldWide = ({
{help && (
<p className="mt-2 text-sm text-gray-500" id={`${name}-description`}>{help}</p>
)}
<ErrorField name={name} classNames="block text-red-500 mt-2" />
<common.ErrorField name={name} classNames="block text-red-500 mt-2" />
</div>
</div>
);
};
interface NumberFieldWideProps {
name: string;
label?: string;
help?: string;
placeholder?: string;
defaultValue?: number;
required?: boolean;
tooltip?: JSX.Element;
name: string;
label?: string;
help?: string;
placeholder?: string;
defaultValue?: number;
required?: boolean;
tooltip?: JSX.Element;
}
export const NumberFieldWide = ({
@ -178,13 +192,13 @@ export const NumberFieldWide = ({
<div>
<label
htmlFor={name}
className="block text-sm font-medium text-gray-900 dark:text-white sm:mt-px sm:pt-2"
className="block ml-px text-sm font-medium text-gray-900 dark:text-white sm:mt-px sm:pt-2"
>
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
<RequiredField required={required} />
<common.RequiredField required={required} />
</div>
</label>
</div>
@ -202,9 +216,9 @@ export const NumberFieldWide = ({
onChange={(e) => { form.setFieldValue(field.name, parseInt(e.target.value)); }}
className={classNames(
meta.touched && meta.error
? "focus:ring-red-500 focus:border-red-500 border-red-500"
: "focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700",
"block w-full shadow-sm dark:bg-gray-800 sm:text-sm dark:text-white rounded-md"
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
"block w-full shadow-sm sm:text-sm rounded-md border py-2.5 bg-gray-100 dark:bg-gray-850 dark:text-gray-100"
)}
onWheel={(event) => {
if (event.currentTarget === document.activeElement) {
@ -219,18 +233,18 @@ export const NumberFieldWide = ({
{help && (
<p className="mt-2 text-sm text-gray-500 dark:text-gray-500" id={`${name}-description`}>{help}</p>
)}
<ErrorField name={name} classNames="block text-red-500 mt-2" />
<common.ErrorField name={name} classNames="block text-red-500 mt-2" />
</div>
</div>
);
interface SwitchGroupWideProps {
name: string;
label: string;
description?: string;
defaultValue?: boolean;
className?: string;
tooltip?: JSX.Element;
name: string;
label: string;
description?: string;
defaultValue?: boolean;
className?: string;
tooltip?: JSX.Element;
}
export const SwitchGroupWide = ({
@ -262,138 +276,23 @@ export const SwitchGroupWide = ({
defaultValue={defaultValue as boolean}
type="checkbox"
>
{({ field, form }: FieldProps) => (
<Switch
{({
field,
form: { setFieldValue }
}: FieldProps) => (
<Checkbox
{...field}
type="button"
value={field.value}
checked={field.checked ?? false}
onChange={(value: unknown) => {
form.setFieldValue(field?.name ?? "", value);
value={!!field.checked}
setValue={(value) => {
setFieldValue(field?.name ?? "", value);
}}
className={classNames(
field.value ? "bg-blue-500 dark:bg-blue-500" : "bg-gray-200 dark:bg-gray-500",
"ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
field.value ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
/>
)}
</Field>
</Switch.Group>
</ul>
);
interface SwitchGroupWideRedProps {
name: string;
label: string;
description?: string;
defaultValue?: boolean;
className?: string;
}
export const SwitchGroupWideRed = ({
name,
label,
description,
defaultValue
}: SwitchGroupWideRedProps) => (
<ul className="mt-2 px-4 divide-y divide-gray-200 dark:divide-gray-700">
<Switch.Group as="li" className="py-4 flex items-center justify-between">
<div className="flex flex-col">
<Switch.Label as="p" className="text-sm font-medium text-gray-900 dark:text-white"
passive>
{label}
</Switch.Label>
{description && (
<Switch.Description className="text-sm text-gray-500 dark:text-gray-700">
{description}
</Switch.Description>
)}
</div>
<Field
name={name}
defaultValue={defaultValue as boolean}
type="checkbox"
>
{({ field, form }: FieldProps) => (
<Switch
{...field}
type="button"
value={field.value}
checked={field.checked ?? false}
onChange={(value: unknown) => {
form.setFieldValue(field?.name ?? "", value);
}}
className={classNames(
field.value ? "bg-blue-500 dark:bg-blue-500" : "bg-red-500 dark:bg-red-500",
"ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
field.value ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
)}
</Field>
</Switch.Group>
</ul>
);
const Input = (props: InputProps) => {
return (
<components.Input
{...props}
inputClassName="outline-none border-none shadow-none focus:ring-transparent"
className="text-gray-400 dark:text-gray-100"
children={props.children}
/>
);
};
const Control = (props: ControlProps) => {
return (
<components.Control
{...props}
className="p-1 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
children={props.children}
/>
);
};
const Menu = (props: MenuProps) => {
return (
<components.Menu
{...props}
className="dark:bg-gray-800 border border-gray-300 dark:border-gray-700 dark:text-gray-400 rounded-md shadow-sm"
children={props.children}
/>
);
};
const Option = (props: OptionProps) => {
return (
<components.Option
{...props}
className="dark:text-gray-400 dark:bg-gray-800 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
children={props.children}
/>
);
};
export const SelectFieldWide = ({
name,
label,
@ -405,7 +304,7 @@ export const SelectFieldWide = ({
<div>
<label
htmlFor={name}
className="flex text-sm font-medium text-gray-900 dark:text-white"
className="flex ml-px text-sm font-medium text-gray-900 dark:text-white"
>
<div className="flex">
{tooltip ? (
@ -426,10 +325,12 @@ export const SelectFieldWide = ({
isClearable={true}
isSearchable={true}
components={{
Input,
Control,
Menu,
Option
Input: common.SelectInput,
Control: common.SelectControl,
Menu: common.SelectMenu,
Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}}
placeholder={optionDefaultText}
styles={{

View file

@ -77,7 +77,7 @@ function RadioFieldsetWide({ name, legend, options }: props) {
checked
? "bg-blue-600 dark:bg-blue-500 border-transparent"
: "bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-300",
"h-6 w-6 mt-1 cursor-pointer rounded-full border flex items-center justify-center"
"h-6 w-6 mt-1 cursor-pointer rounded-full border flex items-center justify-center flex-shrink-0"
)}
aria-hidden="true"
/>

View file

@ -10,24 +10,23 @@ import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/24/solid";
import { MultiSelect as RMSC } from "react-multi-select-component";
import { classNames, COL_WIDTHS } from "@utils";
import { SettingsContext } from "@utils/Context";
import { DocsTooltip } from "@components/tooltips/DocsTooltip";
export interface MultiSelectOption {
value: string | number;
label: string;
key?: string;
disabled?: boolean;
value: string | number;
label: string;
key?: string;
disabled?: boolean;
}
interface MultiSelectProps {
name: string;
label?: string;
options: MultiSelectOption[];
columns?: COL_WIDTHS;
creatable?: boolean;
disabled?: boolean;
tooltip?: JSX.Element;
name: string;
label?: string;
options: MultiSelectOption[];
columns?: COL_WIDTHS;
creatable?: boolean;
disabled?: boolean;
tooltip?: JSX.Element;
}
export const MultiSelect = ({
@ -39,8 +38,6 @@ export const MultiSelect = ({
tooltip,
disabled
}: MultiSelectProps) => {
const settingsContext = SettingsContext.useValue();
const handleNewField = (value: string) => ({
value: value.toUpperCase(),
label: value.toUpperCase(),
@ -50,11 +47,12 @@ export const MultiSelect = ({
return (
<div
className={classNames(
columns ? `col-span-${columns}` : "col-span-12"
"col-span-12",
columns ? `sm:col-span-${columns}` : ""
)}
>
<label
htmlFor={label} className="flex mb-2 text-xs font-bold tracking-wide text-gray-700 uppercase dark:text-gray-200">
htmlFor={label} className="flex ml-px mb-1 text-xs font-bold tracking-wide text-gray-700 uppercase dark:text-gray-100">
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
@ -83,7 +81,6 @@ export const MultiSelect = ({
setFieldValue(field.name, am);
}}
className={settingsContext.darkTheme ? "dark" : ""}
/>
)}
</Field>
@ -93,8 +90,8 @@ export const MultiSelect = ({
interface IndexerMultiSelectOption {
id: number;
name: string;
id: number;
name: string;
}
export const IndexerMultiSelect = ({
@ -102,55 +99,52 @@ export const IndexerMultiSelect = ({
label,
options,
columns
}: MultiSelectProps) => {
const settingsContext = SettingsContext.useValue();
return (
<div
className={classNames(
columns ? `col-span-${columns}` : "col-span-12"
)}
}: MultiSelectProps) => (
<div
className={classNames(
"col-span-12",
columns ? `sm:col-span-${columns}` : ""
)}
>
<label
className="block ml-px mb-1 text-xs font-bold tracking-wide text-gray-700 uppercase dark:text-gray-200"
htmlFor={label}
>
<label
className="block mb-2 text-xs font-bold tracking-wide text-gray-700 uppercase dark:text-gray-200"
htmlFor={label}
>
{label}
</label>
{label}
</label>
<Field name={name} type="select" multiple={true}>
{({
field,
meta,
form: { setFieldValue }
}: FieldProps) => (
<>
<RMSC
{...field}
options={options}
labelledBy={name}
value={field.value && field.value.map((item: IndexerMultiSelectOption) => ({
value: item.id, label: item.name
}))}
onChange={(values: MultiSelectOption[]) => {
const item = values && values.map((i) => ({ id: i.value, name: i.label }));
setFieldValue(field.name, item);
}}
className={settingsContext.darkTheme ? "dark" : ""}
/>
{meta.touched && meta.error && (
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
)}
</>
)}
</Field>
</div>
);
};
<Field name={name} type="select" multiple={true}>
{({
field,
meta,
form: { setFieldValue }
}: FieldProps) => (
<>
<RMSC
{...field}
options={options}
labelledBy={name}
value={field.value && field.value.map((item: IndexerMultiSelectOption) => ({
value: item.id, label: item.name
}))}
onChange={(values: MultiSelectOption[]) => {
const item = values && values.map((i) => ({ id: i.value, name: i.label }));
setFieldValue(field.name, item);
}}
/>
{meta.touched && meta.error && (
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
)}
</>
)}
</Field>
</div>
);
interface DownloadClientSelectProps {
name: string;
action: Action;
clients: DownloadClient[];
name: string;
action: Action;
clients: DownloadClient[];
}
export function DownloadClientSelect({
@ -159,7 +153,7 @@ export function DownloadClientSelect({
clients
}: DownloadClientSelectProps) {
return (
<div className="col-span-6 sm:col-span-6">
<div className="col-span-12 sm:col-span-6">
<Field name={name} type="select">
{({
field,
@ -172,11 +166,11 @@ export function DownloadClientSelect({
>
{({ open }) => (
<>
<Listbox.Label className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
<Listbox.Label className="block text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">
Client
</Listbox.Label>
<div className="mt-2 relative">
<Listbox.Button className="bg-white dark:bg-gray-800 relative w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 dark:text-gray-200 sm:text-sm">
<div className="mt-1 relative">
<Listbox.Button className="block w-full shadow-sm sm:text-sm rounded-md border py-2 pl-3 pr-10 text-left focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-815 dark:text-gray-100">
<span className="block truncate">
{field.value
? clients.find((c) => c.id === field.value)?.name
@ -198,7 +192,7 @@ export function DownloadClientSelect({
>
<Listbox.Options
static
className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
className="absolute z-10 mt-1 w-full border border-gray-400 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg max-h-60 rounded-md py-1 text-base overflow-auto focus:outline-none sm:text-sm"
>
{clients
.filter((c) => c.type === action.type)
@ -207,7 +201,7 @@ export function DownloadClientSelect({
key={client.id}
className={({ active }) => classNames(
active
? "text-white dark:text-gray-100 bg-blue-600 dark:bg-gray-800"
? "text-white dark:text-gray-100 bg-blue-600 dark:bg-gray-950"
: "text-gray-900 dark:text-gray-300",
"cursor-default select-none relative py-2 pl-3 pr-9"
)}
@ -227,7 +221,7 @@ export function DownloadClientSelect({
{selected ? (
<span
className={classNames(
active ? "text-white dark:text-gray-100" : "text-blue-600 dark:text-gray-700",
active ? "text-white dark:text-gray-100" : "text-blue-600 dark:text-blue-500",
"absolute inset-y-0 right-0 flex items-center pr-4"
)}
>
@ -256,17 +250,17 @@ export function DownloadClientSelect({
}
export interface SelectFieldOption {
label: string;
value: string;
label: string;
value: string;
}
export interface SelectFieldProps {
name: string;
label: string;
optionDefaultText: string;
options: SelectFieldOption[];
columns?: COL_WIDTHS;
tooltip?: JSX.Element;
name: string;
label: string;
optionDefaultText: string;
options: SelectFieldOption[];
columns?: COL_WIDTHS;
tooltip?: JSX.Element;
}
export const Select = ({
@ -275,12 +269,13 @@ export const Select = ({
tooltip,
optionDefaultText,
options,
columns
columns = 6
}: SelectFieldProps) => {
return (
<div
className={classNames(
columns ? `col-span-${columns}` : "col-span-6"
"col-span-12",
columns ? `sm:col-span-${columns}` : ""
)}
>
<Field name={name} type="select">
@ -289,20 +284,21 @@ export const Select = ({
form: { setFieldValue }
}: FieldProps) => (
<Listbox
value={field.value}
onChange={(value) => setFieldValue(field?.name, value)}
// ?? null is required here otherwise React throws:
// "console.js:213 A component is changing from uncontrolled to controlled.
// This may be caused by the value changing from undefined to a defined value, which should not happen."
value={field.value ?? null}
onChange={(value) => setFieldValue(field.name, value)}
>
{({ open }) => (
<>
<Listbox.Label className="flex float-left mb-2 text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</div>
<Listbox.Label className="flex text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</Listbox.Label>
<div className="mt-2 relative">
<Listbox.Button className="bg-white dark:bg-gray-800 relative w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm pl-3 pr-10 py-2.5 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 dark:text-gray-200 sm:text-sm">
<div className="mt-1 relative">
<Listbox.Button className="block w-full relative shadow-sm sm:text-sm text-left rounded-md border pl-3 pr-10 py-2.5 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-815 dark:text-gray-100">
<span className="block truncate">
{field.value
? options.find((c) => c.value === field.value)?.label
@ -326,45 +322,39 @@ export const Select = ({
>
<Listbox.Options
static
className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
className="absolute z-10 mt-1 w-full shadow-lg max-h-60 rounded-md py-1 text-base overflow-auto border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-815 dark:text-gray-100 focus:outline-none sm:text-sm"
>
{options.map((opt) => (
<Listbox.Option
key={opt.value}
className={({ active }) =>
className={({ active: hovered, selected }) =>
classNames(
active
? "text-white dark:text-gray-100 bg-blue-600 dark:bg-gray-800"
: "text-gray-900 dark:text-gray-300",
"cursor-default select-none relative py-2 pl-3 pr-9"
selected
? "font-bold text-black dark:text-white bg-gray-300 dark:bg-gray-950"
: (
hovered
? "text-black dark:text-gray-100 font-normal"
: "text-gray-700 dark:text-gray-300 font-normal"
),
hovered ? "bg-gray-200 dark:bg-gray-800" : "",
"transition-colors cursor-default select-none relative py-2 pl-3 pr-9"
)
}
value={opt.value}
>
{({ selected, active }) => (
{({ selected }) => (
<>
<span
className={classNames(
selected ? "font-semibold" : "font-normal",
"block truncate"
)}
>
<span className="block truncate">
{opt.label}
</span>
{selected ? (
<span
className={classNames(
active ? "text-white dark:text-gray-100" : "text-blue-600 dark:text-gray-700",
"absolute inset-y-0 right-0 flex items-center pr-4"
)}
>
<CheckIcon
className="h-5 w-5"
aria-hidden="true"
/>
</span>
) : null}
<span
className={classNames(
selected ? "visible" : "invisible",
"absolute inset-y-0 right-0 flex items-center pr-4"
)}
>
<CheckIcon className="h-5 w-5 text-blue-600 dark:text-blue-500" aria-hidden="true" />
</span>
</>
)}
</Listbox.Option>

View file

@ -3,11 +3,13 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import type { FieldProps } from "formik";
import { Field } from "formik";
import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select";
import { OptionBasicTyped } from "@domain/constants";
import Select from "react-select";
import CreatableSelect from "react-select/creatable";
import type { FieldProps } from "formik";
import { OptionBasicTyped } from "@domain/constants";
import * as common from "@components/inputs/common";
import { DocsTooltip } from "@components/tooltips/DocsTooltip";
interface SelectFieldProps<T> {
@ -26,7 +28,7 @@ export function SelectFieldCreatable<T>({ name, label, help, placeholder, toolti
<div>
<label
htmlFor={name}
className="block text-sm font-medium text-gray-900 dark:text-white sm:pt-2"
className="block ml-px text-sm font-medium text-gray-900 dark:text-white sm:pt-2"
>
<div className="flex">
{tooltip ? (
@ -47,10 +49,12 @@ export function SelectFieldCreatable<T>({ name, label, help, placeholder, toolti
isClearable={true}
isSearchable={true}
components={{
Input,
Control,
Menu,
Option
Input: common.SelectInput,
Control: common.SelectControl,
Menu: common.SelectMenu,
Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}}
placeholder={placeholder ?? "Choose an option"}
styles={{
@ -89,54 +93,13 @@ export function SelectFieldCreatable<T>({ name, label, help, placeholder, toolti
);
}
const Input = (props: InputProps) => {
return (
<components.Input
{...props}
inputClassName="outline-none border-none shadow-none focus:ring-transparent"
className="text-gray-400 dark:text-gray-100"
children={props.children}
/>
);
};
const Control = (props: ControlProps) => {
return (
<components.Control
{...props}
className="p-1 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
children={props.children}
/>
);
};
const Menu = (props: MenuProps) => {
return (
<components.Menu
{...props}
className="dark:bg-gray-800 border border-gray-300 dark:border-gray-700 dark:text-gray-400 rounded-md shadow-sm"
children={props.children}
/>
);
};
const Option = (props: OptionProps) => {
return (
<components.Option
{...props}
className="dark:text-gray-400 dark:bg-gray-800 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
children={props.children}
/>
);
};
export function SelectField<T>({ name, label, help, placeholder, options }: SelectFieldProps<T>) {
return (
<div className="space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div>
<label
htmlFor={name}
className="block text-sm font-medium text-gray-900 dark:text-white sm:pt-2"
className="block ml-px text-sm font-medium text-gray-900 dark:text-white sm:pt-2"
>
{label}
</label>
@ -151,10 +114,12 @@ export function SelectField<T>({ name, label, help, placeholder, options }: Sele
{...field}
id={name}
components={{
Input,
Control,
Menu,
Option
Input: common.SelectInput,
Control: common.SelectControl,
Menu: common.SelectMenu,
Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}}
placeholder={placeholder ?? "Choose an option"}
styles={{
@ -199,7 +164,7 @@ export function SelectFieldBasic<T>({ name, label, help, placeholder, tooltip, d
<div>
<label
htmlFor={name}
className="block text-sm font-medium text-gray-900 dark:text-white sm:pt-2"
className="block ml-px text-sm font-medium text-gray-900 dark:text-white sm:pt-2"
>
<div className="flex">
{tooltip ? (
@ -218,10 +183,12 @@ export function SelectFieldBasic<T>({ name, label, help, placeholder, tooltip, d
{...field}
id={name}
components={{
Input,
Control,
Menu,
Option
Input: common.SelectInput,
Control: common.SelectControl,
Menu: common.SelectMenu,
Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}}
placeholder={placeholder ?? "Choose an option"}
styles={{

View file

@ -3,80 +3,22 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import React from "react";
import type { FieldInputProps, FieldMetaProps, FieldProps, FormikProps, FormikValues } from "formik";
import type { FieldProps } from "formik";
import { Field } from "formik";
import { Switch as HeadlessSwitch } from "@headlessui/react";
import { classNames } from "@utils";
import { DocsTooltip } from "@components/tooltips/DocsTooltip";
type SwitchProps<V = unknown> = {
label?: string
checked: boolean
value: boolean
disabled?: boolean
onChange: (value: boolean) => void
field?: FieldInputProps<V>
form?: FormikProps<FormikValues>
meta?: FieldMetaProps<V>
children: React.ReactNode
className: string
};
export const Switch = ({
label,
checked: $checked,
disabled = false,
onChange: $onChange,
field,
form
}: SwitchProps) => {
const checked = field?.checked ?? $checked;
return (
<HeadlessSwitch.Group as="div" className="flex items-center space-x-4">
<HeadlessSwitch.Label>{label}</HeadlessSwitch.Label>
<HeadlessSwitch
as="button"
name={field?.name}
disabled={disabled}
checked={checked}
onChange={value => {
form?.setFieldValue(field?.name ?? "", value);
$onChange && $onChange(value);
}}
className={classNames(
checked ? "bg-blue-500" : "bg-gray-200 dark:bg-gray-600",
"ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
{({ checked }) => (
<span
aria-hidden="true"
className={classNames(
checked ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
)}
</HeadlessSwitch>
</HeadlessSwitch.Group>
);
};
export type SwitchFormikProps = SwitchProps & FieldProps & React.InputHTMLAttributes<HTMLInputElement>;
export const SwitchFormik = (props: SwitchProps) => <Switch {...props} children={props.children}/>;
import { Checkbox } from "@components/Checkbox";
interface SwitchGroupProps {
name: string;
label?: string;
description?: string;
className?: string;
heading?: boolean;
tooltip?: JSX.Element;
name: string;
label?: string;
description?: string | React.ReactNode;
heading?: boolean;
tooltip?: JSX.Element;
disabled?: boolean;
className?: string;
}
const SwitchGroup = ({
@ -84,15 +26,23 @@ const SwitchGroup = ({
label,
description,
tooltip,
heading
heading,
disabled,
className
}: SwitchGroupProps) => (
<HeadlessSwitch.Group as="ol" className="py-4 flex items-center justify-between">
<HeadlessSwitch.Group
as="div"
className={classNames(
className ?? "py-2",
"flex items-center justify-between"
)}
>
{label && <div className="flex flex-col">
<HeadlessSwitch.Label
passive
as={heading ? "h2" : "span"}
className={classNames(
"flex float-left cursor-default mb-2 text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide",
"flex float-left ml-px cursor-default text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide",
heading ? "text-lg" : "text-sm"
)}
>
@ -103,7 +53,7 @@ const SwitchGroup = ({
</div>
</HeadlessSwitch.Label>
{description && (
<HeadlessSwitch.Description className="text-sm mt-1 text-gray-500 dark:text-gray-400">
<HeadlessSwitch.Description as="span" className="text-sm mt-1 pr-4 text-gray-500 dark:text-gray-400">
{description}
</HeadlessSwitch.Description>
)}
@ -115,28 +65,15 @@ const SwitchGroup = ({
field,
form: { setFieldValue }
}: FieldProps) => (
<Switch
<Checkbox
{...field}
// type="button"
value={field.value}
checked={field.checked ?? false}
onChange={value => {
className=""
value={!!field.checked}
setValue={(value) => {
setFieldValue(field?.name ?? "", value);
}}
className={classNames(
field.value ? "bg-blue-500" : "bg-gray-200",
"ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
<span
aria-hidden="true"
className={classNames(
field.value ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
disabled={disabled}
/>
)}
</Field>
</HeadlessSwitch.Group>

View file

@ -112,7 +112,7 @@ export const TextInput = <TFormValues extends Record<string, unknown>>({
)}
>
{props.label && (
<label htmlFor={name} className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
<label htmlFor={name} className="block ml-px text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">
{props.label}
</label>
)}
@ -121,8 +121,10 @@ export const TextInput = <TFormValues extends Record<string, unknown>>({
name={name}
aria-invalid={hasError}
className={classNames(
"mt-2 block w-full dark:bg-gray-800 dark:text-gray-100 rounded-md",
hasError ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700"
"block mt-1 w-full shadow-sm sm:text-sm rounded-md py-2.5 bg-gray-100 dark:bg-gray-850 dark:text-gray-100",
hasError
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500"
)}
{...props}
{...(register && register(name, rules))}
@ -162,7 +164,7 @@ export const PasswordInput = <TFormValues extends Record<string, unknown>>({
)}
>
{props.label && (
<label htmlFor={name} className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
<label htmlFor={name} className="block ml-px text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">
{props.label}
</label>
)}
@ -173,8 +175,10 @@ export const PasswordInput = <TFormValues extends Record<string, unknown>>({
aria-invalid={hasError}
type={isVisible ? "text" : "password"}
className={classNames(
"mt-2 block w-full dark:bg-gray-800 dark:text-gray-100 rounded-md",
hasError ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700"
"block mt-1 w-full shadow-sm sm:text-sm rounded-md border py-2.5 bg-gray-100 dark:bg-gray-850 dark:text-gray-100",
hasError
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500"
)}
{...props}
{...(register && register(name, rules))}

View file

@ -15,9 +15,12 @@ type Props = {
};
const Toast: FC<Props> = ({ type, body, t }) => (
<div className={classNames(
t?.visible ? "animate-enter" : "animate-leave",
"max-w-sm w-full bg-white dark:bg-gray-800 shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden transition-all")}>
<div
className={classNames(
t?.visible ? "animate-enter" : "animate-leave",
"max-w-sm w-full bg-white dark:bg-gray-800 whitespace-pre-wrap shadow-2xl rounded-lg pointer-events-auto border border-gray-250 dark:border-gray-775 overflow-hidden transition-all"
)}
>
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
@ -51,4 +54,4 @@ const Toast: FC<Props> = ({ type, body, t }) => (
</div>
);
export default Toast;
export default Toast;

View file

@ -96,7 +96,7 @@ function SlideOver<DataType extends FormikValues>({
>
{({ handleSubmit, values }) => (
<Form
className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-scroll"
className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto"
onSubmit={(e) => {
e.preventDefault();
handleSubmit(e);

View file

@ -37,8 +37,9 @@ export const Tooltip = ({
visible
} = usePopperTooltip({
trigger: requiresClick ? ["click"] : ["click", "hover"],
interactive: !requiresClick,
delayHide: 200
interactive: true,
delayHide: 200,
placement: "right"
});
if (!children || Array.isArray(children) && !children.length) {

View file

@ -290,21 +290,6 @@ export const DownloadClientTypeOptions: RadioFieldsetOption[] = [
}
];
export const DownloadClientTypeNameMap: Record<DownloadClientType | string, string> = {
"DELUGE_V1": "Deluge v1",
"DELUGE_V2": "Deluge v2",
"QBITTORRENT": "qBittorrent",
"RTORRENT": "rTorrent",
"TRANSMISSION": "Transmission",
"PORLA": "Porla",
"RADARR": "Radarr",
"SONARR": "Sonarr",
"LIDARR": "Lidarr",
"WHISPARR": "Whisparr",
"READARR": "Readarr",
"SABNZBD": "SABnzbd"
};
export const ActionTypeOptions: RadioFieldsetOption[] = [
{ label: "Test", description: "A simple action to test a filter.", value: "TEST" },
{ label: "Watch dir", description: "Add filtered torrents to a watch directory", value: "WATCH_FOLDER" },
@ -324,7 +309,7 @@ export const ActionTypeOptions: RadioFieldsetOption[] = [
{ label: "SABnzbd", description: "Add to SABnzbd", value: "SABNZBD" }
];
export const ActionTypeNameMap = {
export const ActionTypeNameMap: Record<ActionType, string> = {
"TEST": "Test",
"WATCH_FOLDER": "Watch folder",
"WEBHOOK": "Webhook",
@ -341,7 +326,22 @@ export const ActionTypeNameMap = {
"WHISPARR": "Whisparr",
"READARR": "Readarr",
"SABNZBD": "SABnzbd"
};
} as const;
export const DOWNLOAD_CLIENTS = [
"QBITTORRENT",
"DELUGE_V1",
"DELUGE_V2",
"RTORRENT",
"TRANSMISSION",
"PORLA",
"RADARR",
"SONARR",
"LIDARR",
"WHISPARR",
"READARR",
"SABNZBD"
];
export const ActionContentLayoutOptions: SelectGenericOption<ActionContentLayout>[] = [
{ label: "Original", description: "Original", value: "ORIGINAL" },
@ -528,12 +528,12 @@ export const tagsMatchLogicOptions: OptionBasic[] = [
export const ExternalFilterTypeOptions: RadioFieldsetOption[] = [
{ label: "Exec", description: "Run a custom command", value: "EXEC" },
{ label: "Webhook", description: "Run webhook", value: "WEBHOOK" },
{ label: "Webhook", description: "Run webhook", value: "WEBHOOK" }
];
export const ExternalFilterTypeNameMap = {
"EXEC": "Exec",
"WEBHOOK": "Webhook",
"WEBHOOK": "Webhook"
};
export const ExternalFilterWebhookMethodOptions: OptionBasicTyped<WebhookMethod>[] = [
@ -541,5 +541,5 @@ export const ExternalFilterWebhookMethodOptions: OptionBasicTyped<WebhookMethod>
{ label: "POST", value: "POST" },
{ label: "PUT", value: "PUT" },
{ label: "PATCH", value: "PATCH" },
{ label: "DELETE", value: "DELETE" },
{ label: "DELETE", value: "DELETE" }
];

View file

@ -81,14 +81,14 @@ export function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
validate={validate}
>
{({ values }) => (
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-scroll">
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
<div className="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">Create filter</Dialog.Title>
<p className="text-sm text-gray-500 dark:text-gray-400">
Add new filter.
Add new filter.
</p>
</div>
<div className="h-7 flex items-center">
@ -114,6 +114,7 @@ export function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
className="block text-sm font-medium text-gray-900 dark:text-white sm:mt-px sm:pt-2"
>
Name
<span className="text-red-500"> *</span>
</label>
</div>
<Field name="name">
@ -126,7 +127,7 @@ export function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
{...field}
id="name"
type="text"
className="block w-full shadow-sm dark:bg-gray-800 border-gray-300 dark:border-gray-700 sm:text-sm dark:text-white focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 rounded-md"
className="block w-full shadow-sm sm:text-sm rounded-md border py-2.5 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-815 dark:text-gray-100"
/>
{meta.touched && meta.error &&

View file

@ -70,7 +70,7 @@ export function APIKeyAddForm({ isOpen, toggle }: apiKeyAddFormProps) {
validate={validate}
>
{({ values }) => (
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-scroll">
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
<div className="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
<div className="flex items-start justify-between space-x-3">
@ -116,7 +116,7 @@ export function APIKeyAddForm({ isOpen, toggle }: apiKeyAddFormProps) {
{...field}
id="name"
type="text"
className="block w-full shadow-sm dark:bg-gray-800 border-gray-300 dark:border-gray-700 sm:text-sm dark:text-white focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 rounded-md"
className="block w-full shadow-sm sm:text-sm focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 rounded-md border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-815 dark:text-gray-100"
/>
{meta.touched && meta.error && <span className="block mt-2 text-red-500">{meta.error}</span>}
</div>

View file

@ -13,7 +13,7 @@ import { toast } from "react-hot-toast";
import { classNames, sleep } from "@utils";
import DEBUG from "@components/debug";
import { APIClient } from "@api/APIClient";
import {DownloadClientTypeOptions, DownloadRuleConditionOptions} from "@domain/constants";
import { DownloadClientTypeOptions, DownloadRuleConditionOptions } from "@domain/constants";
import Toast from "@components/notifications/Toast";
import { useToggle } from "@hooks/hooks";
import { DeleteModal } from "@components/modals";
@ -26,7 +26,7 @@ import {
} from "@components/inputs";
import { clientKeys } from "@screens/settings/DownloadClient";
import { DocsLink, ExternalLink } from "@components/ExternalLink";
import {SelectFieldBasic} from "@components/inputs/select_wide";
import { SelectFieldBasic } from "@components/inputs/select_wide";
interface InitialValuesSettings {
basic?: {
@ -474,11 +474,11 @@ function FormFieldsRulesQbit() {
{settings.rules?.ignore_slow_torrents === true && (
<>
<SelectFieldBasic
name="settings.rules.ignore_slow_torrents_condition"
label="Ignore condition"
placeholder="Select ignore condition"
options={DownloadRuleConditionOptions}
tooltip={<p>Choose whether to respect or ignore the <code className="text-blue-400">Max active downloads</code> setting before checking speed thresholds.</p>}
name="settings.rules.ignore_slow_torrents_condition"
label="Ignore condition"
placeholder="Select ignore condition"
options={DownloadRuleConditionOptions}
tooltip={<p>Choose whether to respect or ignore the <code className="text-blue-400">Max active downloads</code> setting before checking speed thresholds.</p>}
/>
<NumberFieldWide
name="settings.rules.download_speed_threshold"
@ -750,7 +750,7 @@ export function DownloadClientAddForm({ isOpen, toggle }: formProps) {
>
{({ handleSubmit, values }) => (
<Form
className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-scroll"
className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto"
onSubmit={handleSubmit}
>
<div className="flex-1">
@ -945,7 +945,7 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
{({ handleSubmit, values }) => {
return (
<Form
className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-scroll"
className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto"
onSubmit={handleSubmit}
>
<div className="flex-1">
@ -962,7 +962,7 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="bg-white dark:bg-gray-800 rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>

View file

@ -6,7 +6,7 @@
import { Fragment, useState } from "react";
import { toast } from "react-hot-toast";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select";
import Select from "react-select";
import type { FieldProps } from "formik";
import { Field, Form, Formik, FormikValues } from "formik";
import { XMarkIcon } from "@heroicons/react/24/solid";
@ -15,47 +15,15 @@ import { Dialog, Transition } from "@headlessui/react";
import { classNames, sleep } from "@utils";
import DEBUG from "@components/debug";
import { APIClient } from "@api/APIClient";
import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
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";
const Input = (props: InputProps) => (
<components.Input
{...props}
inputClassName="outline-none border-none shadow-none focus:ring-transparent"
className="text-gray-400 dark:text-gray-100"
children={props.children}
/>
);
const Control = (props: ControlProps) => (
<components.Control
{...props}
className="p-1 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
children={props.children}
/>
);
const Menu = (props: MenuProps) => (
<components.Menu
{...props}
className="dark:bg-gray-800 border border-gray-300 dark:border-gray-700 dark:text-gray-400 rounded-md shadow-sm cursor-pointer"
children={props.children}
/>
);
const Option = (props: OptionProps) => (
<components.Option
{...props}
className="dark:text-gray-400 dark:bg-gray-800 dark:hover:bg-gray-900 dark:focus:bg-gray-900 cursor-pointer"
children={props.children}
/>
);
import * as common from "@components/inputs/common";
// const isRequired = (message: string) => (value?: string | undefined) => (!!value ? undefined : message);
@ -73,51 +41,54 @@ function validateField(s: IndexerSetting) {
}
const IrcSettingFields = (ind: IndexerDefinition, indexer: string) => {
if (indexer !== "") {
return (
<Fragment>
{ind && ind.irc && ind.irc.settings && (
<div className="border-t border-gray-200 dark:border-gray-700 py-5">
<div className="px-4 space-y-1">
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">IRC</Dialog.Title>
<p className="text-sm text-gray-500 dark:text-gray-200">
Networks and channels are configured automatically in the background.
</p>
</div>
{ind.irc.settings.map((f: IndexerSetting, idx: number) => {
switch (f.type) {
case "text":
return (
<TextFieldWide
key={idx}
name={`irc.${f.name}`}
label={f.label}
required={f.required}
help={f.help}
autoComplete="off"
validate={validateField(f)}
tooltip={
<div>
<p>Please read our IRC guide if you are unfamiliar with IRC.</p>
<DocsLink href="https://autobrr.com/configuration/irc" />
</div>
}
/>
);
case "secret":
if (f.name === "invite_command") {
return <PasswordFieldWide defaultVisible name={`irc.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} validate={validateField(f)} />;
}
return <PasswordFieldWide name={`irc.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} validate={validateField(f)} />;
}
return null;
})}
</div>
)}
</Fragment>
);
if (!indexer.length) {
return null;
}
return (
<>
{ind && ind.irc && ind.irc.settings && (
<div className="border-t border-gray-200 dark:border-gray-700 py-5">
<div className="px-4 space-y-1">
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">IRC</Dialog.Title>
<p className="text-sm text-gray-500 dark:text-gray-200">
Networks and channels are configured automatically in the background.
</p>
</div>
{ind.irc.settings.map((f: IndexerSetting, idx: number) => {
switch (f.type) {
case "text":
return (
<TextFieldWide
key={idx}
name={`irc.${f.name}`}
label={f.label}
required={f.required}
help={f.help}
autoComplete="off"
validate={validateField(f)}
tooltip={
<div>
<p>Please read our IRC guide if you are unfamiliar with IRC.</p>
<DocsLink href="https://autobrr.com/configuration/irc" />
</div>
}
/>
);
case "secret":
if (f.name === "invite_command") {
return <PasswordFieldWide defaultVisible name={`irc.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} validate={validateField(f)} />;
}
return <PasswordFieldWide name={`irc.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} validate={validateField(f)} />;
}
return null;
})}
</div>
)}
</>
);
};
const TorznabFeedSettingFields = (ind: IndexerDefinition, indexer: string) => {
@ -137,10 +108,10 @@ const TorznabFeedSettingFields = (ind: IndexerDefinition, indexer: string) => {
{ind.torznab.settings.map((f: IndexerSetting, idx: number) => {
switch (f.type) {
case "text":
return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />;
case "secret":
return <PasswordFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} validate={validateField(f)} />;
case "text":
return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />;
case "secret":
return <PasswordFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} validate={validateField(f)} />;
}
return null;
})}
@ -176,10 +147,10 @@ const NewznabFeedSettingFields = (ind: IndexerDefinition, indexer: string) => {
{ind.newznab.settings.map((f: IndexerSetting, idx: number) => {
switch (f.type) {
case "text":
return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />;
case "secret":
return <PasswordFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} validate={validateField(f)} />;
case "text":
return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />;
case "secret":
return <PasswordFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} validate={validateField(f)} />;
}
return null;
})}
@ -207,10 +178,10 @@ const RSSFeedSettingFields = (ind: IndexerDefinition, indexer: string) => {
{ind.rss.settings.map((f: IndexerSetting, idx: number) => {
switch (f.type) {
case "text":
return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />;
case "secret":
return <PasswordFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} validate={validateField(f)} />;
case "text":
return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />;
case "secret":
return <PasswordFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} validate={validateField(f)} />;
}
return null;
})}
@ -235,28 +206,28 @@ const SettingFields = (ind: IndexerDefinition, indexer: string) => {
<div key="opt">
{ind && ind.settings && ind.settings.map((f, idx: number) => {
switch (f.type) {
case "text":
return (
<TextFieldWide name={`settings.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />
);
case "secret":
return (
<PasswordFieldWide
name={`settings.${f.name}`}
label={f.label}
required={f.required}
key={idx}
help={f.help}
validate={validateField(f)}
tooltip={
<div>
<p>This field does not take a full URL. Only use alphanumeric strings like <code>uqcdi67cibkx3an8cmdm</code>.</p>
<br />
<DocsLink href="https://autobrr.com/faqs#common-action-rejections" />
</div>
}
/>
);
case "text":
return (
<TextFieldWide name={`settings.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />
);
case "secret":
return (
<PasswordFieldWide
name={`settings.${f.name}`}
label={f.label}
required={f.required}
key={idx}
help={f.help}
validate={validateField(f)}
tooltip={
<div>
<p>This field does not take a full URL. Only use alphanumeric strings like <code>uqcdi67cibkx3an8cmdm</code>.</p>
<br />
<DocsLink href="https://autobrr.com/faqs#common-action-rejections" />
</div>
}
/>
);
}
return null;
})}
@ -468,7 +439,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
onSubmit={onSubmit}
>
{({ values }) => (
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-scroll">
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
<div className="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
<div className="flex items-start justify-between space-x-3">
@ -509,7 +480,14 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
<Select {...field}
isClearable={true}
isSearchable={true}
components={{ Input, Control, Menu, Option }}
components={{
Input: common.SelectInput,
Control: common.SelectControl,
Menu: common.SelectMenu,
Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}}
placeholder="Choose an indexer"
styles={{
singleValue: (base) => ({
@ -566,7 +544,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
name="base_url"
label="Base URL"
help="Override baseurl if it's blocked by your ISP."
options={indexer.urls.map(u => ({ value: u, label: u, key: u })) }
options={indexer.urls.map(u => ({ value: u, label: u, key: u }))}
/>
)}
@ -736,9 +714,9 @@ interface IndexerUpdateInitialValues {
}
interface UpdateProps {
isOpen: boolean;
toggle: () => void;
indexer: IndexerDefinition;
isOpen: boolean;
toggle: () => void;
indexer: IndexerDefinition;
}
export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
@ -852,7 +830,7 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
<input
type="text"
{...field}
className="block w-full shadow-sm dark:bg-gray-800 sm:text-sm dark:text-white focus:ring-blue-500 focus:border-blue-500 border-gray-300 dark:border-gray-700 rounded-md"
className="block w-full shadow-sm sm:text-sm focus:ring-blue-500 focus:border-blue-500 border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-815 dark:text-gray-100 rounded-md"
/>
{meta.touched && meta.error && <span>{meta.error}</span>}
</div>
@ -866,7 +844,7 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
name="base_url"
label="Base URL"
help="Override baseurl if it's blocked by your ISP."
options={indexer.urls.map(u => ({ value: u, label: u, key: u })) }
options={indexer.urls.map(u => ({ value: u, label: u, key: u }))}
/>
)}

View file

@ -10,56 +10,67 @@ import type { FieldProps } from "formik";
import type { FieldArrayRenderProps } from "formik";
import { Field, FieldArray, FormikErrors, FormikValues } from "formik";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select";
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 { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, SwitchGroupWideRed, TextFieldWide } from "@components/inputs";
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
import { SlideOver } from "@components/panels";
import Toast from "@components/notifications/Toast";
import * as common from "@components/inputs/common";
import { classNames } from "@utils";
interface ChannelsFieldArrayProps {
channels: IrcChannel[];
}
const ChannelsFieldArray = ({ channels }: ChannelsFieldArrayProps) => (
<div className="p-6">
<div className="px-4">
<FieldArray name="channels">
{({ remove, push }: FieldArrayRenderProps) => (
<div className="flex flex-col space-y-2 border-2 border-dashed dark:border-gray-700 p-4">
<div className="flex flex-col space-y-2">
{channels && channels.length > 0 ? (
channels.map((_channel: IrcChannel, index: number) => {
const isDisabled = channels[index].name === "#ptp-announce-dev";
channels.map((channel: IrcChannel, index) => {
const isDisabled = channel.name === "#ptp-announce-dev";
return (
<div key={index} className="flex justify-between">
<div className="flex">
<div key={index} className="flex justify-between border dark:border-gray-700 dark:bg-gray-815 p-2 rounded-md">
<div className="flex gap-2">
<Field name={`channels.${index}.name`}>
{({ field }: FieldProps) => (
{({ field, meta }: FieldProps) => (
<input
{...field}
type="text"
value={field.value ?? ""}
onChange={field.onChange}
placeholder="#Channel"
className={`mr-4 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-600 block w-full shadow-sm sm:text-sm rounded-md
${isDisabled ? "disabled dark:bg-gray-800 dark:text-gray-500" : "dark:bg-gray-700 dark:text-white"}`}
className={classNames(
meta.touched && meta.error
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
"block w-full shadow-sm sm:text-sm rounded-md border py-2.5",
isDisabled ? "disabled dark:bg-gray-700 dark:text-gray-400 cursor-not-allowed" : "bg-gray-100 dark:bg-gray-850 dark:text-gray-100"
)}
disabled={isDisabled}
/>
)}
</Field>
<Field name={`channels.${index}.password`}>
{({ field }: FieldProps) => (
{({ field, meta }: FieldProps) => (
<input
{...field}
type="text"
value={field.value ?? ""}
onChange={field.onChange}
placeholder="Password"
className={`mr-4 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-600 block w-full shadow-sm sm:text-sm rounded-md
${isDisabled ? "disabled dark:bg-gray-800 dark:text-gray-500" : "dark:bg-gray-700 dark:text-white"}`}
placeholder="Channel password"
className={classNames(
meta.touched && meta.error
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
"block w-full shadow-sm sm:text-sm rounded-md border py-2.5",
isDisabled ? "disabled dark:bg-gray-700 dark:text-white cursor-not-allowed" : "bg-gray-100 dark:bg-gray-850 dark:text-gray-100"
)}
disabled={isDisabled}
/>
)}
@ -68,8 +79,10 @@ const ChannelsFieldArray = ({ channels }: ChannelsFieldArrayProps) => (
<button
type="button"
className={`bg-white dark:bg-gray-700 rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-500
${isDisabled ? "disabled hidden" : ""}`}
className={classNames(
"bg-white dark:bg-gray-700 rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-500",
isDisabled ? "hidden" : ""
)}
onClick={() => remove(index)}
disabled={isDisabled}
>
@ -204,7 +217,16 @@ export function IrcNetworkAddForm({ isOpen, toggle }: AddFormProps) {
/>
<PasswordFieldWide name="invite_command" label="Invite command" />
<ChannelsFieldArray channels={values.channels} />
<div className="border-t border-gray-200 dark:border-gray-700 py-5">
<div className="px-4 space-y-1 mb-8">
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">Channels</Dialog.Title>
<p className="text-sm text-gray-500 dark:text-gray-400">
Channels to join.
</p>
</div>
<ChannelsFieldArray channels={values.channels} />
</div>
</div>
)}
</SlideOver>
@ -324,7 +346,7 @@ export function IrcNetworkUpdateForm({
required={true}
/>
<SwitchGroupWideRed name="enabled" label="Enabled" />
<SwitchGroupWide name="enabled" label="Enabled" />
<TextFieldWide
name="server"
label="Server"
@ -388,12 +410,20 @@ export function IrcNetworkUpdateForm({
label="Password"
help="NickServ / SASL password."
/>
</div>
<PasswordFieldWide name="invite_command" label="Invite command" />
<ChannelsFieldArray channels={values.channels} />
<div className="border-t border-gray-200 dark:border-gray-700 py-5">
<div className="px-4 space-y-1 mb-8">
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">Channels</Dialog.Title>
<p className="text-sm text-gray-500 dark:text-gray-400">
Channels are added when you setup IRC indexers. Do not edit unless you know what you are doing.
</p>
</div>
<ChannelsFieldArray channels={values.channels} />
</div>
</div>
)}
</SlideOver>
@ -429,10 +459,12 @@ function SelectField<T>({ name, label, options }: SelectFieldProps<T>) {
isClearable={true}
isSearchable={true}
components={{
Input,
Control,
Menu,
Option
Input: common.SelectInput,
Control: common.SelectControl,
Menu: common.SelectMenu,
Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}}
placeholder="Choose a type"
styles={{
@ -468,44 +500,3 @@ function SelectField<T>({ name, label, options }: SelectFieldProps<T>) {
</div>
);
}
const Input = (props: InputProps) => {
return (
<components.Input
{...props}
inputClassName="outline-none border-none shadow-none focus:ring-transparent"
className="text-gray-400 dark:text-gray-100"
children={props.children}
/>
);
};
const Control = (props: ControlProps) => {
return (
<components.Control
{...props}
className="p-1 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
children={props.children}
/>
);
};
const Menu = (props: MenuProps) => {
return (
<components.Menu
{...props}
className="dark:bg-gray-800 border border-gray-300 dark:border-gray-700 dark:text-gray-400 rounded-md shadow-sm"
children={props.children}
/>
);
};
const Option = (props: OptionProps) => {
return (
<components.Option
{...props}
className="dark:text-gray-400 dark:bg-gray-800 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
children={props.children}
/>
);
};

View file

@ -8,60 +8,21 @@ import { Fragment } from "react";
import type { FieldProps } from "formik";
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
import { XMarkIcon } from "@heroicons/react/24/solid";
import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select";
import Select from "react-select";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
import DEBUG from "@components/debug";
import { EventOptions, NotificationTypeOptions, SelectOption } from "@domain/constants";
import { APIClient } from "@api/APIClient";
import Toast from "@components/notifications/Toast";
import { SlideOver } from "@components/panels";
import { componentMapType } from "./DownloadClientForms";
import { notificationKeys } from "@screens/settings/Notifications";
import { EventOptions, NotificationTypeOptions, SelectOption } from "@domain/constants";
import DEBUG from "@components/debug";
import { SlideOver } from "@components/panels";
import { ExternalLink } from "@components/ExternalLink";
import Toast from "@components/notifications/Toast";
import * as common from "@components/inputs/common";
import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
const Input = (props: InputProps) => {
return (
<components.Input
{...props}
inputClassName="outline-none border-none shadow-none focus:ring-transparent"
className="text-gray-400 dark:text-gray-100"
children={props.children}
/>
);
};
const Control = (props: ControlProps) => {
return (
<components.Control
{...props}
className="p-1 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
children={props.children}
/>
);
};
const Menu = (props: MenuProps) => {
return (
<components.Menu
{...props}
className="dark:bg-gray-800 border border-gray-300 dark:border-gray-700 dark:text-gray-400 rounded-md shadow-sm"
children={props.children}
/>
);
};
const Option = (props: OptionProps) => {
return (
<components.Option
{...props}
className="dark:text-gray-400 dark:bg-gray-800 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
children={props.children}
/>
);
};
import { componentMapType } from "./DownloadClientForms";
function FormFieldsDiscord() {
return (
@ -295,7 +256,7 @@ export function NotificationAddForm({ isOpen, toggle }: AddProps) {
validate={validate}
>
{({ values }) => (
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-scroll">
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
<div className="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
<div className="flex items-start justify-between space-x-3">
@ -347,10 +308,12 @@ export function NotificationAddForm({ isOpen, toggle }: AddProps) {
isClearable={true}
isSearchable={true}
components={{
Input,
Control,
Menu,
Option
Input: common.SelectInput,
Control: common.SelectControl,
Menu: common.SelectMenu,
Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}}
placeholder="Choose a type"
styles={{
@ -574,8 +537,14 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
<Select {...field}
isClearable={true}
isSearchable={true}
components={{ Input, Control, Menu, Option }}
components={{
Input: common.SelectInput,
Control: common.SelectControl,
Menu: common.SelectMenu,
Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}}
placeholder="Choose a type"
styles={{
singleValue: (base) => ({

File diff suppressed because one or more lines are too long

View file

@ -7,7 +7,7 @@ import { Stats } from "./dashboard/Stats";
import { ActivityTable } from "./dashboard/ActivityTable";
export const Dashboard = () => (
<div className="my-6 max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
<div className="my-6 max-w-screen-xl mx-auto pb-6 px-2 sm:px-6 lg:pb-16 lg:px-8">
<Stats />
<ActivityTable />
</div>

View file

@ -4,25 +4,24 @@
*/
import { Fragment, useEffect, useRef, useState } from "react";
import format from "date-fns/format";
import { useQuery } from "@tanstack/react-query";
import { Menu, Transition } from "@headlessui/react";
import { DebounceInput } from "react-debounce-input";
import {
Cog6ToothIcon,
DocumentArrowDownIcon
} from "@heroicons/react/24/outline";
import { useQuery } from "@tanstack/react-query";
import { Menu, Transition } from "@headlessui/react";
import { ExclamationCircleIcon } from "@heroicons/react/24/solid";
import format from "date-fns/format";
import { toast } from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { Checkbox } from "@components/Checkbox";
import { classNames, simplifyDate } from "@utils";
import { baseUrl, classNames, simplifyDate } from "@utils";
import { SettingsContext } from "@utils/Context";
import { EmptySimple } from "@components/emptystates";
import { baseUrl } from "@utils";
import { RingResizeSpinner } from "@components/Icons";
import { toast } from "react-hot-toast";
import Toast from "@components/notifications/Toast";
import { ExclamationCircleIcon } from "@heroicons/react/24/solid";
type LogEvent = {
@ -40,7 +39,7 @@ const LogColors: Record<LogLevel, string> = {
"ERR": "text-red-500",
"WRN": "text-yellow-500",
"FTL": "text-red-500",
"PNC": "text-red-600",
"PNC": "text-red-600"
};
export const Logs = () => {
@ -107,7 +106,7 @@ export const Logs = () => {
</div>
<div className="max-w-screen-xl mx-auto pb-12 px-2 sm:px-4 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg px-2 sm:px-4 pt-3 sm:pt-4 pb-3 sm:pb-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-250 dark:border-gray-775 px-2 sm:px-4 pt-3 sm:pt-4 pb-3 sm:pb-4">
<div className="flex relative mb-3">
<DebounceInput
minLength={2}
@ -165,7 +164,7 @@ export const Logs = () => {
</div>
<div className="max-w-screen-xl mx-auto pb-10 px-2 sm:px-4 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg px-4 sm:px-6 pt-3 sm:pt-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-250 dark:border-gray-775 px-4 sm:px-6 pt-3 sm:pt-4">
<LogFiles />
</div>
</div>
@ -186,30 +185,28 @@ export const LogFiles = () => {
return (
<div>
<div className="mt-2">
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Log files</h2>
<h2 className="text-lg leading-4 font-bold text-gray-900 dark:text-white">Log files</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Download old log files.
</p>
</div>
{data && data.files.length > 0 ? (
<section className="py-3 light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md">
<ol className="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">
Name
</div>
<div className="col-span-8 sm:col-span-4 px-1 sm:px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Last modified
</div>
<div className="col-span-3 sm:col-span-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Size
</div>
</li>
<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">
Name
</div>
<div className="col-span-8 sm:col-span-4 px-1 sm:px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Last modified
</div>
<div className="col-span-3 sm:col-span-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Size
</div>
</li>
{data && data.files.map((f, idx) => <LogFilesItem key={idx} file={f} />)}
</ol>
</section>
{data.files.map((f, idx) => <LogFilesItem key={idx} file={f} />)}
</ul>
) : (
<EmptySimple
title="No old log files"

View file

@ -22,7 +22,7 @@ export const Releases = () => {
<div className="mt-6 mb-4 mx-auto flex flex-col max-w-screen-xl px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-black dark:text-white">Releases</h1>
<p className="flex mt-1 text-sm text-gray-800 dark:text-gray-200">
<p className="flex flex-wrap mt-1 text-sm text-gray-800 dark:text-gray-200">
The search engine uses a special pattern-matching engine to filter out results.
Please
<button
@ -30,7 +30,7 @@ export const Releases = () => {
e.preventDefault();
setIsHintOpen((state) => !state);
}}
className="flex items-center shadow-md border rounded-md mx-1 px-1 text-black bg-lime-100 hover:bg-lime-200 border-lime-500 dark:text-white dark:bg-lime-950 dark:hover:bg-lime-900 dark:border-lime-800"
className="flex items-center shadow-md border rounded-md mx-1 px-1 transition text-black bg-lime-100 hover:bg-lime-200 border-lime-500 dark:text-white dark:bg-lime-950 dark:hover:bg-lime-900 dark:border-lime-800"
>
click here
{isHintOpen ? (
@ -46,10 +46,10 @@ export const Releases = () => {
<div className="flex justify-between items-center text-base font-medium pl-2 py-1 border-b border-gray-300 bg-gray-100 dark:border-gray-700 dark:bg-gray-800 rounded-t-md">
Search tips
</div>
<div className="rounded-t-md py-1 px-2 rounded-b-md bg-white dark:bg-gray-900">
<div className="py-1 px-2 rounded-b-md bg-white dark:bg-gray-900">
You can use <b>2</b> special <span className="underline decoration-2 underline-offset-2 decoration-amber-500">wildcard characters</span> for the purpose of pattern matching.
<br />
- Percent (<Code>%</Code>) - for matching any <i>sequence</i> of characters (equivalent to <Code>*</Code> in Regex)
- Percent (<Code>%</Code>) - for matching any <i>sequence</i> of characters (equivalent to <Code>.*</Code> in Regex)
<br />
- Underscore (<Code>_</Code>) - for matching any <i>single</i> character (equivalent to <Code>.</Code> in Regex)
<br /><br />
@ -88,9 +88,9 @@ export const Releases = () => {
</div>
) : null}
</div>
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
<div className="max-w-screen-xl mx-auto pb-6 px-2 sm:px-6 lg:pb-16 lg:px-8">
<ReleaseTable />
</div>
</main>
);
}
};

View file

@ -54,14 +54,15 @@ function SubNavLink({ item }: NavLinkProps) {
to={item.href}
end
className={({ isActive }) => classNames(
"border-transparent text-gray-900 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-gray-300 group border-l-4 px-3 py-2 flex items-center text-sm font-medium",
isActive ?
"font-bold bg-blue-50 dark:bg-gray-700 border-sky-500 dark:border-blue-500 text-sky-700 dark:text-white hover:bg-blue-100 dark:hover:bg-gray-500 hover:text-sky-700 dark:hover:text-gray-200" : ""
"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}
>
<item.icon
className="text-gray-400 group-hover:text-gray-500 dark:group-hover:text-gray-300 flex-shrink-0 -ml-1 mr-3 h-6 w-6"
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>
@ -92,9 +93,9 @@ export function Settings() {
<h1 className="text-3xl font-bold text-black dark:text-white">Settings</h1>
</div>
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg">
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<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">
<SidebarNav subNavigation={subNavigation}/>
<Suspense
fallback={

View file

@ -9,6 +9,8 @@ import { useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
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";
@ -63,60 +65,57 @@ export const Login = () => {
const onSubmit = (data: LoginFormFields) => loginMutation.mutate(data);
return (
<div className="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md mb-6">
<div className="min-h-screen flex flex-col justify-center px-3">
<div className="mx-auto w-full max-w-md mb-6">
<Logo className="mx-auto h-12" />
<h1 className="text-center text-gray-900 dark:text-gray-200 font-bold pt-2 text-2xl">
autobrr
</h1>
</div>
<div className="sm:mx-auto sm:w-full sm:max-w-md shadow-lg">
<div className="bg-white dark:bg-gray-800 py-10 px-4 sm:rounded-lg sm:px-10">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-6">
<TextInput<LoginFormFields>
name="username"
id="username"
label="username"
type="text"
register={register}
rules={{ required: "Username is required" }}
errors={formState.errors}
autoComplete="username"
/>
<PasswordInput<LoginFormFields>
name="password"
id="password"
label="password"
register={register}
rules={{ required: "Password is required" }}
errors={formState.errors}
autoComplete="current-password"
/>
</div>
<div className="mt-6">
<button
type="submit"
className="w-full 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"
>
Sign in
</button>
<div>
<span className="flex float-right items-center mt-3 text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide cursor-pointer" id="forgot">
<Tooltip
label={
<div className="flex flex-row items-center">
Forgot? <svg className="ml-1 w-3 h-3 text-gray-500 dark:text-gray-400 fill-current" viewBox="0 0 72 72"><path d="M32 2C15.432 2 2 15.432 2 32s13.432 30 30 30s30-13.432 30-30S48.568 2 32 2m5 49.75H27v-24h10v24m-5-29.5a5 5 0 1 1 0-10a5 5 0 0 1 0 10"/></svg>
</div>
}
>
<p className="py-1">If you forget your password you can reset it via the terminal: <code>autobrrctl --config /home/username/.config/autobrr change-password $USERNAME</code></p>
</Tooltip>
</span>
</div>
</div>
<div className="mx-auto w-full max-w-md rounded-2xl shadow-lg">
<div className="px-8 pt-8 pb-4 rounded-2xl bg-white dark:bg-gray-800 border border-gray-150 dark:border-gray-775">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<TextInput<LoginFormFields>
name="username"
id="username"
label="username"
type="text"
register={register}
rules={{ required: "Username is required" }}
errors={formState.errors}
autoComplete="username"
/>
<PasswordInput<LoginFormFields>
name="password"
id="password"
label="password"
register={register}
rules={{ required: "Password is required" }}
errors={formState.errors}
autoComplete="current-password"
/>
<button
type="submit"
className="w-full flex items-center 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"
>
<RocketLaunchIcon className="w-4 h-4 mr-1.5" />
Sign in
</button>
</form>
<div
id="forgot"
className="flex mt-2 justify-end items-center text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide"
>
<Tooltip
label={
<div className="flex flex-row items-center cursor-pointer">
Forgot? <svg className="ml-1 w-3 h-3 text-gray-500 dark:text-gray-400 fill-current" viewBox="0 0 72 72"><path d="M32 2C15.432 2 2 15.432 2 32s13.432 30 30 30s30-13.432 30-30S48.568 2 32 2m5 49.75H27v-24h10v24m-5-29.5a5 5 0 1 1 0-10a5 5 0 0 1 0 10" /></svg>
</div>
}
>
<p className="py-1">If you forget your password you can reset it via the terminal: <code>autobrrctl --config /home/username/.config/autobrr change-password $USERNAME</code></p>
</Tooltip>
</div>
</div>
</div>
</div>

View file

@ -10,6 +10,8 @@ import { useNavigate } from "react-router-dom";
import { APIClient } from "@api/APIClient";
import { TextField, PasswordField } from "@components/inputs";
import { UserPlusIcon } from "@heroicons/react/24/outline";
import Logo from "@app/logo.svg?react";
interface InputValues {
@ -52,8 +54,8 @@ export const Onboarding = () => {
autobrr
</h1>
</div>
<div className="sm:mx-auto sm:w-full sm:max-w-md shadow-lg">
<div className="bg-white dark:bg-gray-800 py-8 px-4 sm:rounded-lg sm:px-10">
<div className="mx-auto w-full max-w-md rounded-2xl shadow-lg">
<div className="px-8 pt-8 pb-6 rounded-2xl bg-white dark:bg-gray-800 border border-gray-150 dark:border-gray-775">
<Formik
initialValues={{
username: "",
@ -69,14 +71,13 @@ export const Onboarding = () => {
<PasswordField name="password1" label="Password" columns={6} autoComplete="current-password" />
<PasswordField name="password2" label="Confirm password" columns={6} autoComplete="current-password" />
</div>
<div className="mt-6">
<button
type="submit"
className="w-full 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"
>
Create account
</button>
</div>
<button
type="submit"
className="mt-6 w-full flex items-center justify-center py-2 px-4 border border-transparent transition 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"
>
<UserPlusIcon className="w-4 h-4 mr-1.5" />
Create account
</button>
</Form>
</Formik>
</div>

View file

@ -85,9 +85,9 @@ function Table({ columns, data }: TableProps) {
// Render the UI for your table
return (
<div className="inline-block min-w-full mt-4 mb-2 align-middle">
<div className="bg-white shadow-lg dark:bg-gray-800 rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<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();
return (
@ -100,7 +100,7 @@ function Table({ columns, data }: TableProps) {
<th
key={`${rowKey}-${columnKey}`}
scope="col"
className="first:pl-5 first:rounded-tl-md last:rounded-tr-md pl-3 pr-3 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase group"
className="first:pl-5 first:rounded-tl-md last:rounded-tr-md pl-3 pr-3 py-3 text-xs font-medium tracking-wider text-left uppercase group text-gray-600 dark:text-gray-400 transition hover:bg-gray-200 dark:hover:bg-gray-775"
{...columnRest}
>
<div className="flex items-center justify-between">
@ -127,7 +127,7 @@ function Table({ columns, data }: TableProps) {
</thead>
<tbody
{...getTableBodyProps()}
className="divide-y divide-gray-200 dark:divide-gray-700"
className="divide-y divide-gray-150 dark:divide-gray-750"
>
{page.map((row) => {
prepareRow(row);

View file

@ -15,7 +15,7 @@ interface StatsItemProps {
const StatsItem = ({ name, placeholder, value }: StatsItemProps) => (
<div
className="relative px-4 py-3 overflow-hidden bg-white rounded-lg shadow-lg dark:bg-gray-800"
className="relative px-4 py-3 overflow-hidden rounded-lg shadow-lg bg-white dark:bg-gray-800 border border-gray-150 dark:border-gray-775"
title="All time"
>
<dt>

View file

@ -1,807 +0,0 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Fragment, useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Field, FieldArray, FieldProps, FormikValues, useFormikContext } from "formik";
import type { FieldArrayRenderProps } from "formik";
import { Dialog, Switch as SwitchBasic, Transition } from "@headlessui/react";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { Link } from "react-router-dom";
import { toast } from "react-hot-toast";
import {
ActionContentLayoutOptions,
ActionRtorrentRenameOptions,
ActionTypeNameMap,
ActionTypeOptions
} from "@domain/constants";
import { AlertWarning } from "@components/alerts";
import { DownloadClientSelect, NumberField, Select, SwitchGroup, TextField } from "@components/inputs";
import { APIClient } from "@api/APIClient";
import { EmptyListState } from "@components/emptystates";
import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import { DeleteModal } from "@components/modals";
import { CollapsableSection } from "./Details";
import { TextArea } from "@components/inputs/input";
import Toast from "@components/notifications/Toast";
import { DocsLink } from "@components/ExternalLink";
interface FilterActionsProps {
filter: Filter;
values: FormikValues;
}
export function FilterActions({ filter, values }: FilterActionsProps) {
const { data } = useQuery(
["filters", "download_clients"],
() => APIClient.download_clients.getAll(),
{ refetchOnWindowFocus: false }
);
const newAction: Action = {
id: 0,
name: "new action",
enabled: true,
type: "TEST",
watch_folder: "",
exec_cmd: "",
exec_args: "",
category: "",
tags: "",
label: "",
save_path: "",
paused: false,
ignore_rules: false,
skip_hash_check: false,
content_layout: "" || undefined,
limit_upload_speed: 0,
limit_download_speed: 0,
limit_ratio: 0,
limit_seed_time: 0,
reannounce_skip: false,
reannounce_delete: false,
reannounce_interval: 7,
reannounce_max_attempts: 25,
filter_id: filter.id,
webhook_host: "",
webhook_type: "",
webhook_method: "",
webhook_data: "",
webhook_headers: [],
external_download_client_id: 0,
client_id: 0
};
return (
<div className="mt-10">
<FieldArray name="actions">
{({ remove, push }: FieldArrayRenderProps) => (
<Fragment>
<div className="-ml-4 -mt-4 mb-6 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-gray-200">Actions</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Add to download clients or run custom commands.
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md 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"
onClick={() => push(newAction)}
>
Add new
</button>
</div>
</div>
<div className="light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md">
{values.actions.length > 0 ?
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{values.actions.map((action: Action, index: number) => (
<FilterActionsItem action={action} clients={data ?? []} idx={index} initialEdit={values.actions.length === 1} remove={remove} key={index} />
))}
</ul>
: <EmptyListState text="No actions yet!" />
}
</div>
</Fragment>
)}
</FieldArray>
</div>
);
}
interface TypeFormProps {
action: Action;
idx: number;
clients: Array<DownloadClient>;
}
const TypeForm = ({ action, idx, clients }: TypeFormProps) => {
const { setFieldValue } = useFormikContext();
const resetClientField = (action: Action, idx: number, prevActionType: string): void => {
const fieldName = `actions.${idx}.client_id`;
if (prevActionType !== action.type && (
action.type === "QBITTORRENT" ||
action.type === "DELUGE_V1" ||
action.type === "DELUGE_V2" ||
action.type === "RTORRENT" ||
action.type === "TRANSMISSION" ||
action.type === "PORLA" ||
action.type === "RADARR" ||
action.type === "SONARR" ||
action.type === "LIDARR" ||
action.type === "WHISPARR" ||
action.type === "READARR" ||
action.type === "SABNZBD"
)) {
setFieldValue(fieldName, 0); // Reset the client_id field value
}
};
const [prevActionType, setPrevActionType] = useState<string | null>(null);
useEffect(() => {
if (prevActionType !== null) {
resetClientField(action, idx, prevActionType);
}
setPrevActionType(action.type);
}, [action.type, idx, setFieldValue]);
switch (action.type) {
case "TEST":
return (
<AlertWarning
text="The test action does nothing except to show if the filter works."
/>
);
case "EXEC":
return (
<div>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField
name={`actions.${idx}.exec_cmd`}
label="Command"
columns={6}
placeholder="Path to program eg. /bin/test"
/>
<TextField
name={`actions.${idx}.exec_args`}
label="Arguments"
columns={6}
placeholder="Arguments eg. --test"
/>
</div>
</div>
);
case "WATCH_FOLDER":
return (
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField
name={`actions.${idx}.watch_folder`}
label="Watch folder"
columns={6}
placeholder="Watch directory eg. /home/user/rwatch"
/>
</div>
);
case "WEBHOOK":
return (
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField
name={`actions.${idx}.webhook_host`}
label="Host"
columns={6}
placeholder="Host eg. http://localhost/webhook"
/>
<TextArea
name={`actions.${idx}.webhook_data`}
label="Data (json)"
columns={6}
rows={5}
placeholder={"Request data: { \"key\": \"value\" }"}
/>
</div>
);
case "QBITTORRENT":
return (
<div className="w-full">
<div className="mt-6 grid grid-cols-12 gap-6">
<DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
<div className="col-span-6 sm:col-span-6">
<TextField
name={`actions.${idx}.save_path`}
label="Save path"
columns={6}
placeholder="eg. /full/path/to/download_folder"
tooltip={
<div>
<p>Set a custom save path for this action. Automatic Torrent Management will take care of this if using qBittorrent with categories.</p>
<br />
<p>The field can use macros to transform/add values from metadata:</p>
<DocsLink href="https://autobrr.com/filters/macros" />
</div>
}
/>
</div>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField
name={`actions.${idx}.category`}
label="Category"
columns={6}
placeholder="eg. category"
tooltip={
<div>
<p>The field can use macros to transform/add values from metadata:</p>
<DocsLink href="https://autobrr.com/filters/macros" />
</div>
}
/>
<TextField
name={`actions.${idx}.tags`}
label="Tags"
columns={6}
placeholder="eg. tag1,tag2"
tooltip={
<div>
<p>The field can use macros to transform/add values from metadata:</p>
<DocsLink href="https://autobrr.com/filters/macros" />
</div>
}
/>
</div>
<CollapsableSection title="Rules" subtitle="client options">
<div className="col-span-12">
<div className="mt-6 grid grid-cols-12 gap-6">
<NumberField
name={`actions.${idx}.limit_download_speed`}
label="Limit download speed (KiB/s)"
placeholder="Takes any number (0 is no limit)"
/>
<NumberField
name={`actions.${idx}.limit_upload_speed`}
label="Limit upload speed (KiB/s)"
placeholder="Takes any number (0 is no limit)"
/>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<NumberField
name={`actions.${idx}.limit_ratio`}
label="Ratio limit"
placeholder="Takes any number (0 is no limit)"
step={0.25}
isDecimal
/>
<NumberField
name={`actions.${idx}.limit_seed_time`}
label="Seed time limit (minutes)"
placeholder="Takes any number (0 is no limit)"
/>
</div>
</div>
<div className="col-span-6">
<SwitchGroup
name={`actions.${idx}.paused`}
label="Add paused"
description="Add torrent as paused"
/>
<SwitchGroup
name={`actions.${idx}.ignore_rules`}
label="Ignore client rules"
tooltip={
<div>
<p>
Choose to ignore rules set in <Link className="text-blue-400 visited:text-blue-400" to="/settings/clients">Client Settings</Link>.
</p>
</div>
}
/>
</div>
<div className="col-span-6">
<Select
name={`actions.${idx}.content_layout`}
label="Content Layout"
optionDefaultText="Select content layout"
options={ActionContentLayoutOptions}></Select>
<div className="mt-2">
<SwitchGroup
name={`actions.${idx}.skip_hash_check`}
label="Skip hash check"
description="Add torrent and skip hash check"
/>
</div>
</div>
</CollapsableSection>
<CollapsableSection title="Advanced" subtitle="Advanced options">
<div className="col-span-12">
<div className="mt-6 grid grid-cols-12 gap-6">
<NumberField
name={`actions.${idx}.reannounce_interval`}
label="Reannounce interval. Run every X seconds"
placeholder="7 is default and recommended"
/>
<NumberField
name={`actions.${idx}.reannounce_max_attempts`}
label="Run reannounce Y times"
/>
</div>
</div>
<div className="col-span-6">
<SwitchGroup
name={`actions.${idx}.reannounce_skip`}
label="Disable reannounce"
description="Reannounce is enabled by default. Disable if not needed."
/>
<SwitchGroup
name={`actions.${idx}.reannounce_delete`}
label="Delete stalled"
description="Delete stalled torrents after X attempts"
/>
</div>
</CollapsableSection>
</div>
);
case "DELUGE_V1":
case "DELUGE_V2":
return (
<div>
<div className="mt-6 grid grid-cols-12 gap-6">
<DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
<div className="col-span-12 sm:col-span-6">
<TextField
name={`actions.${idx}.save_path`}
label="Save path"
columns={6}
placeholder="eg. /full/path/to/download_folder"
/>
</div>
</div>
<div className="mt-6 col-span-12 sm:col-span-6">
<TextField
name={`actions.${idx}.label`}
label="Label"
columns={6}
placeholder="eg. label1 (must exist in Deluge to work)"
/>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<NumberField
name={`actions.${idx}.limit_download_speed`}
label="Limit download speed (KB/s)"
/>
<NumberField
name={`actions.${idx}.limit_upload_speed`}
label="Limit upload speed (KB/s)"
/>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<div className="col-span-6">
<SwitchGroup
name={`actions.${idx}.paused`}
label="Add paused"
/>
</div>
</div>
</div>
);
case "RTORRENT":
return (
<div>
<div className="mt-6 grid grid-cols-12 gap-6">
<DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
<div className="col-span-12 sm:col-span-6">
<TextField
name={`actions.${idx}.label`}
label="Label"
columns={6}
placeholder="eg. label1,label2"
/>
</div>
<div className="col-span-12 sm:col-span-6">
<TextField
name={`actions.${idx}.save_path`}
label="Save path"
columns={6}
placeholder="eg. /full/path/to/download_folder"
/>
</div>
<div className="col-span-12 sm:col-span-6">
<div className="col-span-6">
<Select
name={`actions.${idx}.content_layout`}
label="Don't add torrent's name to path"
optionDefaultText="No"
options={ActionRtorrentRenameOptions}
/>
</div>
</div>
<div className="col-span-12 sm:col-span-6">
<div className="col-span-6">
<SwitchGroup
name={`actions.${idx}.paused`}
label="Don't start download automatically"
/>
</div>
</div>
</div>
</div>
);
case "TRANSMISSION":
return (
<div>
<div className="mt-6 grid grid-cols-12 gap-6">
<DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
<div className="col-span-12 sm:col-span-6">
<TextField
name={`actions.${idx}.save_path`}
label="Save path"
columns={6}
placeholder="eg. /full/path/to/download_folder"
/>
</div>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<div className="col-span-6">
<SwitchGroup
name={`actions.${idx}.paused`}
label="Add paused"
/>
</div>
<TextField
name={`actions.${idx}.label`}
label="Label"
columns={6}
placeholder="eg. label1"
/>
</div>
<CollapsableSection title="Rules" subtitle="client options">
<div className="col-span-12">
<div className="mt-6 grid grid-cols-12 gap-6">
<NumberField
name={`actions.${idx}.limit_download_speed`}
label="Limit download speed (KiB/s)"
placeholder="Takes any number (0 is no limit)"
/>
<NumberField
name={`actions.${idx}.limit_upload_speed`}
label="Limit upload speed (KiB/s)"
placeholder="Takes any number (0 is no limit)"
/>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<NumberField
name={`actions.${idx}.limit_ratio`}
label="Ratio limit"
placeholder="Takes any number (0 is no limit)"
step={0.25}
isDecimal
/>
<NumberField
name={`actions.${idx}.limit_seed_time`}
label="Seed time limit (minutes)"
placeholder="Takes any number (0 is no limit)"
/>
</div>
</div>
</CollapsableSection>
<CollapsableSection title="Re-announce" subtitle="Re-announce options">
<div className="col-span-12">
<div className="mt-6 grid grid-cols-12 gap-6">
<NumberField
name={`actions.${idx}.reannounce_interval`}
label="Reannounce interval. Run every X seconds"
placeholder="7 is default and recommended"
/>
<NumberField
name={`actions.${idx}.reannounce_max_attempts`}
label="Run reannounce Y times"
/>
</div>
</div>
<div className="col-span-6">
<SwitchGroup
name={`actions.${idx}.reannounce_skip`}
label="Disable reannounce"
description="Reannounce is enabled by default. Disable if not needed."
/>
<SwitchGroup
name={`actions.${idx}.reannounce_delete`}
label="Delete stalled"
description="Delete stalled torrents after X attempts"
/>
</div>
</CollapsableSection>
</div>
);
case "PORLA":
return (
<div className="w-full">
<div className="mt-6 grid grid-cols-12 gap-6">
<DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
<div className="col-span-6 sm:col-span-6">
<TextField
name={`actions.${idx}.save_path`}
label="Save path"
columns={6}
placeholder="eg. /full/path/to/torrent/data"
/>
</div>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField
name={`actions.${idx}.label`}
label="Preset"
columns={6}
placeholder="eg. default"
tooltip={<div>A case-sensitive preset name as configured in Porla.</div>} />
</div>
<CollapsableSection title="Rules" subtitle="client options">
<div className="col-span-12">
<div className="mt-6 grid grid-cols-12 gap-6">
<NumberField
name={`actions.${idx}.limit_download_speed`}
label="Limit download speed (KiB/s)"
/>
<NumberField
name={`actions.${idx}.limit_upload_speed`}
label="Limit upload speed (KiB/s)"
/>
</div>
</div>
</CollapsableSection>
</div>
);
case "RADARR":
case "SONARR":
case "LIDARR":
case "WHISPARR":
case "READARR":
return (
<div className="mt-6 grid grid-cols-12 gap-6">
<DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
<NumberField
name={`actions.${idx}.external_download_client_id`}
label="Override download client id for arr"
tooltip={<p>Override Download client Id from the one set in Clients. Useful if you have multiple clients inside the arr.</p>}
/>
</div>
);
case "SABNZBD":
return (
<div>
<div className="mt-6 grid grid-cols-12 gap-6">
<DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
<TextField
name={`actions.${idx}.category`}
label="Category"
columns={6}
placeholder="eg. category"
tooltip={<p>Category must exist already.</p>} />
</div>
</div>
);
default:
return null;
}
};
interface FilterActionsItemProps {
action: Action;
clients: DownloadClient[];
idx: number;
initialEdit: boolean;
remove: <T>(index: number) => T | undefined;
}
function FilterActionsItem({ action, clients, idx, initialEdit, remove }: FilterActionsItemProps) {
const cancelButtonRef = useRef(null);
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const [edit, toggleEdit] = useToggle(initialEdit);
const removeMutation = useMutation({
mutationFn: (id: number) => APIClient.actions.delete(id),
onSuccess: () => {
remove(idx);
// Invalidate filters just in case, most likely not necessary but can't hurt.
// queryClient.invalidateQueries({ queryKey: filterKeys.detail(id) });
toast.custom((t) => (
<Toast type="success" body={`Action ${action?.name} was deleted`} t={t} />
));
}
});
const removeAction = (id: number) => {
removeMutation.mutate(id);
};
return (
<li>
<div
className={classNames(
idx % 2 === 0 ? "bg-white dark:bg-gray-800" : "bg-gray-50 dark:bg-gray-700",
"flex items-center sm:px-6 hover:bg-gray-50 dark:hover:bg-gray-600"
)}
>
<Field name={`actions.${idx}.enabled`} type="checkbox">
{({
field,
form: { setFieldValue }
}: FieldProps) => (
<SwitchBasic
{...field}
type="button"
value={field.value}
checked={field.checked ?? false}
onChange={(value: boolean) => {
setFieldValue(field?.name ?? "", value);
}}
className={classNames(
field.value ? "bg-blue-500" : "bg-gray-200 dark:bg-gray-600",
"relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
<span className="sr-only">toggle enabled</span>
<span
aria-hidden="true"
className={classNames(
field.value ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</SwitchBasic>
)}
</Field>
<button className="px-4 py-4 w-full flex" type="button" onClick={toggleEdit}>
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div className="truncate">
<div className="flex text-sm">
<p className="ml-4 font-medium text-dark-600 dark:text-gray-100 truncate">
{action.name}
</p>
</div>
</div>
<div className="mt-4 flex-shrink-0 sm:mt-0 sm:ml-5">
<div className="flex overflow-hidden -space-x-1">
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
{ActionTypeNameMap[action.type]}
</span>
</div>
</div>
</div>
<div className="ml-5 flex-shrink-0">
<ChevronRightIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</div>
</button>
</div>
{edit && (
<div className="px-4 py-4 flex items-center sm:px-6 border dark:border-gray-600">
<Transition.Root show={deleteModalIsOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed inset-0 overflow-y-auto"
initialFocus={cancelButtonRef}
open={deleteModalIsOpen}
onClose={toggleDeleteModal}
>
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={removeMutation.isLoading}
buttonRef={cancelButtonRef}
toggle={toggleDeleteModal}
deleteAction={() => removeAction(action.id)}
title="Remove filter action"
text="Are you sure you want to remove this action? This action cannot be undone."
/>
</Dialog>
</Transition.Root>
<div className="w-full">
<div className="mt-6 grid grid-cols-12 gap-6">
<Select
name={`actions.${idx}.type`}
label="Type"
optionDefaultText="Select type"
options={ActionTypeOptions}
tooltip={<div><p>Select the download client type for this action.</p></div>}
/>
<TextField name={`actions.${idx}.name`} label="Name" columns={6} />
</div>
<TypeForm action={action} clients={clients} idx={idx} />
<div className="pt-6 divide-y divide-gray-200">
<div className="mt-4 pt-4 flex justify-between">
<button
type="button"
className="inline-flex items-center justify-center px-4 py-2 rounded-md sm:text-sm bg-red-700 dark:bg-red-900 hover:dark:bg-red-700 hover:bg-red-800 text-white focus:outline-none"
onClick={toggleDeleteModal}
>
Remove
</button>
<div>
<button
type="button"
className="light:bg-white light:border light:border-gray-300 rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-gray-700 dark:text-gray-500 light:hover:bg-gray-50 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
onClick={toggleEdit}
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
)}
</li>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,341 +0,0 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Field, FieldArray, FieldArrayRenderProps, FieldProps, useFormikContext } from "formik";
import { NumberField, Select, TextField } from "@components/inputs";
import { TextArea } from "@components/inputs/input";
import { Fragment, useRef } from "react";
import { EmptyListState } from "@components/emptystates";
import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import { Switch as SwitchBasic } from "@headlessui/react";
import {
ExternalFilterTypeNameMap,
ExternalFilterTypeOptions,
ExternalFilterWebhookMethodOptions
} from "@domain/constants";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { DeleteModal } from "@components/modals";
import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/24/outline";
import { DocsLink } from "@components/ExternalLink";
export function External() {
const { values } = useFormikContext<Filter>();
const newItem: ExternalFilter = {
id: values.external.length + 1,
index: values.external.length,
name: `External ${values.external.length + 1}`,
enabled: false,
type: "EXEC"
};
return (
<div className="mt-10">
<FieldArray name="external">
{({ remove, push, move }: FieldArrayRenderProps) => (
<Fragment>
<div className="-ml-4 -mt-4 mb-6 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-gray-200">External filters</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Run external scripts or webhooks and check status as part of filtering.
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md 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"
onClick={() => push(newItem)}
>
Add new
</button>
</div>
</div>
<div className="light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md">
{values.external.length > 0
? <ul className="divide-y divide-gray-200 dark:divide-gray-700">
{values.external.map((f, index: number) => (
<FilterExternalItem external={f} idx={index} key={index} remove={remove} move={move} initialEdit={true} />
))}
</ul>
: <EmptyListState text="No external filters yet!" />
}
</div>
</Fragment>
)}
</FieldArray>
</div>
);
}
interface FilterExternalItemProps {
external: ExternalFilter;
idx: number;
initialEdit: boolean;
remove: <T>(index: number) => T | undefined;
move: (from: number, to: number) => void;
}
function FilterExternalItem({ idx, external, initialEdit, remove, move }: FilterExternalItemProps) {
const { values, setFieldValue } = useFormikContext<Filter>();
const cancelButtonRef = useRef(null);
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const [edit, toggleEdit] = useToggle(initialEdit);
const removeAction = () => {
remove(idx);
};
const moveUp = () => {
move(idx, idx - 1);
setFieldValue(`external.${idx}.index`, idx - 1);
};
const moveDown = () => {
move(idx, idx + 1);
setFieldValue(`external.${idx}.index`, idx + 1);
};
return (
<li>
<div
className={classNames(
idx % 2 === 0 ? "bg-white dark:bg-gray-800" : "bg-gray-50 dark:bg-gray-700",
"flex items-center sm:px-6 hover:bg-gray-50 dark:hover:bg-gray-600"
)}
>
<div className="flex flex-col pr-2 justify-between">
{idx > 0 && (
<button type="button" className="bg-gray-600 hover:bg-gray-700" onClick={moveUp}>
<ArrowUpIcon
className="p-0.5 h-4 w-4 text-gray-400"
aria-hidden="true"
/>
</button>
)}
{idx < values.external.length - 1 && (
<button type="button" className="bg-gray-600 hover:bg-gray-700" onClick={moveDown}>
<ArrowDownIcon
className="p-0.5 h-4 w-4 text-gray-400"
aria-hidden="true"
/>
</button>
)}
</div>
<Field name={`external.${idx}.enabled`} type="checkbox">
{({
field,
form: { setFieldValue }
}: FieldProps) => (
<SwitchBasic
{...field}
type="button"
value={field.value}
checked={field.checked ?? false}
onChange={(value: boolean) => {
setFieldValue(field?.name ?? "", value);
}}
className={classNames(
field.value ? "bg-blue-500" : "bg-gray-200 dark:bg-gray-600",
"relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
<span className="sr-only">toggle enabled</span>
<span
aria-hidden="true"
className={classNames(
field.value ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</SwitchBasic>
)}
</Field>
<button className="px-4 py-4 w-full flex" type="button" onClick={toggleEdit}>
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div className="truncate">
<div className="flex text-sm">
<p className="ml-4 font-medium text-dark-600 dark:text-gray-100 truncate">
{external.name}
</p>
</div>
</div>
<div className="mt-4 flex-shrink-0 sm:mt-0 sm:ml-5">
<div className="flex overflow-hidden -space-x-1">
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
{ExternalFilterTypeNameMap[external.type]}
</span>
</div>
</div>
</div>
<div className="ml-5 flex-shrink-0">
<ChevronRightIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
</button>
</div>
{edit && (
<div className="px-4 py-4 flex items-center sm:px-6 border dark:border-gray-600">
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={false}
buttonRef={cancelButtonRef}
toggle={toggleDeleteModal}
deleteAction={removeAction}
title="Remove external filter"
text="Are you sure you want to remove this external filter? This action cannot be undone."
/>
<div className="w-full">
<div className="mt-6 grid grid-cols-12 gap-6">
<Select
name={`external.${idx}.type`}
label="Type"
optionDefaultText="Select type"
options={ExternalFilterTypeOptions}
tooltip={<div><p>Select the type for this external filter.</p></div>}
/>
<TextField name={`external.${idx}.name`} label="Name" columns={6} />
</div>
<TypeForm external={external} idx={idx} />
<div className="pt-6 divide-y divide-gray-200">
<div className="mt-4 pt-4 flex justify-between">
<button
type="button"
className="inline-flex items-center justify-center px-4 py-2 rounded-md sm:text-sm bg-red-700 dark:bg-red-900 hover:dark:bg-red-700 hover:bg-red-800 text-white focus:outline-none"
onClick={toggleDeleteModal}
>
Remove
</button>
<div>
<button
type="button"
className={
"bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none"
}
onClick={toggleEdit}
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
)}
</li>
);
}
interface TypeFormProps {
external: ExternalFilter;
idx: number;
}
const TypeForm = ({ external, idx }: TypeFormProps) => {
switch (external.type) {
case "EXEC":
return (
<div>
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField
name={`external.${idx}.exec_cmd`}
label="Command"
columns={6}
placeholder="Absolute path to executable eg. /bin/test"
tooltip={
<div>
<p>
For custom commands you should specify the full path to the binary/program
you want to run. And you can include your own static variables:
</p>
<DocsLink href="https://autobrr.com/filters/actions#custom-commands--exec" />
</div>
}
/>
<TextField
name={`external.${idx}.exec_args`}
label="Arguments"
columns={6}
placeholder={"Arguments eg. --test \"{{ .TorrentName }}\""}
/>
</div>
<div className="mt-6 grid grid-cols-12 gap-6">
<NumberField
name={`external.${idx}.exec_expect_status`}
label="Expected exit status"
placeholder="0"
/>
</div>
</div>
);
case "WEBHOOK":
return (
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField
name={`external.${idx}.webhook_host`}
label="Host"
columns={6}
placeholder="Host eg. http://localhost/webhook"
tooltip={<p>URL or IP to api. Pass params and set api tokens etc.</p>}
/>
<Select
name={`external.${idx}.webhook_method`}
label="HTTP method"
optionDefaultText="Select http method"
options={ExternalFilterWebhookMethodOptions}
tooltip={<div><p>Select the HTTP method for this webhook. Defaults to POST</p></div>}
/>
<TextField
name={`external.${idx}.webhook_headers`}
label="Headers"
columns={6}
placeholder="HEADER=custom1,HEADER2=custom2"
/>
<TextArea
name={`external.${idx}.webhook_data`}
label="Data (json)"
columns={6}
rows={5}
placeholder={"Request data: { \"key\": \"value\" }"}
/>
<NumberField
name={`external.${idx}.webhook_expect_status`}
label="Expected http status code"
placeholder="200"
/>
<TextField
name={`external.${idx}.webhook_retry_status`}
label="Retry http status code(s)"
placeholder="Retry on status eg. 202, 204"
/>
<NumberField
name={`external.${idx}.webhook_retry_attempts`}
label="Maximum retry attempts"
placeholder="10"
/>
<NumberField
name={`external.${idx}.webhook_retry_delay_seconds`}
label="Retry delay in seconds"
placeholder="1"
/>
</div>
);
default:
return null;
}
};

View file

@ -21,7 +21,7 @@ interface ModalLowerProps extends ImporterProps {
const ModalUpper = ({ children }: { children: React.ReactNode; }) => (
<div className="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:py-6 sm:px-4 sm:pb-4">
<div className="mt-3 text-left sm:mt-0 sm:ml-4 sm:pr-8 max-w-full">
<div className="mt-3 text-left sm:mt-0 max-w-full">
<Dialog.Title as="h3" className="mb-3 text-lg leading-6 font-medium text-gray-900 dark:text-white break-words">
Import filter (in JSON or autodl-irssi format)
</Dialog.Title>
@ -31,10 +31,10 @@ const ModalUpper = ({ children }: { children: React.ReactNode; }) => (
);
const ModalLower = ({ isOpen, setIsOpen, onImportClick }: ModalLowerProps) => (
<div className="bg-gray-50 dark:bg-gray-800 border-t border-gray-300 dark:border-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<div className="bg-gray-50 dark:bg-gray-800 border-t border-gray-300 dark:border-gray-700 px-4 py-3 sm:px-4 sm:flex sm:flex-row-reverse">
<button
type="button"
className="w-full inline-flex justify-center rounded-md border border-lime-500 shadow-sm px-4 py-2 bg-lime-700 text-base font-medium text-white hover:bg-lime-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-lime-500 sm:ml-3 sm:w-auto sm:text-sm"
className="w-full inline-flex justify-center rounded-md border border-blue-500 shadow-sm px-4 py-2 bg-blue-700 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm"
onClick={(e) => {
e.preventDefault();
if (isOpen) {
@ -105,7 +105,7 @@ const ImportJSON = async (inputFilterText: string) => {
/>
);
}
}
};
const ImportAutodlIrssi = async (inputText: string) => {
const parser = new AutodlIrssiConfigParser();
@ -162,7 +162,7 @@ const ImportAutodlIrssi = async (inputText: string) => {
/>
);
}
}
};
export const Importer = ({
isOpen,
@ -253,7 +253,7 @@ export const Importer = ({
<div className="inline-block align-bottom border border-transparent dark:border-gray-700 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle w-full sm:max-w-6xl">
<ModalUpper>
<textarea
className="form-input resize-y block w-full shadow-sm sm:text-sm rounded-md border py-2.5 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-400 dark:border-gray-700 bg-gray-100 dark:bg-gray-900 dark:text-gray-100"
className="form-input resize-y block w-full shadow-sm sm:text-sm rounded-md border py-2.5 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-815 dark:text-gray-100"
placeholder="Paste your filter data here (either autobrr JSON format or your entire autodl-irssi config)"
value={inputFilterText}
onChange={(event) => {
@ -288,4 +288,4 @@ export const Importer = ({
</Dialog>
</Transition.Root>
);
}
};

View file

@ -6,7 +6,7 @@
import { Dispatch, FC, Fragment, MouseEventHandler, useReducer, useRef, useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { toast } from "react-hot-toast";
import { Listbox, Menu, Switch, Transition } from "@headlessui/react";
import { Listbox, Menu, Transition } from "@headlessui/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FormikValues } from "formik";
import { useCallback } from "react";
@ -35,6 +35,7 @@ import { DeleteModal } from "@components/modals";
import { Importer } from "./Importer";
import { Tooltip } from "@components/tooltips/Tooltip";
import { Checkbox } from "@components/Checkbox";
export const filterKeys = {
all: ["filters"] as const,
@ -102,7 +103,7 @@ export function Filters() {
{({ open }) => (
<>
<button
className="relative inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-l-md 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"
className="relative inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-l-md transition 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"
onClick={(e: { stopPropagation: () => void; }) => {
if (!open) {
e.stopPropagation();
@ -113,7 +114,7 @@ export function Filters() {
<PlusIcon className="h-5 w-5 mr-1" />
Create Filter
</button>
<Menu.Button className="relative inline-flex items-center px-2 py-2 border-l border-spacing-1 dark:border-black shadow-sm text-sm font-medium rounded-r-md 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">
<Menu.Button className="relative inline-flex items-center px-2 py-2 border-l border-spacing-1 dark:border-black shadow-sm text-sm font-medium rounded-r-md transition 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">
<ChevronDownIcon className="h-5 w-5" />
</Menu.Button>
<Transition
@ -133,11 +134,12 @@ export function Filters() {
type="button"
className={classNames(
active ? "bg-gray-50 dark:bg-gray-600" : "",
"flex items-center w-full text-left py-2 px-4 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
"flex items-center w-full text-left py-2 px-3 text-sm font-medium text-gray-700 dark:text-gray-200 rounded-md focus:outline-none"
)}
onClick={() => setShowImportModal(true)}
>
<ArrowUpOnSquareIcon className="mr-1 w-4 h-4" />Import filter
<ArrowUpOnSquareIcon className="mr-1 w-4 h-4" />
<span>Import filter</span>
</button>
)}
</Menu.Item>
@ -200,9 +202,9 @@ function FilterList({ toggleCreateFilter }: any) {
const filtered = filteredData(data ?? [], status);
return (
<div className="max-w-screen-xl mx-auto pb-12 px-4 sm:px-6 lg:px-8 relative">
<div className="align-middle min-w-full rounded-t-lg rounded-b-lg shadow-lg bg-gray-50 dark:bg-gray-800">
<div className="rounded-t-lg flex justify-between px-4 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="max-w-screen-xl mx-auto pb-12 px-2 sm:px-6 lg:px-8 relative">
<div className="align-middle min-w-full rounded-t-lg rounded-b-lg shadow-table bg-gray-50 dark:bg-gray-800 border border-gray-250 dark:border-gray-775">
<div className="rounded-t-lg flex justify-between px-4 bg-gray-125 dark:bg-gray-850 border-b border-gray-200 dark:border-gray-750">
<div className="flex gap-4">
<StatusButton data={filtered.all} label="All" value="" currentValue={status} dispatch={dispatchFilter} />
<StatusButton data={filtered.enabled} label="Enabled" value="enabled" currentValue={status} dispatch={dispatchFilter} />
@ -216,15 +218,15 @@ function FilterList({ toggleCreateFilter }: any) {
</div>
{data && data.length > 0 ? (
<ol className="min-w-full">
{filtered.filtered.length > 0
? filtered.filtered.map((filter: Filter, idx) => (
<ul className="min-w-full divide-y divide-gray-150 dark:divide-gray-775">
{filtered.filtered.length > 0 ? (
filtered.filtered.map((filter: Filter, idx) => (
<FilterListItem filter={filter} values={filter} key={filter.id} idx={idx} />
))
: <EmptyListState text={`No ${status} filters`} />
}
</ol>
) : (
<EmptyListState text={`No ${status} filters`} />
)}
</ul>
) : (
<EmptyListState text="No filters here.." buttonText="Add new" buttonOnClick={toggleCreateFilter} />
)}
@ -254,8 +256,10 @@ const StatusButton = ({ data, label, value, currentValue, dispatch }: StatusButt
return (
<button
className={classNames(
currentValue == value ? "font-bold border-b-2 border-blue-500 dark:text-gray-100 text-gray-900" : "font-medium text-gray-600 dark:text-gray-400",
"py-4 pb-4 text-left text-xs tracking-wider"
"py-4 pb-4 text-left text-xs tracking-wider transition border-b-2",
currentValue === value
? "font-bold border-blue-500 dark:text-gray-100 text-gray-950"
: "font-medium border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
)}
onClick={setFilter}
value={value}
@ -440,7 +444,7 @@ const FilterItemDropdown = ({ filter, onToggle }: FilterItemDropdownProps) => {
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute right-0 w-56 mt-2 origin-top-right bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-10 focus:outline-none z-10"
className="absolute right-0 w-56 mt-2 origin-top-right bg-white dark:bg-gray-825 divide-y divide-gray-200 dark:divide-gray-750 rounded-md shadow-lg border border-gray-250 dark:border-gray-750 focus:outline-none z-10"
>
<div className="px-1 py-1">
<Menu.Item>
@ -600,47 +604,34 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
<li
key={filter.id}
className={classNames(
"flex items-center hover:bg-gray-100 dark:hover:bg-[#222225] rounded-b-lg",
idx % 2 === 0 ?
"bg-white dark:bg-[#2e2e31]" :
"bg-gray-50 dark:bg-gray-800"
"flex items-center transition last:rounded-b-md py-0.5",
idx % 2 === 0
? "bg-white dark:bg-gray-800"
: "bg-gray-75 dark:bg-gray-825"
)}
>
<span
className="px-4 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-100"
className="pl-2 pr-4 sm:px-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-100"
>
<Switch
checked={filter.enabled}
onChange={toggleActive}
className={classNames(
filter.enabled ? "bg-blue-500 dark:bg-blue-500" : "bg-gray-200 dark:bg-gray-700",
"relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
filter.enabled ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white dark:bg-gray-200 shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
<Checkbox
value={filter.enabled}
setValue={toggleActive}
/>
</span>
<div className="py-2 flex flex-col overflow-hidden w-full justify-center">
<span className="w-full break-words whitespace-wrap text-sm font-bold text-gray-900 dark:text-gray-100">
<Link
to={filter.id.toString()}
className="hover:text-black dark:hover:text-gray-300"
>
{filter.name}
</Link>
</span>
<div className="flex items-center">
<Link
to={filter.id.toString()}
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}
</Link>
<div className="flex items-center flex-wrap">
<span className="mr-2 break-words whitespace-nowrap text-xs font-medium text-gray-600 dark:text-gray-400">
Priority: {filter.priority}
Priority: {filter.priority !== 0 ? (
<span className="text-gray-850 dark:text-gray-200">{filter.priority}</span>
) : filter.priority}
</span>
<span className="whitespace-nowrap text-xs font-medium text-gray-600 dark:text-gray-400">
<span className="z-10 whitespace-nowrap text-xs font-medium text-gray-600 dark:text-gray-400">
<Tooltip
label={
<Link
@ -652,10 +643,7 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
</span>
{!filter.actions_count && (
<span className="mr-2 ml-2 flex h-3 w-3 relative">
<span className="animate-ping inline-flex h-full w-full rounded-full dark:bg-red-500 bg-red-400 opacity-75" />
<span
className="inline-flex absolute rounded-full h-3 w-3 dark:bg-red-500 bg-red-400"
/>
</span>
)}
</Link>
@ -668,10 +656,10 @@ function FilterListItem({ filter, values, idx }: FilterListItemProps) {
</span>
</div>
</div>
<span className="hidden md:flex px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
<span className="hidden md:flex px-4 whitespace-nowrap text-sm font-medium text-gray-900">
<FilterIndexers indexers={filter.indexers} />
</span>
<span className="min-w-fit px-4 py-4 whitespace-nowrap text-right text-sm font-medium">
<span className="min-w-fit px-4 py-2 whitespace-nowrap text-right text-sm font-medium">
<FilterItemDropdown
values={values}
filter={filter}
@ -688,8 +676,7 @@ interface IndexerTagProps {
const IndexerTag: FC<IndexerTagProps> = ({ indexer }) => (
<span
key={indexer.id}
className="hidden sm:inline-flex mr-2 items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400"
className="hidden sm:inline-flex items-center px-2 py-0.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400"
>
{indexer.name}
</span>
@ -700,32 +687,31 @@ interface FilterIndexersProps {
}
function FilterIndexers({ indexers }: FilterIndexersProps) {
if (indexers.length <= 2) {
if (!indexers.length) {
return (
<>
{indexers.length > 0
? indexers.map((indexer, idx) => (
<IndexerTag key={idx} indexer={indexer} />
))
: <span className="hidden sm:flex text-red-400 dark:text-red-800 p-1 text-xs tracking-wide rounded border border-red-400 dark:border-red-700 bg-red-100 dark:bg-red-400">NO INDEXERS SELECTED</span>
}
</>
<span className="hidden sm:inline-flex items-center px-2 py-1 rounded-md text-xs font-medium uppercase text-white bg-red-750">
NO INDEXER
</span>
);
}
const res = indexers.slice(2);
return (
<>
<div className="flex flex-row gap-1">
<IndexerTag indexer={indexers[0]} />
<IndexerTag indexer={indexers[1]} />
<span
className="mr-2 inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400"
title={res.map(v => v.name).toString()}
>
+{indexers.length - 2}
</span>
</>
{indexers.length > 1 ? (
<IndexerTag indexer={indexers[1]} />
) : null}
{indexers.length > 2 ? (
<span
className="mr-2 inline-flex items-center px-2 py-0.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400"
title={res.map(v => v.name).toString()}
>
+{indexers.length - 2}
</span>
) : null}
</div>
);
}
@ -750,11 +736,11 @@ const ListboxFilter = ({
onChange={onChange}
>
<div className="relative">
<Listbox.Button className="relative w-full py-2 pr-5 text-left dark:text-gray-400 text-sm">
<Listbox.Button className="relative w-full py-2 pr-4 text-left dark:text-gray-400 text-sm">
<span className="block truncate">{label}</span>
<span className="absolute inset-y-0 right-0 flex items-center pointer-events-none">
<ChevronDownIcon
className="w-3 h-3 text-gray-600 hover:text-gray-600"
className="w-3 h-3"
aria-hidden="true"
/>
</span>

View file

@ -61,7 +61,7 @@ export const IRC_SUBSTITUTION_MAP: Record<string, string> = {
"ssl": "tls",
"nick": "nickserv_account",
"ident_password": "nickserv_password",
"server-password": "pass",
"server-password": "pass"
} as const;
export const FILTER_SUBSTITUTION_MAP: Record<string, string> = {

View file

@ -0,0 +1,315 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
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 { ChevronRightIcon, BoltIcon } from "@heroicons/react/24/solid";
import { classNames } from "@utils";
import { useToggle } from "@hooks/hooks";
import { APIClient } from "@api/APIClient";
import { ActionTypeNameMap, ActionTypeOptions, DOWNLOAD_CLIENTS } from "@domain/constants";
import { Select, TextField } from "@components/inputs";
import { DeleteModal } from "@components/modals";
import { EmptyListState } from "@components/emptystates";
import Toast from "@components/notifications/Toast";
import { Checkbox } from "@components/Checkbox";
import { TitleSubtitle } from "@components/headings";
import * as FilterSection from "./_components";
import * as FilterActions from "./action_components";
interface FilterActionsProps {
filter: Filter;
values: FormikValues;
}
export function Actions({ filter, values }: FilterActionsProps) {
const { data } = useQuery(
["filters", "download_clients"],
() => APIClient.download_clients.getAll(),
{ refetchOnWindowFocus: false }
);
const newAction: Action = {
id: 0,
name: "new action",
enabled: true,
type: "TEST",
watch_folder: "",
exec_cmd: "",
exec_args: "",
category: "",
tags: "",
label: "",
save_path: "",
paused: false,
ignore_rules: false,
skip_hash_check: false,
content_layout: "" || undefined,
limit_upload_speed: 0,
limit_download_speed: 0,
limit_ratio: 0,
limit_seed_time: 0,
reannounce_skip: false,
reannounce_delete: false,
reannounce_interval: 7,
reannounce_max_attempts: 25,
filter_id: filter.id,
webhook_host: "",
webhook_type: "",
webhook_method: "",
webhook_data: "",
webhook_headers: [],
external_download_client_id: 0,
client_id: 0
};
return (
<div className="mt-5">
<FieldArray name="actions">
{({ remove, push }: FieldArrayRenderProps) => (
<>
<div className="-ml-4 -mt-4 mb-6 flex justify-between items-center flex-wrap sm:flex-nowrap">
<TitleSubtitle
className="ml-4 mt-4"
title="Actions"
subtitle="Add to download clients or run custom commands."
/>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
className="relative inline-flex items-center px-4 py-2 border border-transparent transition shadow-sm text-sm font-medium rounded-md 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"
onClick={() => push(newAction)}
>
<BoltIcon
className="w-5 h-5 mr-1"
aria-hidden="true"
/>
Add new
</button>
</div>
</div>
{values.actions.length > 0 ? (
<ul className="rounded-md">
{values.actions.map((action: Action, index: number) => (
<FilterActionsItem
key={action.id}
action={action}
clients={data ?? []}
idx={index}
initialEdit={values.actions.length === 1}
remove={remove}
/>
))}
</ul>
) : (
<EmptyListState text="No actions yet!" />
)}
</>
)}
</FieldArray>
</div>
);
}
const TypeForm = (props: ClientActionProps) => {
const { setFieldValue } = useFormikContext();
const [prevActionType, setPrevActionType] = useState<string | null>(null);
const { action, idx } = props;
useEffect(() => {
if (prevActionType !== null && prevActionType !== action.type && DOWNLOAD_CLIENTS.includes(action.type)) {
// Reset the client_id field value
setFieldValue(`actions.${idx}.client_id`, 0);
}
setPrevActionType(action.type);
}, [action.type, idx, setFieldValue]);
switch (action.type) {
// torrent clients
case "QBITTORRENT":
return <FilterActions.QBittorrent {...props} />;
case "DELUGE_V1":
case "DELUGE_V2":
return <FilterActions.Deluge {...props} />;
case "RTORRENT":
return <FilterActions.RTorrent {...props} />;
case "TRANSMISSION":
return <FilterActions.Transmission {...props} />;
case "PORLA":
return <FilterActions.Porla {...props} />;
// arrs
case "RADARR":
case "SONARR":
case "LIDARR":
case "WHISPARR":
case "READARR":
return <FilterActions.Arr {...props} />;
// nzb
case "SABNZBD":
return <FilterActions.SABnzbd {...props} />;
// autobrr actions
case "TEST":
return <FilterActions.Test />;
case "EXEC":
return <FilterActions.Exec {...props} />;
case "WATCH_FOLDER":
return <FilterActions.WatchFolder {...props} />;
case "WEBHOOK":
return <FilterActions.WebHook {...props} />;
default:
// TODO(stacksmash76): Indicate error
return null;
}
};
interface FilterActionsItemProps {
action: Action;
clients: DownloadClient[];
idx: number;
initialEdit: boolean;
remove: <T>(index: number) => T | undefined;
}
function FilterActionsItem({ action, clients, idx, initialEdit, remove }: FilterActionsItemProps) {
const cancelButtonRef = useRef(null);
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const [edit, toggleEdit] = useToggle(initialEdit);
const removeMutation = useMutation({
mutationFn: (id: number) => APIClient.actions.delete(id),
onSuccess: () => {
remove(idx);
// Invalidate filters just in case, most likely not necessary but can't hurt.
// queryClient.invalidateQueries({ queryKey: filterKeys.detail(id) });
toast.custom((t) => (
<Toast type="success" body={`Action ${action?.name} was deleted`} t={t} />
));
}
});
const removeAction = (id: number) => {
removeMutation.mutate(id);
};
return (
<li>
<div
className={classNames(
idx % 2 === 0
? "bg-white dark:bg-gray-775"
: "bg-gray-100 dark:bg-gray-815",
"flex items-center transition px-2 sm:px-6 rounded-md my-1 border border-gray-150 dark:border-gray-750 hover:bg-gray-200 dark:hover:bg-gray-850"
)}
>
<Field name={`actions.${idx}.enabled`} type="checkbox">
{({
field,
form: { setFieldValue }
}: FieldProps) => (
<Checkbox
{...field}
value={!!field.checked}
setValue={(value: boolean) => {
setFieldValue(field.name, value);
}}
/>
)}
</Field>
<button className="pl-2 pr-0 sm:px-4 py-4 w-full flex items-center" type="button" onClick={toggleEdit}>
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex text-sm truncate">
<p className="font-medium text-dark-600 dark:text-gray-100 truncate">
{action.name}
</p>
</div>
<div className="flex-shrink-0 sm:mt-0 sm:ml-5">
<div className="flex overflow-hidden -space-x-1">
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
{ActionTypeNameMap[action.type]}
</span>
</div>
</div>
</div>
<div className="ml-5 flex-shrink-0">
<ChevronRightIcon
className="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</div>
</button>
</div>
{edit && (
<div className="flex items-center mt-1 px-3 sm:px-5 rounded-md border border-gray-150 dark:border-gray-750">
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={removeMutation.isLoading}
buttonRef={cancelButtonRef}
toggle={toggleDeleteModal}
deleteAction={() => removeAction(action.id)}
title="Remove filter action"
text="Are you sure you want to remove this action? This action cannot be undone."
/>
<FilterSection.Page gap="sm:gap-y-6">
<FilterSection.Section
title="Action"
subtitle="Define the download client for your action and its name"
>
<FilterSection.Layout>
<FilterSection.HalfRow>
<Select
name={`actions.${idx}.type`}
label="Action type"
optionDefaultText="Select type"
options={ActionTypeOptions}
tooltip={<div><p>Select the action type for this action.</p></div>}
/>
</FilterSection.HalfRow>
<FilterSection.HalfRow>
<TextField name={`actions.${idx}.name`} label="Name" />
</FilterSection.HalfRow>
</FilterSection.Layout>
</FilterSection.Section>
<TypeForm action={action} clients={clients} idx={idx} />
<div className="pt-6 pb-4 flex space-x-2 justify-between">
<button
type="button"
className="inline-flex items-center justify-center px-4 py-2 rounded-md sm:text-sm bg-red-700 dark:bg-red-900 hover:dark:bg-red-700 hover:bg-red-800 text-white focus:outline-none"
onClick={toggleDeleteModal}
>
Remove Action
</button>
<button
type="button"
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none"
onClick={toggleEdit}
>
Close
</button>
</div>
</FilterSection.Page>
</div>
)}
</li>
);
}

View file

@ -0,0 +1,458 @@
import type { FormikValues } from "formik";
import { DocsLink } from "@components/ExternalLink";
import { WarningAlert } from "@components/alerts";
import * as Input from "@components/inputs";
import * as CONSTS from "@domain/constants";
import { CollapsibleSection } from "./_components";
import * as Components from "./_components";
import { classNames } from "@utils";
type ValueConsumer = {
values: FormikValues;
};
const Releases = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen
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 = () => (
<CollapsibleSection
defaultOpen={false}
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 = () => (
<CollapsibleSection
defaultOpen
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 = () => (
<CollapsibleSection
defaultOpen={false}
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>Comma separated list of tags 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"
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>
);
const Uploaders = () => (
<CollapsibleSection
defaultOpen={false}
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 = () => (
<CollapsibleSection
defaultOpen={false}
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 = () => (
<CollapsibleSection
defaultOpen={false}
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
title="Freeleech"
subtitle="Match based off freeleech (if announced)"
>
<Input.TextField
name="freeleech_percent"
label="Freeleech percent"
disabled={values.freeleech}
tooltip={
<div>
<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:{" "}
<DocsLink href="https://autobrr.com/filters/freeleech" />
</p>
</div>
}
/>
</Components.HalfRow>
</CollapsibleSection>
);
const FeedSpecific = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={false}
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>
}
/>
<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>
}
/>
</CollapsibleSection>
);
const RawReleaseTags = ({ values }: ValueConsumer) => (
<CollapsibleSection
defaultOpen={false}
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"
/>
</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 = ({ values }: { values: FormikValues; }) => (
<div className="flex flex-col w-full gap-y-4 py-2 sm:-mx-1">
<Releases values={values} />
<Groups />
<Categories />
<Freeleech values={values} />
<Tags />
<Uploaders />
<Language />
<Origins />
<FeedSpecific values={values} />
<RawReleaseTags values={values} />
</div>
);

View file

@ -0,0 +1,370 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useRef } from "react";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { ArrowDownIcon, ArrowUpIcon, SquaresPlusIcon } from "@heroicons/react/24/outline";
import { Field, FieldArray, FieldArrayRenderProps, FieldProps, useFormikContext } from "formik";
import { classNames } from "@utils";
import { useToggle } from "@hooks/hooks";
import { TextAreaAutoResize } from "@components/inputs/input";
import { EmptyListState } from "@components/emptystates";
import { NumberField, Select, TextField } from "@components/inputs";
import {
ExternalFilterTypeNameMap,
ExternalFilterTypeOptions,
ExternalFilterWebhookMethodOptions
} from "@domain/constants";
import { DeleteModal } from "@components/modals";
import { DocsLink } from "@components/ExternalLink";
import { Checkbox } from "@components/Checkbox";
import { TitleSubtitle } from "@components/headings";
import * as FilterSection from "./_components";
export function External() {
const { values } = useFormikContext<Filter>();
const newItem: ExternalFilter = {
id: values.external.length + 1,
index: values.external.length,
name: `External ${values.external.length + 1}`,
enabled: false,
type: "EXEC"
};
return (
<div className="mt-5">
<FieldArray name="external">
{({ remove, push, move }: FieldArrayRenderProps) => (
<>
<div className="-ml-4 -mt-4 mb-6 flex justify-between items-center flex-wrap sm:flex-nowrap">
<TitleSubtitle
className="ml-4 mt-4"
title="External filters"
subtitle="Run external scripts or webhooks and check status as part of filtering."
/>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
className="relative inline-flex items-center px-4 py-2 transition border border-transparent shadow-sm text-sm font-medium rounded-md 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"
onClick={() => push(newItem)}
>
<SquaresPlusIcon
className="w-5 h-5 mr-1"
aria-hidden="true"
/>
Add new
</button>
</div>
</div>
{values.external.length > 0 ? (
<ul className="rounded-md">
{values.external.map((external, index: number) => (
<FilterExternalItem
key={external.id}
initialEdit
external={external}
idx={index}
remove={remove}
move={move}
/>
))}
</ul>
) : (
<EmptyListState text="No external filters yet!" />
)}
</>
)}
</FieldArray>
</div>
);
}
interface FilterExternalItemProps {
external: ExternalFilter;
idx: number;
initialEdit: boolean;
remove: <T>(index: number) => T | undefined;
move: (from: number, to: number) => void;
}
function FilterExternalItem({ idx, external, initialEdit, remove, move }: FilterExternalItemProps) {
const { values, setFieldValue } = useFormikContext<Filter>();
const cancelButtonRef = useRef(null);
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const [edit, toggleEdit] = useToggle(initialEdit);
const removeAction = () => {
remove(idx);
};
const moveUp = () => {
move(idx, idx - 1);
setFieldValue(`external.${idx}.index`, idx - 1);
};
const moveDown = () => {
move(idx, idx + 1);
setFieldValue(`external.${idx}.index`, idx + 1);
};
return (
<li>
<div
className={classNames(
idx % 2 === 0
? "bg-white dark:bg-gray-775"
: "bg-gray-100 dark:bg-gray-815",
"flex items-center transition px-2 sm:px-6 rounded-md my-1 border border-gray-150 dark:border-gray-750 hover:bg-gray-200 dark:hover:bg-gray-850"
)}
>
{((idx > 0) || (idx < values.external.length - 1)) ? (
<div className="flex flex-col pr-2 justify-between">
{idx > 0 && (
<button type="button" onClick={moveUp}>
<ArrowUpIcon
className="p-0.5 h-4 w-4 text-gray-700 dark:text-gray-400"
aria-hidden="true"
/>
</button>
)}
{idx < values.external.length - 1 && (
<button type="button" onClick={moveDown}>
<ArrowDownIcon
className="p-0.5 h-4 w-4 text-gray-700 dark:text-gray-400"
aria-hidden="true"
/>
</button>
)}
</div>
) : null}
<Field name={`external.${idx}.enabled`} type="checkbox">
{({
field,
form: { setFieldValue }
}: FieldProps) => (
<Checkbox
{...field}
value={!!field.checked}
setValue={(value: boolean) => {
setFieldValue(field.name, value);
}}
/>
)}
</Field>
<button className="pl-2 pr-0 sm:px-4 py-4 w-full flex items-center" type="button" onClick={toggleEdit}>
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div className="truncate">
<div className="flex text-sm">
<p className="font-medium text-dark-600 dark:text-gray-100 truncate">
{external.name}
</p>
</div>
</div>
<div className="flex-shrink-0 sm:mt-0 sm:ml-5">
<div className="flex overflow-hidden -space-x-1">
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
{ExternalFilterTypeNameMap[external.type]}
</span>
</div>
</div>
</div>
<div className="ml-5 flex-shrink-0">
<ChevronRightIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
</button>
</div>
{edit && (
<div className="flex items-center mt-1 px-3 sm:px-5 rounded-md border border-gray-150 dark:border-gray-750">
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={false}
buttonRef={cancelButtonRef}
toggle={toggleDeleteModal}
deleteAction={removeAction}
title="Remove external filter"
text="Are you sure you want to remove this external filter? This action cannot be undone."
/>
<FilterSection.Page gap="sm:gap-y-6">
<FilterSection.Section
title="External Filter"
subtitle="Define the type of your filter and its name"
>
<FilterSection.Layout>
<FilterSection.HalfRow>
<Select
name={`external.${idx}.type`}
label="Type"
optionDefaultText="Select type"
options={ExternalFilterTypeOptions}
tooltip={<div><p>Select the type for this external filter.</p></div>}
/>
</FilterSection.HalfRow>
<FilterSection.HalfRow>
<TextField name={`external.${idx}.name`} label="Name" />
</FilterSection.HalfRow>
</FilterSection.Layout>
</FilterSection.Section>
<TypeForm external={external} idx={idx} />
<div className="pt-6 pb-4 space-x-2 flex justify-between">
<button
type="button"
className="inline-flex items-center justify-center px-4 py-2 rounded-md sm:text-sm bg-red-700 dark:bg-red-900 hover:dark:bg-red-700 hover:bg-red-800 text-white focus:outline-none"
onClick={toggleDeleteModal}
>
Remove External
</button>
<button
type="button"
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none"
onClick={toggleEdit}
>
Close
</button>
</div>
</FilterSection.Page>
</div>
)}
</li>
);
}
interface TypeFormProps {
external: ExternalFilter;
idx: number;
}
const TypeForm = ({ external, idx }: TypeFormProps) => {
switch (external.type) {
case "EXEC":
return (
<FilterSection.Section
title="Execute"
subtitle="Specify the executable, the argument and the expected exit status to run as a pre-filter"
>
<FilterSection.Layout>
<TextAreaAutoResize
name={`external.${idx}.exec_cmd`}
label="Path to Executable"
columns={5}
placeholder="Absolute path to executable eg. /bin/test"
tooltip={
<div>
<p>
For custom commands you should specify the full path to the binary/program
you want to run. And you can include your own static variables:
</p>
<DocsLink href="https://autobrr.com/filters/actions#custom-commands--exec" />
</div>
}
/>
<TextAreaAutoResize
name={`external.${idx}.exec_args`}
label="Exec Arguments"
columns={5}
placeholder={"Arguments eg. --test \"{{ .TorrentName }}\""}
/>
<div className="col-span-12 sm:col-span-2">
<NumberField
name={`external.${idx}.exec_expect_status`}
label="Expected exit status"
placeholder="0"
/>
</div>
</FilterSection.Layout>
</FilterSection.Section>
);
case "WEBHOOK":
return (
<>
<FilterSection.Section
title="Request"
subtitle="Specify your request destination endpoint, headers and expected return status"
>
<FilterSection.Layout>
<TextField
name={`external.${idx}.webhook_host`}
label="Endpoint"
columns={6}
placeholder="Host eg. http://localhost/webhook"
tooltip={<p>URL or IP to your API. Pass params and set API tokens etc.</p>}
/>
<Select
name={`external.${idx}.webhook_method`}
label="HTTP method"
optionDefaultText="Select http method"
options={ExternalFilterWebhookMethodOptions}
tooltip={<div><p>Select the HTTP method for this webhook. Defaults to POST</p></div>}
/>
<TextField
name={`external.${idx}.webhook_headers`}
label="HTTP Request Headers"
columns={6}
placeholder="HEADER=custom1,HEADER2=custom2"
/>
<NumberField
name={`external.${idx}.webhook_expect_status`}
label="Expected HTTP status code"
placeholder="200"
/>
</FilterSection.Layout>
</FilterSection.Section>
<FilterSection.Section
title="Retry"
subtitle="Retry behavior on request failure"
>
<FilterSection.Layout>
<TextField
name={`external.${idx}.webhook_retry_status`}
label="Retry http status code(s)"
placeholder="Retry on status eg. 202, 204"
columns={6}
/>
<NumberField
name={`external.${idx}.webhook_retry_attempts`}
label="Maximum retry attempts"
placeholder="10"
/>
<NumberField
name={`external.${idx}.webhook_retry_delay_seconds`}
label="Retry delay in seconds"
placeholder="1"
/>
</FilterSection.Layout>
</FilterSection.Section>
<FilterSection.Section
title="Payload"
subtitle="Specify your JSON payload"
>
<FilterSection.Layout>
<TextAreaAutoResize
name={`external.${idx}.webhook_data`}
label="Data (json)"
placeholder={"Request data: { \"key\": \"value\" }"}
/>
</FilterSection.Layout>
</FilterSection.Section>
</>
);
default:
return null;
}
};

View file

@ -0,0 +1,123 @@
import { useQuery } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import { downloadsPerUnitOptions } from "@domain/constants";
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 indexerOptions = data?.map(MapIndexer) ?? [];
return (
<Components.Page>
<Components.Section>
<Components.Layout>
<Input.TextField name="name" label="Filter name" columns={6} placeholder="eg. Filter 1" />
{!isLoading && (
<Input.IndexerMultiSelect name="indexers" options={indexerOptions} label="Indexers" columns={6} />
)}
</Components.Layout>
</Components.Section>
<Components.Section
title="Rules"
subtitle="Specify rules on how torrents should be handled/selected."
>
<Components.Layout>
<Input.TextField
name="min_size"
label="Min size"
columns={6}
placeholder="eg. 100MiB, 80GB"
tooltip={
<div>
<p>Supports units such as MB, MiB, GB, etc.</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.TextField
name="max_size"
label="Max size"
columns={6}
placeholder="eg. 100MiB, 80GB"
tooltip={
<div>
<p>Supports units such as MB, MiB, GB, etc.</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="delay"
label="Delay"
placeholder="Number of seconds to delay actions"
tooltip={
<div>
<p>Number of seconds to wait before running actions.</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="priority"
label="Priority"
placeholder="Higher number = higher priority"
tooltip={
<div>
<p>Filters are checked in order of priority. Higher number = higher priority.</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.NumberField
name="max_downloads"
label="Max downloads"
placeholder="Takes any number (0 is infinite)"
tooltip={
<div>
<p>Number of max downloads as specified by the respective unit.</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
<Input.Select
name="max_downloads_unit"
label="Max downloads per"
options={downloadsPerUnitOptions}
optionDefaultText="Select unit"
tooltip={
<div>
<p>The unit of time for counting the maximum downloads per filter.</p>
<DocsLink href="https://autobrr.com/filters#rules" />
</div>
}
/>
</Components.Layout>
<Components.Layout>
<Input.SwitchGroup
name="enabled"
label="Enabled"
description="Enable or disable this filter."
className="pb-2 col-span-12 sm:col-span-6"
/>
</Components.Layout>
</Components.Section>
</Components.Page>
);
};

View file

@ -0,0 +1,199 @@
import { DocsLink } from "@components/ExternalLink";
import { TextAreaAutoResize } from "@components/inputs/input";
import { MultiSelect, SwitchGroup, TextField } from "@components/inputs";
import * as CONSTS from "@domain/constants";
import * as Components from "./_components";
const SeasonsAndEpisodes = () => (
<Components.Section
title="Seasons and Episodes"
subtitle="Set season and episode match constraints."
>
<Components.Layout>
<TextField
name="seasons"
label="Seasons"
columns={8}
placeholder="eg. 1,3,2-6"
tooltip={
<div>
<p>See docs for information about how to <b>only</b> grab season packs:</p>
<DocsLink href="https://autobrr.com/filters/examples#only-season-packs" />
</div>
}
/>
<TextField
name="episodes"
label="Episodes"
columns={4}
placeholder="eg. 2,4,10-20"
tooltip={
<div>
<p>See docs for information about how to <b>only</b> grab episodes:</p>
<DocsLink href="https://autobrr.com/filters/examples/#skip-season-packs" />
</div>
}
/>
<div className="col-span-12 sm:col-span-6">
<SwitchGroup
name="smart_episode"
label="Smart Episode"
description="Do not match episodes older than the last one matched."
/>
</div>
</Components.Layout>
</Components.Section>
);
const Quality = () => (
<Components.Section
title="Quality"
subtitle="Set resolution, source, codec and related match constraints."
>
<Components.Layout gap={Components.WideGridGapClass}>
<MultiSelect
name="resolutions"
options={CONSTS.RESOLUTION_OPTIONS}
label="resolutions"
columns={6}
tooltip={
<div>
<p>Will match releases which contain any of the selected resolutions.</p>
<DocsLink href="https://autobrr.com/filters#quality" />
</div>
}
/>
<MultiSelect
name="sources"
options={CONSTS.SOURCES_OPTIONS}
label="sources"
columns={6}
tooltip={
<div>
<p>Will match releases which contain any of the selected sources.</p>
<DocsLink href="https://autobrr.com/filters#quality" />
</div>
}
/>
</Components.Layout>
<Components.Layout gap={Components.WideGridGapClass}>
<MultiSelect
name="codecs"
options={CONSTS.CODECS_OPTIONS}
label="codecs"
columns={6}
tooltip={
<div>
<p>Will match releases which contain any of the selected codecs.</p>
<DocsLink href="https://autobrr.com/filters#quality" />
</div>
}
/>
<MultiSelect
name="containers"
options={CONSTS.CONTAINER_OPTIONS}
label="containers"
columns={6}
tooltip={
<div>
<p>Will match releases which contain any of the selected containers.</p>
<DocsLink href="https://autobrr.com/filters#quality" />
</div>
}
/>
</Components.Layout>
<Components.Layout gap={Components.WideGridGapClass}>
<MultiSelect
name="match_hdr"
options={CONSTS.HDR_OPTIONS}
label="Match HDR"
columns={6}
tooltip={
<div>
<p>Will match releases which contain any of the selected HDR designations.</p>
<DocsLink href="https://autobrr.com/filters#quality" />
</div>
}
/>
<MultiSelect
name="except_hdr"
options={CONSTS.HDR_OPTIONS}
label="Except HDR"
columns={6}
tooltip={
<div>
<p>Won't match releases which contain any of the selected HDR designations (takes priority over Match HDR).</p>
<DocsLink href="https://autobrr.com/filters#quality" />
</div>
}
/>
</Components.Layout>
<Components.Layout gap={Components.WideGridGapClass}>
<MultiSelect
name="match_other"
options={CONSTS.OTHER_OPTIONS}
label="Match Other"
columns={6}
tooltip={
<div>
<p>Will match releases which contain any of the selected designations.</p>
<DocsLink href="https://autobrr.com/filters#quality" />
</div>
}
/>
<MultiSelect
name="except_other"
options={CONSTS.OTHER_OPTIONS}
label="Except Other"
columns={6}
tooltip={
<div>
<p>Won't match releases which contain any of the selected Other designations (takes priority over Match Other).</p>
<DocsLink href="https://autobrr.com/filters#quality" />
</div>
}
/>
</Components.Layout>
</Components.Section>
);
export const MoviesTv = () => (
<Components.Page>
<Components.Section>
<Components.Layout>
<TextAreaAutoResize
name="shows"
label="Movies / Shows"
columns={8}
placeholder="eg. Movie,Show 1,Show?2"
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#tvmovies" />
</div>
}
/>
<TextField
name="years"
label="Years"
columns={4}
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#tvmovies" />
</div>
}
/>
</Components.Layout>
</Components.Section>
<SeasonsAndEpisodes />
<Quality />
</Components.Page>
);

View file

@ -0,0 +1,187 @@
import type { FormikValues } from "formik";
import { DocsLink } from "@components/ExternalLink";
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>
<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}
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" />
</div>
}
/>
<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>
<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>
<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">
This is what you want in 90% of cases (instead of options above).
</span>
</Components.Layout>
</Components.Section>
</Components.Page>
);

View file

@ -0,0 +1,157 @@
import { ChevronDownIcon } from "@heroicons/react/24/solid";
import { classNames } from "@utils";
import { useToggle } from "@hooks/hooks";
import { TitleSubtitle } from "@components/headings";
type FilterSectionProps = {
children: React.ReactNode;
title?: string;
subtitle?: string | React.ReactNode;
gap?: string;
};
type OwningComponent = {
children: React.ReactNode;
className?: string;
gap?: string;
};
const VerticalGap = "gap-y-6 sm:gap-y-4";
export const NormalGridGapClass = `gap-x-0.5 sm:gap-x-3 ${VerticalGap}`;
export const TightGridGapClass = `gap-x-0.5 sm:gap-x-1.5 ${VerticalGap}`;
export const WideGridGapClass = `gap-x-0.5 sm:gap-x-6 ${VerticalGap}`;
export const LayoutClass = "grid grid-cols-12 col-span-12";
export const Layout = ({
children,
className = "",
gap = NormalGridGapClass
}: OwningComponent) => (
<div className={classNames(className, LayoutClass, gap)}>{children}</div>
);
export const Row = ({
children,
className = "",
gap = NormalGridGapClass
}: OwningComponent) => (
<div className={classNames(className, gap, "col-span-12")}>{children}</div>
);
export const HalfRow = ({
children,
className = "",
gap = NormalGridGapClass
}: OwningComponent) => (
<div className={classNames(className, gap, "col-span-12 sm:col-span-6")}>{children}</div>
);
export const Section = ({
title,
subtitle,
children,
gap = NormalGridGapClass
}: FilterSectionProps) => (
<div
className={classNames(
title ? "py-6" : "pt-5 pb-4",
"flex flex-col",
gap
)}
>
{(title && subtitle) ? (
<TitleSubtitle title={title} subtitle={subtitle} />
) : null}
{children}
</div>
);
type FilterPageProps = {
gap?: string;
children: React.ReactNode;
};
export const Page = ({
gap = VerticalGap,
children
}: FilterPageProps) => (
<div
className={classNames(
gap,
"flex flex-col w-full divide-y divide-gray-150 dark:divide-gray-750"
)}
>
{children}
</div>
);
interface CollapsibleSectionProps {
title: string;
subtitle?: string | React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
noBottomBorder?: boolean;
childClassName?: string;
}
// NOTE(stacksmash76): added text-shadow only for the dark theme - light theme is fine contrast-wise when it comes to headings
// ideally, this would need a redesign
export const CollapsibleSection = ({
title,
subtitle,
children,
defaultOpen = false,
noBottomBorder = false,
childClassName = NormalGridGapClass
}: CollapsibleSectionProps) => {
const [isOpen, toggleOpen] = useToggle(defaultOpen);
return (
<div
className={classNames(
isOpen ? "pb-10" : "pb-4",
noBottomBorder ? "" : "border-dashed border-b-2 border-gray-150 dark:border-gray-775",
"rounded-t-lg"
)}
>
<div
className="flex select-none items-center py-3.5 px-1 -ml-1 cursor-pointer transition rounded-lg hover:bg-gray-100 dark:hover:bg-gray-725"
onClick={toggleOpen}
>
<div className="flex flex-row gap-2 items-center">
<button
type="button"
className={classNames(
isOpen ? "rotate-0" : "-rotate-90",
"text-sm font-medium text-white transition-transform"
)}
>
<ChevronDownIcon className="h-6 w-6 text-gray-400" aria-hidden="true" />
</button>
<div
className={classNames(
isOpen ? "flex-col gap-0" : "flex-col sm:flex-row sm:items-end sm:gap-2",
"flex"
)}
>
<h3 className="text-xl leading-6 font-bold break-all dark:text-shadow dark:shadow-gray-900 text-gray-900 dark:text-gray-200">
{title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate whitespace-normal break-words">
{subtitle}
</p>
</div>
</div>
</div>
{/*TODO: Animate this too*/}
{isOpen && (
<div className={classNames(childClassName, "grid grid-cols-12 col-span-12 sm:px-1 mt-2")}>
{children}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,66 @@
import * as Input from "@components/inputs";
import { CollapsibleSection } from "../_components";
import * as FilterSection from "../_components";
export const Deluge = ({ idx, action, clients }: ClientActionProps) => (
<>
<FilterSection.Section
title="Instance"
subtitle={
<>Select the <span className="font-bold">specific instance</span> which you want to handle this release filter.</>
}
>
<FilterSection.Layout>
<FilterSection.HalfRow>
<Input.DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
</FilterSection.HalfRow>
<FilterSection.HalfRow>
<Input.TextField
name={`actions.${idx}.label`}
label="Label"
columns={6}
placeholder="eg. label1 (must exist in Deluge to work)"
/>
</FilterSection.HalfRow>
<Input.TextAreaAutoResize
name={`actions.${idx}.save_path`}
label="Save path"
placeholder="eg. /full/path/to/download_folder"
/>
</FilterSection.Layout>
<FilterSection.Layout className="pb-6">
<FilterSection.HalfRow>
<Input.SwitchGroup
name={`actions.${idx}.paused`}
label="Add paused"
description="Add torrent as paused"
/>
</FilterSection.HalfRow>
</FilterSection.Layout>
<CollapsibleSection
noBottomBorder
title="Limits"
subtitle="Configure your speed/ratio/seed time limits"
>
<Input.NumberField
name={`actions.${idx}.limit_download_speed`}
label="Limit download speed (KB/s)"
placeholder="Takes any number (0 is no limit)"
/>
<Input.NumberField
name={`actions.${idx}.limit_upload_speed`}
label="Limit upload speed (KB/s)"
placeholder="Takes any number (0 is no limit)"
/>
</CollapsibleSection>
</FilterSection.Section>
</>
);

View file

@ -0,0 +1,63 @@
import * as Input from "@components/inputs";
import { CollapsibleSection } from "../_components";
import * as FilterSection from "../_components";
export const Porla = ({ idx, action, clients }: ClientActionProps) => (
<>
<FilterSection.Section
title="Instance"
subtitle={
<>Select the <span className="font-bold">specific instance</span> which you want to handle this release filter.</>
}
>
<FilterSection.Layout>
<FilterSection.HalfRow>
<Input.DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
</FilterSection.HalfRow>
<FilterSection.HalfRow>
<Input.TextField
name={`actions.${idx}.label`}
label="Preset"
placeholder="eg. default"
tooltip={
<div>A case-sensitive preset name as configured in Porla.</div>
}
/>
</FilterSection.HalfRow>
</FilterSection.Layout>
<Input.TextAreaAutoResize
name={`actions.${idx}.save_path`}
label="Save path"
placeholder="eg. /full/path/to/torrent/data"
className="pb-6"
/>
<CollapsibleSection
noBottomBorder
title="Limits"
subtitle="Configure your speed/ratio/seed time limits"
>
<FilterSection.HalfRow>
<Input.NumberField
name={`actions.${idx}.limit_download_speed`}
label="Limit download speed (KiB/s)"
placeholder="Takes any number (0 is no limit)"
/>
</FilterSection.HalfRow>
<FilterSection.HalfRow>
<Input.NumberField
name={`actions.${idx}.limit_upload_speed`}
label="Limit upload speed (KiB/s)"
placeholder="Takes any number (0 is no limit)"
/>
</FilterSection.HalfRow>
</CollapsibleSection>
</FilterSection.Section>
</>
);

View file

@ -0,0 +1,177 @@
import { Link } from "react-router-dom";
import { DocsLink } from "@components/ExternalLink";
import { ActionContentLayoutOptions } from "@domain/constants";
import * as Input from "@components/inputs";
import { CollapsibleSection } from "../_components";
import * as FilterSection from "../_components";
export const QBittorrent = ({ idx, action, clients }: ClientActionProps) => (
<>
<FilterSection.Section
title="Instance"
subtitle={
<>Select the <span className="font-bold">specific instance</span> which you want to handle this release filter.</>
}
>
<FilterSection.Layout>
<FilterSection.HalfRow>
<Input.DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
</FilterSection.HalfRow>
</FilterSection.Layout>
<FilterSection.Layout>
<Input.TextField
name={`actions.${idx}.category`}
label="Category"
columns={6}
placeholder="eg. category"
tooltip={
<div>
<p>The field can use macros to transform/add values from metadata:</p>
<DocsLink href="https://autobrr.com/filters/macros" />
</div>
}
/>
<Input.TextField
name={`actions.${idx}.tags`}
label="Tags"
columns={6}
placeholder="eg. tag1,tag2"
tooltip={
<div>
<p>The field can use macros to transform/add values from metadata:</p>
<DocsLink href="https://autobrr.com/filters/macros" />
</div>
}
/>
</FilterSection.Layout>
<FilterSection.Layout className="pb-6">
<Input.TextAreaAutoResize
name={`actions.${idx}.save_path`}
label="Save path"
placeholder="eg. /full/path/to/download_folder"
tooltip={
<div>
<p>Set a custom save path for this action. Automatic Torrent Management will take care of this if using qBittorrent with categories.</p>
<br />
<p>The field can use macros to transform/add values from metadata:</p>
<DocsLink href="https://autobrr.com/filters/macros" />
</div>
}
/>
</FilterSection.Layout>
<CollapsibleSection
title="Rules"
subtitle="Configure your torrent client rules"
childClassName={FilterSection.WideGridGapClass}
>
<FilterSection.HalfRow>
<Input.SwitchGroup
name={`actions.${idx}.ignore_rules`}
label="Ignore existing client rules"
description={
<p>
Choose to ignore rules set in <Link className="text-blue-400 visited:text-blue-400" to="/settings/clients">Client Settings</Link>.
</p>
}
className="py-2 pb-4"
/>
<Input.Select
name={`actions.${idx}.content_layout`}
label="Content Layout"
optionDefaultText="Select content layout"
options={ActionContentLayoutOptions}
/>
</FilterSection.HalfRow>
<FilterSection.HalfRow>
<Input.SwitchGroup
name={`actions.${idx}.paused`}
label="Add paused"
description="Add torrent as paused"
/>
<Input.SwitchGroup
name={`actions.${idx}.skip_hash_check`}
label="Skip hash check"
description="Add torrent and skip hash check"
/>
</FilterSection.HalfRow>
</CollapsibleSection>
<CollapsibleSection
title="Limits"
subtitle="Configure your speed/ratio/seed time limits"
>
<FilterSection.Layout>
<Input.NumberField
name={`actions.${idx}.limit_download_speed`}
label="Limit download speed (KiB/s)"
placeholder="Takes any number (0 is no limit)"
/>
<Input.NumberField
name={`actions.${idx}.limit_upload_speed`}
label="Limit upload speed (KiB/s)"
placeholder="Takes any number (0 is no limit)"
/>
</FilterSection.Layout>
<FilterSection.Layout>
<Input.NumberField
name={`actions.${idx}.limit_ratio`}
label="Ratio limit"
placeholder="Takes any number (0 is no limit)"
step={0.25}
isDecimal
/>
<Input.NumberField
name={`actions.${idx}.limit_seed_time`}
label="Seed time limit (minutes)"
placeholder="Takes any number (0 is no limit)"
/>
</FilterSection.Layout>
</CollapsibleSection>
<CollapsibleSection
noBottomBorder
title="Announce"
subtitle="Set number of reannounces (if needed), delete after Y announce failures, etc."
childClassName={FilterSection.WideGridGapClass}
>
<FilterSection.HalfRow>
<Input.SwitchGroup
name={`actions.${idx}.reannounce_skip`}
label="Skip reannounce"
description="If reannounce is not needed, skip it completely"
className="pt-2 pb-4"
/>
<Input.NumberField
name={`actions.${idx}.reannounce_interval`}
label="Reannounce interval. Run every X seconds"
placeholder="7 is default and recommended"
/>
</FilterSection.HalfRow>
<FilterSection.HalfRow>
<Input.SwitchGroup
name={`actions.${idx}.reannounce_delete`}
label="Delete stalled"
description="Delete stalled torrents after Y attempts"
className="pt-2 pb-4"
/>
<Input.NumberField
name={`actions.${idx}.reannounce_max_attempts`}
label="Run reannounce Y times"
/>
</FilterSection.HalfRow>
</CollapsibleSection>
</FilterSection.Section>
</>
);

View file

@ -0,0 +1,57 @@
import { ActionRtorrentRenameOptions } from "@domain/constants";
import * as Input from "@components/inputs";
import * as FilterSection from "../_components";
export const RTorrent = ({ idx, action, clients }: ClientActionProps) => (
<>
<FilterSection.Section
title="Instance"
subtitle={
<>Select the <span className="font-bold">specific instance</span> which you want to handle this release filter.</>
}
>
<FilterSection.Layout>
<FilterSection.HalfRow>
<Input.DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
</FilterSection.HalfRow>
<FilterSection.HalfRow>
<Input.TextField
name={`actions.${idx}.label`}
label="Label"
columns={6}
placeholder="eg. label1,label2"
/>
</FilterSection.HalfRow>
</FilterSection.Layout>
<Input.TextAreaAutoResize
name={`actions.${idx}.save_path`}
label="Save path"
placeholder="eg. /full/path/to/download_folder"
/>
<FilterSection.Layout>
<FilterSection.HalfRow>
<Input.SwitchGroup
name={`actions.${idx}.paused`}
label="Add paused"
description="Add torrent as paused"
className="pt-2 pb-4"
/>
<Input.Select
name={`actions.${idx}.content_layout`}
label="Do not add torrent name to path"
optionDefaultText="No"
options={ActionRtorrentRenameOptions}
/>
</FilterSection.HalfRow>
</FilterSection.Layout>
</FilterSection.Section>
</>
);

View file

@ -0,0 +1,116 @@
import * as Input from "@components/inputs";
import { CollapsibleSection } from "../_components";
import * as FilterSection from "../_components";
export const Transmission = ({ idx, action, clients }: ClientActionProps) => (
<>
<FilterSection.Section
title="Instance"
subtitle={
<>Select the <span className="font-bold">specific instance</span> which you want to handle this release filter.</>
}
>
<FilterSection.Layout>
<FilterSection.HalfRow>
<Input.DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
</FilterSection.HalfRow>
<FilterSection.HalfRow>
<Input.TextField
name={`actions.${idx}.label`}
label="Torrent Label"
columns={6}
placeholder="eg. label1"
/>
</FilterSection.HalfRow>
</FilterSection.Layout>
<Input.TextAreaAutoResize
name={`actions.${idx}.save_path`}
label="Save path"
columns={6}
placeholder="eg. /full/path/to/download_folder"
/>
<FilterSection.Layout className="pb-6">
<FilterSection.HalfRow>
<Input.SwitchGroup
name={`actions.${idx}.paused`}
label="Add paused"
description="Add torrent as paused"
/>
</FilterSection.HalfRow>
</FilterSection.Layout>
<CollapsibleSection
title="Limits"
subtitle="Configure your speed/ratio/seed time limits"
>
<FilterSection.Layout>
<Input.NumberField
name={`actions.${idx}.limit_download_speed`}
label="Limit download speed (KiB/s)"
placeholder="Takes any number (0 is no limit)"
/>
<Input.NumberField
name={`actions.${idx}.limit_upload_speed`}
label="Limit upload speed (KiB/s)"
placeholder="Takes any number (0 is no limit)"
/>
</FilterSection.Layout>
<FilterSection.Layout>
<Input.NumberField
name={`actions.${idx}.limit_ratio`}
label="Ratio limit"
placeholder="Takes any number (0 is no limit)"
step={0.25}
isDecimal
/>
<Input.NumberField
name={`actions.${idx}.limit_seed_time`}
label="Seed time limit (minutes)"
placeholder="Takes any number (0 is no limit)"
/>
</FilterSection.Layout>
</CollapsibleSection>
<CollapsibleSection
noBottomBorder
title="Announce"
subtitle="Set number of reannounces (if needed), delete after Y announce failures, etc."
childClassName={FilterSection.WideGridGapClass}
>
<FilterSection.HalfRow>
<Input.SwitchGroup
name={`actions.${idx}.reannounce_skip`}
label="Skip reannounce"
description="If reannounce is not needed, skip it completely"
className="pt-2 pb-4"
/>
<Input.NumberField
name={`actions.${idx}.reannounce_interval`}
label="Reannounce interval. Run every X seconds"
placeholder="7 is default and recommended"
/>
</FilterSection.HalfRow>
<FilterSection.HalfRow>
<Input.SwitchGroup
name={`actions.${idx}.reannounce_delete`}
label="Delete stalled"
description="Delete stalled torrents after Y attempts"
className="pt-2 pb-4"
/>
<Input.NumberField
name={`actions.${idx}.reannounce_max_attempts`}
label="Run reannounce Y times"
/>
</FilterSection.HalfRow>
</CollapsibleSection>
</FilterSection.Section>
</>
);

View file

@ -0,0 +1,134 @@
import { WarningAlert } from "@components/alerts";
import * as Input from "@components/inputs";
import * as FilterSection from "../_components";
export const SABnzbd = ({ idx, action, clients }: ClientActionProps) => (
<FilterSection.Section
title="Instance"
subtitle={
<>Select the <span className="font-bold">specific instance</span> which you want to handle this release filter.</>
}
>
<FilterSection.Layout>
<FilterSection.HalfRow>
<Input.DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
</FilterSection.HalfRow>
<FilterSection.HalfRow>
<Input.TextField
name={`actions.${idx}.category`}
label="Category"
columns={6}
placeholder="eg. category"
tooltip={<p>Category must exist already.</p>}
/>
</FilterSection.HalfRow>
</FilterSection.Layout>
</FilterSection.Section>
);
export const Test = () => (
<WarningAlert
alert="Heads up!"
className="mt-2"
colors="text-fuchsia-700 bg-fuchsia-100 dark:bg-fuchsia-200 dark:text-fuchsia-800"
text="The test action does nothing except to show if the filter works. Make sure to have your Logs page open while testing."
/>
);
export const Exec = ({ idx }: ClientActionProps) => (
<FilterSection.Section
title="Exec Arguments"
subtitle="Specify the executable and its arguments to be executed upon filter match. Use an absolute path."
>
<FilterSection.Layout>
<Input.TextField
name={`actions.${idx}.exec_cmd`}
label="Path to Executable"
placeholder="Path to program eg. /bin/test"
/>
<Input.TextAreaAutoResize
name={`actions.${idx}.exec_args`}
label="Arguments"
placeholder="Arguments eg. --test"
/>
</FilterSection.Layout>
</FilterSection.Section>
);
export const WatchFolder = ({ idx }: ClientActionProps) => (
<FilterSection.Section
title="Watch Folder Arguments"
subtitle="Point to where autobrr should save the files it fetches. Use an absolute path."
>
<FilterSection.Layout>
<Input.TextAreaAutoResize
name={`actions.${idx}.watch_folder`}
label="Watch directory"
placeholder="Watch directory eg. /home/user/rwatch"
/>
</FilterSection.Layout>
</FilterSection.Section>
);
export const WebHook = ({ idx }: ClientActionProps) => (
<FilterSection.Section
title="Webhook Arguments"
subtitle="Specify the payload to be sent to the desired endpoint upon filter match."
>
<FilterSection.Layout>
<Input.TextField
name={`actions.${idx}.webhook_host`}
label="Endpoint"
columns={6}
placeholder="Host eg. http://localhost/webhook"
tooltip={
<p>URL or IP to your API. Pass params and set API tokens etc.</p>
}
/>
</FilterSection.Layout>
<Input.TextAreaAutoResize
name={`actions.${idx}.webhook_data`}
label="Payload (json)"
placeholder={"Request data: { \"key\": \"value\" }"}
/>
</FilterSection.Section>
);
export const Arr = ({ idx, action, clients }: ClientActionProps) => (
<FilterSection.Section
title="Instance"
subtitle={
<>Select the <span className="font-bold">specific instance</span> which you want to handle this release filter.</>
}
>
<FilterSection.Layout>
<FilterSection.HalfRow>
<Input.DownloadClientSelect
name={`actions.${idx}.client_id`}
action={action}
clients={clients}
/>
</FilterSection.HalfRow>
<FilterSection.HalfRow>
<Input.NumberField
name={`actions.${idx}.external_download_client_id`}
label="Override download client id for arr"
tooltip={
<p>
Override Download client Id from the one set in Clients. Useful if you
have multiple clients inside the arr.
</p>
}
/>
</FilterSection.HalfRow>
</FilterSection.Layout>
</FilterSection.Section>
);

View file

@ -0,0 +1,6 @@
export * from "./ActionDeluge";
export * from "./ActionQBittorrent";
export * from "./ActionRTorrent";
export * from "./ActionTransmission";
export * from "./ActionPorla";
export * from "./OtherActions";

View file

@ -0,0 +1,5 @@
interface ClientActionProps {
idx: number;
action: Action;
clients: DownloadClient[];
}

View file

@ -0,0 +1,6 @@
export * from "./General";
export * from "./Advanced";
export * from "./MoviesAndTV";
export * from "./Music";
export * from "./External";
export * from "./Actions";

View file

@ -141,7 +141,7 @@ export const PushStatusSelectColumnFilter = ({
</ListboxFilter>
</div>
);
}
};
export const SearchColumnFilter = ({
column: { filterValue, setFilter, id }
@ -163,4 +163,4 @@ export const SearchColumnFilter = ({
/>
</div>
);
}
};

View file

@ -95,6 +95,7 @@ export const ReleaseTable = () => {
)}
>
<Tooltip
requiresClick
label={props.cell.value}
maxWidth="max-w-[90vw]"
>
@ -226,9 +227,9 @@ export const ReleaseTable = () => {
))
)}
</div>
<div className="bg-white shadow-lg dark:bg-gray-800 rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<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"
@ -244,7 +245,7 @@ export const ReleaseTable = () => {
</tr>
</thead>
<tbody className=" divide-gray-200 dark:divide-gray-700">
<tbody className="divide-y divide-gray-150 dark:divide-gray-700">
<tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
@ -316,13 +317,12 @@ export const ReleaseTable = () => {
<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 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:text-gray-500 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
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));
@ -392,9 +392,9 @@ export const ReleaseTable = () => {
))
)}
</div>
<div className="bg-white shadow-lg dark:bg-gray-800 rounded-md overflow-auto">
<table {...getTableProps()} className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<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();
return (
@ -407,7 +407,7 @@ export const ReleaseTable = () => {
<th
key={`${rowKey}-${columnKey}`}
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"
className="first:pl-5 first:rounded-tl-md last:rounded-tr-md pl-3 pr-3 py-3 text-xs font-medium tracking-wider text-left uppercase group text-gray-600 dark:text-gray-400 transition hover:bg-gray-200 dark:hover:bg-gray-775"
{...columnRest}
>
<div className="flex items-center justify-between">
@ -434,7 +434,7 @@ export const ReleaseTable = () => {
</thead>
<tbody
{...getTableBodyProps()}
className="divide-y divide-gray-200 dark:divide-gray-700"
className="divide-y divide-gray-150 dark:divide-gray-750"
>
{page.map((row) => {
prepareRow(row);
@ -474,7 +474,7 @@ export const ReleaseTable = () => {
<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-400 dark:hover:text-gray-500 focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
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));
@ -482,7 +482,7 @@ export const ReleaseTable = () => {
>
{[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
Show {pageSize}
{pageSize} entries
</option>
))}
</select>
@ -495,29 +495,31 @@ export const ReleaseTable = () => {
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" />
<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}
>
<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" />
<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 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" />
<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}
>
<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" />
<ChevronDoubleRightIcon className="w-4 h-4" aria-hidden="true" />
<span className="sr-only">Last</span>
</DataTable.PageButton>
</nav>
</div>

View file

@ -1,78 +0,0 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
function ActionSettings() {
return (
<div className="lg:col-span-9">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">Actions</h3>
<p className="mt-1 text-sm text-gray-500">
Manage actions.
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Add new
</button>
</div>
</div>
<div className="flex flex-col mt-6">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Name
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Type
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Port
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Enabled
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>empty</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default ActionSettings;

View file

@ -16,6 +16,8 @@ import { APIClient } from "@api/APIClient";
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,
@ -37,55 +39,44 @@ function APISettings() {
});
return (
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9">
<div className="pb-6 py-6 px-4 sm:p-6 lg:pb-8">
<APIKeyAddForm isOpen={addFormIsOpen} toggle={toggleAddForm} />
<Section
title="API keys"
description="Manage your autobrr API keys here."
rightSide={
<button
type="button"
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md 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"
onClick={toggleAddForm}
>
<PlusIcon className="h-5 w-5 mr-1" />
Add new
</button>
}
>
<APIKeyAddForm isOpen={addFormIsOpen} toggle={toggleAddForm} />
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">
API keys
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Manage API keys.
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md 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"
onClick={toggleAddForm}
>
Add new
</button>
</div>
</div>
{data && 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">
Name
</div>
<div className="col-span-8 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Key
</div>
</li>
{data && data.length > 0 ? (
<section className="mt-6 light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md">
<ol 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-5 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Name
</div>
<div className="col-span-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Key
</div>
</li>
{data && data.map((k, idx) => <APIListItem key={idx} apikey={k} />)}
</ol>
</section>
) : (
<EmptySimple
title="No API keys"
subtitle=""
buttonAction={toggleAddForm}
buttonText="Create API key"
/>
)}
</div>
</div>
{data.map((k, idx) => <APIListItem key={idx} apikey={k} />)}
</ul>
) : (
<EmptySimple
title="No API keys"
subtitle=""
buttonAction={toggleAddForm}
buttonText="Create API key"
/>
)}
</Section>
);
}
@ -131,7 +122,7 @@ function APIListItem({ apikey }: ApiKeyItemProps) {
/>
<div className="sm:grid grid-cols-12 gap-4 items-center py-2">
<div className="col-span-5 px-2 sm:px-6 py-2 sm:py-0 truncate block sm:text-sm text-md font-medium text-gray-900 dark:text-white">
<div className="col-span-3 px-2 sm:px-6 py-2 sm:py-0 truncate block sm:text-sm text-md font-medium text-gray-900 dark:text-white">
<div className="flex justify-between">
<div className="pl-1 py-2">{apikey.name}</div>
<div>
@ -151,7 +142,7 @@ function APIListItem({ apikey }: ApiKeyItemProps) {
</div>
</div>
</div>
<div className="col-span-6 flex items-center text-sm font-medium text-gray-900 dark:text-white">
<div className="col-span-8 flex items-center text-sm font-medium text-gray-900 dark:text-white">
<KeyField value={apikey.key} />
</div>

View file

@ -7,79 +7,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import { APIClient } from "@api/APIClient";
import { Checkbox } from "@components/Checkbox";
import { SettingsContext } from "@utils/Context";
import { GithubRelease } from "@app/types/Update";
import { Checkbox } from "@components/Checkbox";
import Toast from "@components/notifications/Toast";
import { ExternalLink } from "@components/ExternalLink";
interface RowItemProps {
label: string;
value?: string;
title?: string;
emptyText?: string;
newUpdate?: GithubRelease;
}
const RowItem = ({ label, value, title, emptyText }: RowItemProps) => {
return (
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
<dt className="font-medium text-gray-900 dark:text-white text-sm" title={title}>{label}</dt>
<dd className="mt-1 text-gray-900 dark:text-gray-300 text-sm sm:mt-0 sm:col-span-3 break-all truncate">
{value ? <span className="px-1.5 py-1 bg-gray-200 dark:bg-gray-700 rounded shadow">{value}</span> : emptyText}
</dd>
</div>
);
};
// interface RowItemNumberProps {
// label: string;
// value?: string | number;
// title?: string;
// unit?: string;
// }
// const RowItemNumber = ({ label, value, title, unit }: RowItemNumberProps) => {
// return (
// <div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
// <dt className="font-medium text-gray-500 dark:text-white" title={title}>{label}:</dt>
// <dd className="mt-1 text-gray-900 dark:text-white sm:mt-0 sm:col-span-2 break-all">
// <span className="px-1 py-0.5 bg-gray-700 rounded shadow">{value}</span>
// {unit &&
// <span className="ml-1 text-sm text-gray-800 dark:text-gray-400">{unit}</span>
// }
// </dd>
// </div>
// );
// };
const RowItemVersion = ({ label, value, title, newUpdate }: RowItemProps) => {
if (!value) {
return null;
}
return (
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
<dt className="font-medium text-gray-900 dark:text-white text-sm" title={title}>{label}</dt>
<dd className="mt-1 text-gray-900 dark:text-gray-300 text-sm sm:mt-0 sm:col-span-2 break-all truncate">
<span className="px-1.5 py-1 bg-gray-200 dark:bg-gray-700 rounded shadow">{value}</span>
{newUpdate && newUpdate.html_url && (
<ExternalLink
href={newUpdate.html_url}
className="ml-2 inline-flex items-center rounded-md bg-green-100 px-2.5 py-0.5 text-sm font-medium text-green-800"
>
{newUpdate.name} available!
</ExternalLink>
)}
</dd>
</div>
);
};
import { Section, RowItem } from "./_components";
function ApplicationSettings() {
const [settings, setSettings] = SettingsContext.use();
const { isLoading, data } = useQuery({
const { data } = useQuery({
queryKey: ["config"],
queryFn: APIClient.config.get,
retry: false,
@ -105,8 +43,9 @@ function ApplicationSettings() {
}
});
const toggleCheckUpdateMutation = useMutation((value: boolean) => APIClient.config.update({ check_for_updates: value }).then(() => value), {
onSuccess: (value: boolean) => {
const toggleCheckUpdateMutation = useMutation({
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"] });
checkUpdateMutation.mutate();
@ -114,20 +53,16 @@ function ApplicationSettings() {
});
return (
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Application</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Application settings. Change in config.toml and restart to take effect.
</p>
</div>
<form className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9" action="#" method="POST">
{!isLoading && data && (
<div className="mt-6 grid grid-cols-12 gap-6">
<div className="col-span-6 sm:col-span-4">
<label htmlFor="host" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
<Section
title="Application"
description="Application settings. Change in config.toml and restart to take effect."
>
<div className="-mx-4 divide-y divide-gray-150 dark:divide-gray-750">
<form className="mt-6 mb-4" action="#" method="POST">
{data && (
<div className="grid grid-cols-12 gap-2 sm:gap-6 px-4 sm:px-6">
<div className="col-span-12 sm:col-span-4">
<label htmlFor="host" className="block ml-px text-xs font-bold text-gray-700 dark:text-white uppercase tracking-wide">
Host
</label>
<input
@ -136,12 +71,12 @@ function ApplicationSettings() {
id="host"
value={data.host}
disabled={true}
className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
className="mt-1 block w-full sm:text-sm rounded-md border-gray-300 dark:border-gray-750 bg-gray-100 dark:bg-gray-825 dark:text-gray-100"
/>
</div>
<div className="col-span-6 sm:col-span-4">
<label htmlFor="port" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
<div className="col-span-12 sm:col-span-4">
<label htmlFor="port" className="block ml-px text-xs font-bold text-gray-700 dark:text-white uppercase tracking-wide">
Port
</label>
<input
@ -150,12 +85,12 @@ function ApplicationSettings() {
id="port"
value={data.port}
disabled={true}
className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
className="mt-1 block w-full sm:text-sm rounded-md border-gray-300 dark:border-gray-750 bg-gray-100 dark:bg-gray-825 dark:text-gray-100"
/>
</div>
<div className="col-span-6 sm:col-span-4">
<label htmlFor="base_url" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
<div className="col-span-12 sm:col-span-4">
<label htmlFor="base_url" className="block ml-px text-xs font-bold text-gray-700 dark:text-white uppercase tracking-wide">
Base url
</label>
<input
@ -164,64 +99,68 @@ function ApplicationSettings() {
id="base_url"
value={data.base_url}
disabled={true}
className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
className="mt-1 block w-full sm:text-sm rounded-md border-gray-300 dark:border-gray-750 bg-gray-100 dark:bg-gray-825 dark:text-gray-100"
/>
</div>
</div>
)}
</form>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="px-4 py-5 sm:p-0">
<dl className="sm:divide-y divide-gray-200 dark:divide-gray-700">
<RowItemVersion label="Version" value={data?.version} newUpdate={updateData ?? undefined} />
{data?.commit && <RowItem label="Commit" value={data.commit} />}
{data?.date && <RowItem label="Build date" value={data.date} />}
<RowItem label="Application" value={data?.application} />
<RowItem label="Config path" value={data?.config_dir} />
<RowItem label="Database" value={data?.database} />
</dl>
<RowItem
label="Version"
value={data?.version}
rightSide={
updateData && updateData.html_url ? (
<ExternalLink
href={updateData.html_url}
className="ml-2 inline-flex items-center rounded-md bg-green-100 px-2.5 py-0.5 text-sm font-medium text-green-800"
>
{updateData.name} available!
</ExternalLink>
) : null
}
/>
{data?.commit && <RowItem label="Commit" value={data.commit} />}
{data?.date && <RowItem label="Build date" value={data.date} />}
<RowItem label="Application" value={data?.application} />
<RowItem label="Config path" value={data?.config_dir} />
<RowItem label="Database" value={data?.database} />
<div className="py-0.5">
<Checkbox
label="WebUI Debug mode"
value={settings.debug}
className="p-4 sm:px-6"
setValue={
(newValue: boolean) => setSettings((prevState) => ({
...prevState,
debug: newValue
}))
}
/>
</div>
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="px-4 sm:px-6 py-1">
<Checkbox
label="WebUI Debug mode"
value={settings.debug}
setValue={
(newValue: boolean) => setSettings((prevState) => ({
...prevState,
debug: newValue
}))
}
/>
</div>
<div className="px-4 sm:px-6 py-1">
<Checkbox
label="Check for updates"
description="Get notified of new updates."
value={data?.check_for_updates ?? true}
setValue={(newValue: boolean) => {
toggleCheckUpdateMutation.mutate(newValue);
}}
/>
</div>
<div className="px-4 sm:px-6 py-1">
<Checkbox
label="Dark theme"
description="Switch between dark and light theme."
value={settings.darkTheme}
setValue={
(newValue: boolean) => setSettings((prevState) => ({
...prevState,
darkTheme: newValue
}))
}
/>
</div>
</ul>
<Checkbox
label="Check for updates"
description="Get notified of new updates."
value={data?.check_for_updates ?? true}
className="p-4 sm:px-6"
setValue={(newValue: boolean) => {
toggleCheckUpdateMutation.mutate(newValue);
}}
/>
<Checkbox
label="Dark theme"
description="Switch between dark and light theme."
value={settings.darkTheme}
className="p-4 sm:px-6"
setValue={
(newValue: boolean) => setSettings((prevState) => ({
...prevState,
darkTheme: newValue
}))
}
/>
</div>
</div>
</Section>
);
}

View file

@ -4,17 +4,19 @@
*/
import { useState, useMemo } from "react";
import { Switch } from "@headlessui/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { PlusIcon } from "@heroicons/react/24/solid";
import toast from "react-hot-toast";
import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import { DownloadClientAddForm, DownloadClientUpdateForm } from "@forms";
import { EmptySimple } from "@components/emptystates";
import { APIClient } from "@api/APIClient";
import { DownloadClientTypeNameMap } from "@domain/constants";
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,
@ -25,8 +27,7 @@ export const clientKeys = {
};
interface DLSettingsItemProps {
client: DownloadClient;
idx: number;
client: DownloadClient;
}
interface ListItemProps {
@ -87,7 +88,7 @@ function useSort(items: ListItemProps["clients"][], config?: SortConfig) {
return { items: sortedItems, requestSort, sortConfig, getSortIndicator };
}
function DownloadClientSettingsListItem({ client }: DLSettingsItemProps) {
function ListItem({ client }: DLSettingsItemProps) {
const [updateClientIsOpen, toggleUpdateClient] = useToggle(false);
const queryClient = useQueryClient();
@ -108,35 +109,24 @@ function DownloadClientSettingsListItem({ client }: DLSettingsItemProps) {
};
return (
<li key={client.name}>
<li>
<div className="grid grid-cols-12 items-center py-2">
<DownloadClientUpdateForm
client={client}
isOpen={updateClientIsOpen}
toggle={toggleUpdateClient}
/>
<div className="col-span-2 sm:col-span-1 px-6 flex items-center sm:px-6">
<Switch
checked={client.enabled}
onChange={onToggleMutation}
className={classNames(
client.enabled ? "bg-blue-500" : "bg-gray-200 dark:bg-gray-600",
"relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
client.enabled ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
<div className="col-span-2 sm:col-span-1 pl-1 sm:pl-5 flex items-center">
<Checkbox
value={client.enabled}
setValue={onToggleMutation}
/>
</div>
<div className="col-span-8 sm:col-span-4 lg:col-span-4 pl-10 sm:pl-12 pr-6 py-3 block flex-col text-sm font-medium text-gray-900 dark:text-white truncate" title={client.name}>{client.name}</div>
<div className="hidden sm:block col-span-4 pr-6 py-3 text-left items-center whitespace-nowrap text-sm text-gray-600 dark:text-gray-400 truncate" title={client.host}>{client.host}</div>
<div className="hidden sm:block col-span-2 py-3 text-left items-center text-sm text-gray-600 dark:text-gray-400">
{ActionTypeNameMap[client.type]}
</div>
<div className="col-span-8 sm:col-span-4 lg:col-span-4 pl-12 pr-6 py-3 block flex-col text-sm font-medium text-gray-900 dark:text-white truncate" title={client.name}>{client.name}</div>
<div className="hidden sm:block col-span-4 pr-6 py-3 text-left items-center whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 truncate" title={client.host}>{client.host}</div>
<div className="hidden sm:block col-span-2 py-3 text-left items-center text-sm text-gray-500 dark:text-gray-400">{DownloadClientTypeNameMap[client.type]}</div>
<div className="col-span-1 pl-0.5 whitespace-nowrap text-center text-sm font-medium">
<span className="text-blue-600 dark:text-gray-300 hover:text-blue-900 cursor-pointer" onClick={toggleUpdateClient}>
Edit
@ -163,65 +153,57 @@ function DownloadClientSettings() {
}
return (
<div className="lg:col-span-9">
<Section
title="Download Clients"
description="Manage download clients."
rightSide={
<button
type="button"
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md 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"
onClick={toggleAddClient}
>
<PlusIcon className="h-5 w-5 mr-1" />
Add new
</button>
}
>
<DownloadClientAddForm isOpen={addClientIsOpen} toggle={toggleAddClient} />
<div className="py-6 px-2 lg:pb-8">
<div className="px-4 -ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Clients</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Manage download clients.
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md 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"
onClick={toggleAddClient}
>
Add new
</button>
</div>
</div>
<div className="flex flex-col mt-6 px-4">
{sortedClients.items.length > 0
? <section className="light:bg-white dark:bg-gray-800 light:shadow sm:rounded-sm">
<ol className="min-w-full relative">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
<div className="flex col-span-2 sm:col-span-1 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedClients.requestSort("enabled")}>
Enabled <span className="sort-indicator">{sortedClients.getSortIndicator("enabled")}</span>
</div>
<div
className="col-span-6 sm:col-span-4 lg:col-span-4 pl-12 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedClients.requestSort("name")}
>
Name <span className="sort-indicator">{sortedClients.getSortIndicator("name")}</span>
</div>
<div
className="hidden sm:flex col-span-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedClients.requestSort("host")}
>
Host <span className="sort-indicator">{sortedClients.getSortIndicator("host")}</span>
</div>
<div className="hidden sm:flex col-span-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedClients.requestSort("type")}
>
Type <span className="sort-indicator">{sortedClients.getSortIndicator("type")}</span>
</div>
</li>
{sortedClients.items.map((client, idx) => (
<DownloadClientSettingsListItem client={client} idx={idx} key={idx} />
))}
</ol>
</section>
: <EmptySimple title="No download clients" subtitle="" buttonText="Add new client" buttonAction={toggleAddClient} />
}
</div>
<div className="flex flex-col">
{sortedClients.items.length > 0 ? (
<ul className="min-w-full relative">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
<div className="flex col-span-2 sm:col-span-1 pl-0 sm:pl-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedClients.requestSort("enabled")}>
Enabled <span className="sort-indicator">{sortedClients.getSortIndicator("enabled")}</span>
</div>
<div
className="col-span-6 sm:col-span-4 lg:col-span-4 pl-10 sm:pl-12 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedClients.requestSort("name")}
>
Name <span className="sort-indicator">{sortedClients.getSortIndicator("name")}</span>
</div>
<div
className="hidden sm:flex col-span-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedClients.requestSort("host")}
>
Host <span className="sort-indicator">{sortedClients.getSortIndicator("host")}</span>
</div>
<div className="hidden sm:flex col-span-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedClients.requestSort("type")}
>
Type <span className="sort-indicator">{sortedClients.getSortIndicator("type")}</span>
</div>
</li>
{sortedClients.items.map((client) => (
<ListItem key={client.id} client={client} />
))}
</ul>
) : (
<EmptySimple title="No download clients" subtitle="" buttonText="Add new client" buttonAction={toggleAddClient} />
)}
</div>
</div>
</Section>
);
}

View file

@ -5,7 +5,7 @@
import { Fragment, useRef, useState, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Menu, Switch, Transition } from "@headlessui/react";
import { Menu, Transition } from "@headlessui/react";
import { toast } from "react-hot-toast";
import {
ArrowsRightLeftIcon,
@ -25,6 +25,8 @@ import { EmptySimple } from "@components/emptystates";
import { ImplementationBadges } from "./Indexer";
import { ArrowPathIcon } from "@heroicons/react/24/solid";
import { ExternalLink } from "@components/ExternalLink";
import { Section } from "./_components";
import { Checkbox } from "@components/Checkbox";
export const feedKeys = {
all: ["feeds"] as const,
@ -99,55 +101,47 @@ function FeedSettings() {
const sortedFeeds = useSort(data || []);
return (
<div className="lg:col-span-9">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Feeds</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Manage RSS, Newznab, and Torznab feeds.
</p>
</div>
</div>
{data && data.length > 0 ?
<section className="mt-6 light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md">
<ol className="min-w-full relative">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
<div
className="flex col-span-2 sm:col-span-1 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedFeeds.requestSort("enabled")}>
Enabled <span className="sort-indicator">{sortedFeeds.getSortIndicator("enabled")}</span>
</div>
<div
className="col-span-5 pl-12 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedFeeds.requestSort("name")}>
Name <span className="sort-indicator">{sortedFeeds.getSortIndicator("name")}</span>
</div>
<div
className="hidden md:flex col-span-1 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedFeeds.requestSort("type")}>
Type <span className="sort-indicator">{sortedFeeds.getSortIndicator("type")}</span>
</div>
<div
className="hidden md:flex col-span-2 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedFeeds.requestSort("last_run")}>
Last run <span className="sort-indicator">{sortedFeeds.getSortIndicator("last_run")}</span>
</div>
<div
className="hidden md:flex col-span-2 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedFeeds.requestSort("next_run")}>
Next run <span className="sort-indicator">{sortedFeeds.getSortIndicator("next_run")}</span>
</div>
</li>
{sortedFeeds.items.map((feed) => (
<ListItem key={feed.id} feed={feed} />
))}
</ol>
</section>
: <EmptySimple title="No feeds" subtitle="Setup via indexers" />}
</div>
</div>
<Section
title="Feeds"
description="Manage RSS, Newznab, and Torznab feeds."
>
{data && data.length > 0 ? (
<ul className="min-w-full relative">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
<div
className="flex col-span-2 sm:col-span-1 pl-0 sm:pl-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedFeeds.requestSort("enabled")}>
Enabled <span className="sort-indicator">{sortedFeeds.getSortIndicator("enabled")}</span>
</div>
<div
className="col-span-5 pl-10 sm:pl-12 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedFeeds.requestSort("name")}>
Name <span className="sort-indicator">{sortedFeeds.getSortIndicator("name")}</span>
</div>
<div
className="hidden md:flex col-span-1 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedFeeds.requestSort("type")}>
Type <span className="sort-indicator">{sortedFeeds.getSortIndicator("type")}</span>
</div>
<div
className="hidden md:flex col-span-2 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedFeeds.requestSort("last_run")}>
Last run <span className="sort-indicator">{sortedFeeds.getSortIndicator("last_run")}</span>
</div>
<div
className="hidden md:flex col-span-2 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedFeeds.requestSort("next_run")}>
Next run <span className="sort-indicator">{sortedFeeds.getSortIndicator("next_run")}</span>
</div>
</li>
{sortedFeeds.items.map((feed) => (
<ListItem key={feed.id} feed={feed} />
))}
</ul>
) : (
<EmptySimple title="No feeds" subtitle="Setup via indexers" />
)}
</Section>
);
}
@ -181,26 +175,13 @@ function ListItem({ feed }: ListItemProps) {
<FeedUpdateForm isOpen={updateFormIsOpen} toggle={toggleUpdateForm} feed={feed} />
<div className="grid grid-cols-12 items-center">
<div className="col-span-2 sm:col-span-1 px-6 flex items-center">
<Switch
checked={feed.enabled}
onChange={toggleActive}
className={classNames(
feed.enabled ? "bg-blue-500 dark:bg-blue-500" : "bg-gray-200 dark:bg-gray-600",
"relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
feed.enabled ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
<div className="col-span-2 sm:col-span-1 pl-1 sm:pl-5 flex items-center">
<Checkbox
value={feed.enabled}
setValue={toggleActive}
/>
</div>
<div className="col-span-8 sm:col-span-5 pl-12 py-3 flex flex-col text-sm font-medium text-gray-900 dark:text-white">
<div className="col-span-8 sm:col-span-5 pl-10 sm:pl-12 py-3 flex flex-col text-sm font-medium text-gray-900 dark:text-white">
<span>{feed.name}</span>
<span className="text-gray-900 dark:text-gray-500 text-xs">
{feed.indexer}
@ -308,7 +289,7 @@ const FeedItemDropdown = ({
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute right-0 w-56 mt-2 origin-top-right bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-10 focus:outline-none"
className="absolute right-0 w-56 mt-2 origin-top-right bg-white dark:bg-gray-825 divide-y divide-gray-200 dark:divide-gray-750 rounded-md shadow-lg border border-gray-250 dark:border-gray-750 focus:outline-none z-10"
>
<div className="px-1 py-1">
<Menu.Item>
@ -352,26 +333,18 @@ const FeedItemDropdown = ({
)}
</Menu.Item>
</div>
<div>
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<ExternalLink
href={`${baseUrl()}api/feeds/${feed.id}/latest`}
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"
)}
>
<DocumentTextIcon
className={classNames(
active ? "text-white" : "text-blue-500",
"w-5 h-5 mr-2"
)}
aria-hidden="true"
/>
View latest run
</ExternalLink>
)}
<ExternalLink
href={`${baseUrl()}api/feeds/${feed.id}/latest`}
className="font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm text-gray-900 dark:text-gray-300 hover:bg-blue-600 hover:text-white"
>
<DocumentTextIcon
className="w-5 h-5 mr-2 text-blue-500 group-hover:text-white"
aria-hidden="true"
/>
View latest run
</ExternalLink>
</Menu.Item>
<Menu.Item>
{({ active }) => (

View file

@ -6,16 +6,18 @@
import { useState, useMemo } from "react";
import toast from "react-hot-toast";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Switch } from "@headlessui/react";
import { PlusIcon } from "@heroicons/react/24/solid";
import Toast from "@components/notifications/Toast";
import { IndexerAddForm, IndexerUpdateForm } from "@forms";
import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import { EmptySimple } from "@components/emptystates";
import { APIClient } from "@api/APIClient";
import { Checkbox } from "@components/Checkbox";
import Toast from "@components/notifications/Toast";
import { EmptySimple } from "@components/emptystates";
import { IndexerAddForm, IndexerUpdateForm } from "@forms";
import { componentMapType } from "@forms/settings/DownloadClientForms";
import { Section } from "./_components";
export const indexerKeys = {
all: ["indexers"] as const,
lists: () => [...indexerKeys.all, "list"] as const,
@ -85,7 +87,7 @@ const ImplementationBadgeIRC = () => (
);
const ImplementationBadgeTorznab = () => (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-orange-200 dark:bg-orange-400 text-orange-800 dark:text-orange-800">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-orange-200 dark:bg-orange-400 text-orange-800 dark:text-amber-900">
Torznab
</span>
);
@ -131,6 +133,10 @@ const ListItem = ({ indexer }: ListItemProps) => {
updateMutation.mutate(newState);
};
if (!indexer) {
return null;
}
return (
<li>
<div className="grid grid-cols-12 items-center py-1.5">
@ -139,25 +145,8 @@ const ListItem = ({ indexer }: ListItemProps) => {
toggle={toggleUpdate}
indexer={indexer}
/>
<div className="col-span-2 sm:col-span-1 flex px-6 items-center sm:px-6">
<Switch
onClick={(e) => e.stopPropagation()}
checked={indexer.enabled ?? false}
onChange={onToggleMutation}
className={classNames(
indexer.enabled ? "bg-blue-500" : "bg-gray-200 dark:bg-gray-600",
"relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
<span className="sr-only">Enable</span>
<span
aria-hidden="true"
className={classNames(
indexer.enabled ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
<div className="col-span-2 sm:col-span-1 flex pl-1 sm:pl-5 items-center">
<Checkbox value={indexer.enabled ?? false} setValue={onToggleMutation} />
</div>
<div className="col-span-7 sm:col-span-8 pl-12 sm:pr-6 py-3 block flex-col text-sm font-medium text-gray-900 dark:text-white truncate">
{indexer.name}
@ -194,71 +183,64 @@ function IndexerSettings() {
}
return (
<div className="lg:col-span-9">
<Section
title="Indexers"
description={
<>
Indexer settings for IRC, RSS, Newznab, and Torznab based indexers.<br />
Generic RSS/Newznab/Torznab feeds can be added here by selecting one of the <span className="font-bold">Generic</span> indexers.
</>
}
rightSide={
<button
type="button"
onClick={toggleAddIndexer}
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md 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"
>
<PlusIcon className="h-5 w-5 mr-1" />
Add new
</button>
}
>
<IndexerAddForm isOpen={addIndexerIsOpen} toggle={toggleAddIndexer} />
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">
Indexers
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Indexer settings for IRC, RSS, Newznab, and Torznab based indexers.<br />
Generic feeds can be added here by selecting the Generic indexer.
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
onClick={toggleAddIndexer}
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md 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"
>
Add new
</button>
</div>
</div>
<div className="flex flex-col mt-6">
{data && data.length > 0 ? (
<section className="light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md">
<ol className="min-w-full relative">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
<div
className="flex col-span-2 sm:col-span-1 px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedIndexers.requestSort("enabled")}
>
Enabled <span className="sort-indicator">{sortedIndexers.getSortIndicator("enabled")}</span>
</div>
<div
className="col-span-7 sm:col-span-8 pl-12 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedIndexers.requestSort("name")}
>
Name <span className="sort-indicator">{sortedIndexers.getSortIndicator("name")}</span>
</div>
<div
className="hidden md:flex col-span-1 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedIndexers.requestSort("implementation")}
>
Implementation <span className="sort-indicator">{sortedIndexers.getSortIndicator("implementation")}</span>
</div>
</li>
{sortedIndexers.items.map((indexer) => (
<ListItem indexer={indexer} key={indexer.id} />
))}
</ol>
</section>
) : (
<EmptySimple
title="No indexers"
subtitle=""
buttonText="Add new indexer"
buttonAction={toggleAddIndexer}
/>
)}
</div>
<div className="flex flex-col">
{sortedIndexers.items.length ? (
<ul className="min-w-full relative">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
<div
className="flex col-span-2 sm:col-span-1 pl-0 sm:pl-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
onClick={() => sortedIndexers.requestSort("enabled")}
>
Enabled <span className="sort-indicator">{sortedIndexers.getSortIndicator("enabled")}</span>
</div>
<div
className="col-span-7 sm:col-span-8 pl-12 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
onClick={() => sortedIndexers.requestSort("name")}
>
Name <span className="sort-indicator">{sortedIndexers.getSortIndicator("name")}</span>
</div>
<div
className="hidden md:flex col-span-1 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
onClick={() => sortedIndexers.requestSort("implementation")}
>
Implementation <span className="sort-indicator">{sortedIndexers.getSortIndicator("implementation")}</span>
</div>
</li>
{sortedIndexers.items.map((indexer) => (
<ListItem indexer={indexer} key={indexer.id} />
))}
</ul>
) : (
<EmptySimple
title="No indexers"
subtitle=""
buttonText="Add new indexer"
buttonAction={toggleAddIndexer}
/>
)}
</div>
</div>
</Section>
);
}

View file

@ -5,8 +5,8 @@
import { Fragment, useRef, useState, useMemo, useEffect, MouseEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { LockClosedIcon, LockOpenIcon } from "@heroicons/react/24/solid";
import { Menu, Switch, Transition } from "@headlessui/react";
import { LockClosedIcon, LockOpenIcon, PlusIcon } from "@heroicons/react/24/solid";
import { Menu, Transition } from "@headlessui/react";
import { toast } from "react-hot-toast";
import {
ArrowsPointingInIcon,
@ -29,6 +29,8 @@ 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,
@ -106,112 +108,99 @@ const IrcSettings = () => {
const sortedNetworks = useSort(data || []);
return (
<div className="text-sm lg:col-span-9">
<Section
title="IRC"
description="IRC networks and channels. Click on a network to view channel status."
rightSide={
<button
type="button"
onClick={toggleAddNetwork}
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md 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"
>
<PlusIcon className="h-5 w-5 mr-1" />
Add new
</button>
}
>
<IrcNetworkAddForm isOpen={addNetworkIsOpen} toggle={toggleAddNetwork} />
<div className="py-6 px-4 md:p-6 lg:pb-8">
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap md:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">
IRC
</h3>
<p className="mt-1 text-gray-500 dark:text-gray-400">
IRC networks and channels. Click on a network to view channel
status.
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
onClick={toggleAddNetwork}
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm font-medium rounded-md 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"
<div className="flex justify-between flex-col md:flex-row px-1">
<ul className="flex flex-col md:flex-row md:gap-2 pb-4 md:pb-0 md:divide-x md:divide-gray-200 md:dark:divide-gray-700">
<li className="flex items-center">
<span
className="mr-2 flex h-4 w-4 relative"
title="Network healthy"
>
Add new
</button>
</div>
</div>
<span className="animate-ping inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="inline-flex absolute rounded-full h-4 w-4 bg-green-500" />
</span>
<span className="text-sm text-gray-800 dark:text-gray-500">Network healthy</span>
</li>
<div className="flex justify-between flex-col md:flex-row mt-10 px-1">
<ol className="flex flex-col md:flex-row md:gap-2 pb-4 md:pb-0 md:divide-x md:divide-gray-200 md:dark:divide-gray-700">
<li className="flex items-center">
<span
className="mr-2 flex h-4 w-4 relative"
title="Network healthy"
>
<span className="animate-ping inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="inline-flex absolute rounded-full h-4 w-4 bg-green-500" />
</span>
<span className="text-gray-800 dark:text-gray-500">Network healthy</span>
</li>
<li className="flex items-center md:pl-2">
<span
className="mr-2 flex h-4 w-4 rounded-full opacity-75 bg-yellow-400 over:text-yellow-600"
title="Network unhealthy"
/>
<span className="text-sm text-gray-800 dark:text-gray-500">Network unhealthy</span>
</li>
<li className="flex items-center md:pl-2">
<span
className="mr-2 flex h-4 w-4 rounded-full opacity-75 bg-yellow-400 over:text-yellow-600"
title="Network unhealthy"
/>
<span className="text-gray-800 dark:text-gray-500">Network unhealthy</span>
</li>
<li className="flex items-center md:pl-2">
<span
className="mr-2 flex h-4 w-4 rounded-full opacity-75 bg-gray-500"
title="Network disabled"
>
</span>
<span className="text-gray-800 dark:text-gray-500">Network disabled</span>
</li>
</ol>
<div className="flex gap-x-2">
<button
className="flex items-center text-gray-800 dark:text-gray-400 p-1 px-2 rounded shadow bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={toggleExpand}
title={expandNetworks ? "collapse" : "expand"}
<li className="flex items-center md:pl-2">
<span
className="mr-2 flex h-4 w-4 rounded-full opacity-75 bg-gray-500"
title="Network disabled"
>
{expandNetworks
? <span className="flex items-center">Collapse <ArrowsPointingInIcon className="ml-1 w-4 h-4"/></span>
: <span className="flex items-center">Expand <ArrowsPointingOutIcon className="ml-1 w-4 h-4"/></span>
}</button>
<IRCLogsDropdown/>
</div>
</span>
<span className="text-sm text-gray-800 dark:text-gray-500">Network disabled</span>
</li>
</ul>
<div className="flex gap-x-2">
<button
className="flex items-center text-gray-800 dark:text-gray-400 p-1 px-2 rounded shadow bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={toggleExpand}
title={expandNetworks ? "collapse" : "expand"}
>
{expandNetworks
? <span className="flex items-center text-sm">Collapse <ArrowsPointingInIcon className="ml-1 w-4 h-4" /></span>
: <span className="flex items-center text-sm">Expand <ArrowsPointingOutIcon className="ml-1 w-4 h-4" /></span>
}</button>
<IRCLogsDropdown />
</div>
{data && data.length > 0 ? (
<section className="mt-6 light:bg-white dark:bg-gray-800 light:shadow md:rounded-md">
<ol className="min-w-full relative">
<li className="grid grid-cols-12 gap-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex col-span-2 md:col-span-1 px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedNetworks.requestSort("enabled")}>
Enabled <span className="sort-indicator">{sortedNetworks.getSortIndicator("enabled")}</span>
</div>
<div className="col-span-10 md:col-span-3 px-8 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedNetworks.requestSort("name")}>
Network <span className="sort-indicator">{sortedNetworks.getSortIndicator("name")}</span>
</div>
<div className="hidden md:flex col-span-4 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedNetworks.requestSort("server")}>
Server <span className="sort-indicator">{sortedNetworks.getSortIndicator("server")}</span>
</div>
<div className="hidden md:flex col-span-3 px-5 lg:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedNetworks.requestSort("nick")}>
Nick <span className="sort-indicator">{sortedNetworks.getSortIndicator("nick")}</span>
</div>
</li>
{data &&
sortedNetworks.items.map((network) => (
<ListItem key={network.id} expanded={expandNetworks} network={network} />
))}
</ol>
</section>
) : (
<EmptySimple
title="No networks"
subtitle="Normally set up via Indexers"
buttonText="Add new network"
buttonAction={toggleAddNetwork}
/>
)}
</div>
</div>
{data && data.length > 0 ? (
<ul className="mt-6 min-w-full relative">
<li className="grid grid-cols-12 gap-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex col-span-2 md:col-span-1 pl-0 sm:px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedNetworks.requestSort("enabled")}>
Enabled <span className="sort-indicator">{sortedNetworks.getSortIndicator("enabled")}</span>
</div>
<div className="col-span-10 md:col-span-3 px-8 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedNetworks.requestSort("name")}>
Network <span className="sort-indicator">{sortedNetworks.getSortIndicator("name")}</span>
</div>
<div className="hidden md:flex col-span-4 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedNetworks.requestSort("server")}>
Server <span className="sort-indicator">{sortedNetworks.getSortIndicator("server")}</span>
</div>
<div className="hidden md:flex col-span-3 px-5 lg:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer"
onClick={() => sortedNetworks.requestSort("nick")}>
Nick <span className="sort-indicator">{sortedNetworks.getSortIndicator("nick")}</span>
</div>
</li>
{sortedNetworks.items.map((network) => (
<ListItem key={network.id} expanded={expandNetworks} network={network} />
))}
</ul>
) : (
<EmptySimple
title="No networks"
subtitle="Normally set up via Indexers"
buttonText="Add new network"
buttonAction={toggleAddNetwork}
/>
)}
</Section>
);
};
@ -245,7 +234,7 @@ const ListItem = ({ network, expanded }: ListItemProps) => {
<li>
<div
className={classNames(
"grid grid-cols-12 gap-2 lg:gap-4 items-center py-2 cursor-pointer",
"grid grid-cols-12 gap-2 lg:gap-4 items-center mt-0.5 py-2.5 cursor-pointer first:rounded-t-md last:rounded-b-md transition",
network.enabled && !network.healthy ? "bg-red-50 dark:bg-red-900 hover:bg-red-100 dark:hover:bg-red-800" : "hover:bg-gray-50 dark:hover:bg-gray-700"
)}
onClick={(e) => {
@ -261,25 +250,11 @@ const ListItem = ({ network, expanded }: ListItemProps) => {
toggle={toggleUpdate}
network={network}
/>
<div className="col-span-2 md:col-span-1 flex pl-5 text-gray-500 dark:text-gray-400">
<Switch
onClick={(e) => e.stopPropagation()}
checked={network.enabled}
onChange={onToggleMutation}
className={classNames(
network.enabled ? "bg-blue-500" : "bg-gray-200 dark:bg-gray-600",
"items-center relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
<span className="sr-only">Enable</span>
<span
aria-hidden="true"
className={classNames(
network.enabled ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
<div className="col-span-2 md:col-span-1 flex pl-1 sm:pl-5 text-gray-500 dark:text-gray-400">
<Checkbox
value={network.enabled}
setValue={onToggleMutation}
/>
</div>
<div className="col-span-8 xs:col-span-3 md:col-span-3 items-center pl-8 font-medium text-gray-900 dark:text-white cursor-pointer">
<div className="flex">
@ -305,7 +280,7 @@ const ListItem = ({ network, expanded }: ListItemProps) => {
<span className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-gray-500" />
)}
</span>
<div className="block truncate">
<div className="block text-sm truncate">
{network.name}
</div>
</div>
@ -330,13 +305,13 @@ const ListItem = ({ network, expanded }: ListItemProps) => {
)} />
)}
</div>
<p className="block truncate">
<p className="block text-sm truncate">
{network.server}:{network.port}
</p>
</div>
</div>
<div className="hidden md:flex col-span-3 items-center md:pl-6 text-gray-500 dark:text-gray-400">
<div className="block truncate">
<div className="block text-sm truncate">
{network.nick}
</div>
</div>
@ -345,25 +320,25 @@ const ListItem = ({ network, expanded }: ListItemProps) => {
</div>
</div>
{(edit || expanded) && (
<div className="px-4 py-4 flex border-b border-x-0 dark:border-gray-600 dark:bg-gray-700">
<div className="px-4 py-4 flex border-b border-x-0 dark:border-gray-600 dark:bg-gray-775">
<div className="min-w-full">
{network.channels.length > 0 ? (
<ol>
<ul>
<li className="grid grid-cols-12 gap-4 border-b border-gray-200 dark:border-gray-700">
<div className="col-span-4 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<div className="col-span-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Channel
</div>
<div className="col-span-4 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<div className="col-span-4 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Monitoring since
</div>
<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">
<div className="col-span-3 sm:px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Last announce
</div>
</li>
{network.channels.map((c) => (
<ChannelItem key={`${network.id}.${c.id}`} network={network} channel={c} />
))}
</ol>
</ul>
) : (
<div className="flex text-center justify-center py-4 dark:text-gray-500">
<p>No channels!</p>
@ -387,12 +362,12 @@ const ChannelItem = ({ network, channel }: ChannelItemProps) => {
return (
<li
className={classNames(
"mb-2 text-gray-500 dark:text-gray-400",
viewChannel ? "bg-gray-200 dark:bg-gray-800 rounded-md" : ""
"mb-2 text-gray-500 dark:text-gray-400 hover:cursor-pointer rounded-md",
viewChannel ? "bg-gray-200 dark:bg-gray-800 rounded-md" : "hover:bg-gray-300 dark:hover:bg-gray-800"
)}
>
<div
className="grid grid-cols-12 gap-4 items-center py-4 hover:bg-gray-300 dark:hover:bg-gray-800 hover:cursor-pointer rounded-md"
className="grid grid-cols-12 gap-4 items-center py-4 "
onClick={toggleView}
>
<div className="col-span-4 flex items-center md:px-6">
@ -403,14 +378,14 @@ const ChannelItem = ({ network, channel }: ChannelItemProps) => {
className="mr-3 flex h-3 w-3 relative"
title="monitoring"
>
<span className="animate-ping inline-flex h-full w-full rounded-full bg-green-400 opacity-75"/>
<span className="inline-flex absolute rounded-full h-3 w-3 bg-green-500"/>
<span className="animate-ping inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="inline-flex absolute rounded-full h-3 w-3 bg-green-500" />
</span>
) : (
<span className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-red-400"/>
<span className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-red-400" />
)
) : (
<span className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-gray-500"/>
<span className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-gray-500" />
)}
{channel.name}
</span>
@ -432,7 +407,7 @@ const ChannelItem = ({ network, channel }: ChannelItemProps) => {
</div>
</div>
{viewChannel && (
<Events network={network} channel={channel.name}/>
<Events network={network} channel={channel.name} />
)}
</li>
);
@ -459,7 +434,7 @@ const ListItemDropdown = ({
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}/>);
toast.custom((t) => <Toast type="success" body={`Network ${network.name} was deleted`} t={t} />);
toggleDeleteModal();
}
@ -471,7 +446,7 @@ const ListItemDropdown = ({
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}/>);
toast.custom((t) => <Toast type="success" body={`${network.name} was successfully restarted`} t={t} />);
}
});
@ -514,7 +489,7 @@ const ListItemDropdown = ({
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute right-0 w-32 md:w-56 mt-2 origin-top-right bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-10 focus:outline-none"
className="absolute right-0 w-56 mt-2 origin-top-right bg-white dark:bg-gray-825 divide-y divide-gray-200 dark:divide-gray-750 rounded-md shadow-lg border border-gray-250 dark:border-gray-750 focus:outline-none z-10"
>
<div className="px-1 py-1">
<Menu.Item>
@ -623,7 +598,6 @@ interface EventsProps {
}
export const Events = ({ network, channel }: EventsProps) => {
const [logs, setLogs] = useState<IrcEvent[]>([]);
const [settings] = SettingsContext.use();
@ -700,8 +674,8 @@ export const Events = ({ network, channel }: EventsProps) => {
onClick={toggleFullscreen}
>
{isFullscreen
? <span className="flex items-center"><ArrowsPointingInIcon className="w-5 h-5"/></span>
: <span className="flex items-center"><ArrowsPointingOutIcon className="w-5 h-5"/></span>}
? <span className="flex items-center"><ArrowsPointingInIcon className="w-5 h-5" /></span>
: <span className="flex items-center"><ArrowsPointingOutIcon className="w-5 h-5" /></span>}
</button>
</div>
<div
@ -759,7 +733,7 @@ const IRCLogsDropdown = () => {
return (
<Menu as="div" className="relative">
<Menu.Button className="flex items-center text-gray-800 dark:text-gray-400 p-1 px-2 rounded shadow bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600">
<span className="flex items-center">Options <Cog6ToothIcon className="ml-1 w-4 h-4"/></span>
<span className="flex items-center">Options <Cog6ToothIcon className="ml-1 w-4 h-4" /></span>
</Menu.Button>
<Transition
as={Fragment}
@ -771,7 +745,7 @@ const IRCLogsDropdown = () => {
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute z-10 right-0 mt-2 px-3 py-2 bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-10 focus:outline-none"
className="absolute z-10 right-0 mt-2 px-3 py-2 bg-white dark:bg-gray-825 divide-y divide-gray-200 dark:divide-gray-750 rounded-md shadow-lg border border-gray-750 focus:outline-none"
>
<Menu.Item>
{() => (

View file

@ -5,126 +5,55 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast";
import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select";
import { Link } from "react-router-dom";
import Select from "react-select";
import { APIClient } from "@api/APIClient";
import { GithubRelease } from "@app/types/Update";
import Toast from "@components/notifications/Toast";
import { LogLevelOptions, SelectOption } from "@domain/constants";
import { Section, RowItem } from "./_components";
import * as common from "@components/inputs/common";
import { LogFiles } from "@screens/Logs";
interface RowItemProps {
label: string;
value?: string;
title?: string;
emptyText?: string;
newUpdate?: GithubRelease;
}
const RowItem = ({ label, value, title, emptyText }: RowItemProps) => {
return (
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
<dt className="font-medium text-gray-900 dark:text-white text-sm" title={title}>{label}</dt>
<dd className="mt-1 text-gray-900 dark:text-white text-sm sm:mt-0 sm:col-span-2 break-all truncate">
<span className="px-1.5 py-1 bg-gray-200 dark:bg-gray-700 rounded shadow">{value ? value : emptyText}</span>
</dd>
</div>
);
type SelectWrapperProps = {
id: string;
value: unknown;
onChange: any;
options: unknown[];
};
interface RowItemNumberProps {
label: string;
value?: string | number;
title?: string;
unit?: string;
}
const RowItemNumber = ({ label, value, title, unit }: RowItemNumberProps) => {
return (
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
<dt className="font-medium text-gray-900 dark:text-white text-sm" title={title}>{label}</dt>
<dd className="mt-1 text-gray-900 dark:text-white text-sm sm:mt-0 sm:col-span-2 break-all truncate">
<span className="px-1.5 py-1 bg-gray-200 dark:bg-gray-700 rounded shadow truncate">{value}</span>
{unit &&
<span className="ml-1 text-sm text-gray-700 dark:text-gray-400">{unit}</span>
}
</dd>
</div>
);
};
const Input = (props: InputProps) => {
return (
<components.Input
{...props}
inputClassName="outline-none border-none shadow-none focus:ring-transparent"
className="text-gray-400 dark:text-gray-100"
children={props.children}
/>
);
};
const Control = (props: ControlProps) => {
return (
<components.Control
{...props}
className="p-1 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
children={props.children}
/>
);
};
const Menu = (props: MenuProps) => {
return (
<components.Menu
{...props}
className="dark:bg-gray-800 border border-gray-300 dark:border-gray-700 dark:text-gray-400 rounded-md shadow-sm"
children={props.children}
/>
);
};
const Option = (props: OptionProps) => {
return (
<components.Option
{...props}
className="dark:text-gray-400 dark:bg-gray-800 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
children={props.children}
/>
);
};
const RowItemSelect = ({ id, title, label, value, options, onChange }: any) => {
return (
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6">
<dt className="font-medium text-gray-500 dark:text-white" title={title}>{label}:</dt>
<dd className="mt-1 text-gray-900 dark:text-white sm:mt-0 sm:col-span-2 break-all">
<Select
id={id}
components={{ Input, Control, Menu, Option }}
placeholder="Choose a type"
styles={{
singleValue: (base) => ({
...base,
color: "unset"
})
}}
theme={(theme) => ({
...theme,
spacing: {
...theme.spacing,
controlHeight: 30,
baseUnit: 2
}
})}
value={value && options.find((o: any) => o.value == value)}
onChange={onChange}
options={options}
/>
</dd>
</div>
);
};
const SelectWrapper = ({ id, value, onChange, options }: SelectWrapperProps) => (
<Select
id={id}
components={{
Input: common.SelectInput,
Control: common.SelectControl,
Menu: common.SelectMenu,
Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}}
placeholder="Choose a type"
styles={{
singleValue: (base) => ({
...base,
color: "unset"
})
}}
theme={(theme) => ({
...theme,
spacing: {
...theme.spacing,
controlHeight: 30,
baseUnit: 2
}
})}
value={value && options.find((o: any) => o.value == value)}
onChange={onChange}
options={options}
/>
);
function LogSettings() {
const { isLoading, data } = useQuery({
@ -140,58 +69,58 @@ function LogSettings() {
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}/>);
toast.custom((t) => <Toast type="success" body={"Config successfully updated!"} t={t} />);
queryClient.invalidateQueries({ queryKey: ["config"] });
}
});
return (
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Logs</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Set level, size etc.
</p>
</div>
<Section
title="Logs"
description={
<>
Configure log level, log size rotation, etc. You can download your old log files
{" "}
<Link
to="/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
</Link>.
</>
}
>
<div className="-mx-4 lg:col-span-9">
<div className="divide-y divide-gray-200 dark:divide-gray-750">
{!isLoading && data && (
<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
className="sm:col-span-1"
label="Level"
title="Log level"
value={
<SelectWrapper
id="log_level"
value={data?.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"/>
</form>
)}
<div className="px-6 pt-4">
<LogFiles/>
</div>
</div>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="px-4 py-5 sm:p-0">
<form className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9" action="#" method="POST">
{!isLoading && data && (
<dl className="sm:divide-y divide-gray-200 dark:divide-gray-700">
<RowItem label="Path" value={data?.log_path} title="Set in config.toml" emptyText="Not set!" />
<RowItemSelect id="log_level" label="Level" value={data?.log_level} title="Log level" options={LogLevelOptions} onChange={(value: SelectOption) => setLogLevelUpdateMutation.mutate(value.value)} />
<RowItemNumber label="Max Size" value={data?.log_max_size} title="Set in config.toml" unit="MB" />
<RowItemNumber label="Max Backups" value={data?.log_max_backups} title="Set in config.toml" />
</dl>
)}
</form>
</div>
<div className="mt-4 flex flex-col py-4 px-4 sm:px-6">
<LogFiles />
</div>
{/*<div className="mt-4 flex justify-end py-4 px-4 sm:px-6">*/}
{/* <button*/}
{/* type="button"*/}
{/* className="inline-flex justify-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"*/}
{/* >*/}
{/* Cancel*/}
{/* </button>*/}
{/* <button*/}
{/* type="submit"*/}
{/* className="ml-5 inline-flex justify-center rounded-md border border-transparent bg-blue-700 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"*/}
{/* >*/}
{/* Save*/}
{/* </button>*/}
{/*</div>*/}
</div>
</div>
</Section>
);
}

View file

@ -4,16 +4,17 @@
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Switch } from "@headlessui/react";
import { APIClient } from "@api/APIClient";
import { EmptySimple } from "@components/emptystates";
import { useToggle } from "@hooks/hooks";
import { NotificationAddForm, NotificationUpdateForm } from "@forms/settings/NotificationForms";
import { classNames } from "@utils";
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 { Checkbox } from "@components/Checkbox";
export const notificationKeys = {
all: ["notifications"] as const,
@ -28,50 +29,42 @@ function NotificationSettings() {
const { data } = useQuery({
queryKey: notificationKeys.lists(),
queryFn: APIClient.notifications.getAll,
refetchOnWindowFocus: false }
refetchOnWindowFocus: false
}
);
return (
<div className="lg:col-span-9">
<Section
title="Notifications"
description="Send notifications on events."
rightSide={
<button
type="button"
onClick={toggleAddNotifications}
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md 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"
>
<PlusIcon className="h-5 w-5 mr-1" />
Add new
</button>
}
>
<NotificationAddForm isOpen={addNotificationsIsOpen} toggle={toggleAddNotifications} />
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Notifications</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Send notifications on events.
</p>
</div>
<div className="ml-4 mt-4 flex-shrink-0">
<button
type="button"
onClick={toggleAddNotifications}
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md 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"
>
Add new
</button>
</div>
</div>
{data && 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>
<div className="col-span-6 pl-10 sm:pl-12 pr-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name</div>
<div className="hidden md:flex col-span-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Type</div>
<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 && data.length > 0 ?
<section className="mt-6 light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md">
<ol 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-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Enabled</div>
<div className="col-span-6 pl-10 md:pl-12 pr-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name</div>
<div className="hidden md:flex col-span-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Type</div>
<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 && data.map((n: ServiceNotification) => (
<ListItem key={n.id} notification={n} />
))}
</ol>
</section>
: <EmptySimple title="No notifications" subtitle="" buttonText="Create new notification" buttonAction={toggleAddNotifications} />}
</div>
</div>
{data.map((n) => <ListItem key={n.id} notification={n} />)}
</ul>
) : (
<EmptySimple title="No notifications" subtitle="" buttonText="Create new notification" buttonAction={toggleAddNotifications} />
)}
</Section>
);
}
@ -95,14 +88,14 @@ const TelegramIcon = () => (
const PushoverIcon = () => (
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" className="mr-2 h-4">
<path d="m495.6 319.4 104-13.7-101.3 228.6c17.8-1.4 35.2-7.4 52.3-18.1 17.1-10.7 32.9-24.2 47.2-40.4 14.4-16.2 26.8-34.2 37.3-54.1 10.5-19.8 18-39.4 22.6-58.5 2.7-11.9 4-23.3 3.8-34.2-.2-10.9-3.1-20.5-8.6-28.7s-13.8-14.8-25-19.8-26.3-7.5-45.5-7.5c-22.4 0-44.4 3.6-66 10.9-21.7 7.3-41.7 17.9-60.2 31.8-18.5 13.9-34.5 31.2-48.2 52-13.7 20.8-23.5 44.4-29.4 70.8-2.3 8.7-3.6 15.6-4.1 20.9-.5 5.3-.6 9.6-.3 13 .2 3.4.7 6.1 1.4 7.9.7 1.8 1.3 3.6 1.7 5.5-23.3 0-40.3-4.7-51-14-10.7-9.3-13.3-25.7-7.9-48.9 5.5-24.2 17.9-47.2 37.3-69.1 19.4-21.9 42.4-41.2 69.1-57.8 26.7-16.6 55.9-29.9 87.6-39.7 31.7-9.8 62.6-14.7 92.7-14.7 26.5 0 48.7 3.8 66.7 11.3 18 7.5 32.1 17.5 42.1 29.8s16.3 26.7 18.8 43.1c2.5 16.4 1.7 33.5-2.4 51.3-5 21.4-14.5 43-28.4 64.7-13.9 21.7-31.4 41.3-52.3 58.8-21 17.6-45 31.8-72.2 42.8-27.1 10.9-56 16.4-86.6 16.4h-3.4l-86.9 195H302l193.6-435.4z"
clipRule="evenodd" fill="currentColor" fillRule="evenodd"/>
clipRule="evenodd" fill="currentColor" fillRule="evenodd" />
</svg>
);
const GotifyIcon = () => (
<svg viewBox="0 0 140 140" xmlns="http://www.w3.org/2000/svg" className="mr-2 h-4">
<path d="m 114.5,21.4 c -11.7,0 -47.3,5.9 -54.3,7.1 -47.3,8.0 -48.4,9.9 -50.1,12.8 -1.2,2.1 -2.4,4.0 2.6,29.4 2.3,11.5 5.8,26.9 8.8,35.8 1.8,5.4 3.6,8.8 6.9,10.1 0.8,0.3 1.7,0.5 2.7,0.6 0.2,0.0 0.3,0.0 0.5,0.0 12.8,0 89.1,-19.5 89.9,-19.7 1.4,-0.4 4.0,-1.5 5.3,-5.1 1.8,-4.7 1.9,-16.7 0.5,-35.7 -2.1,-28.0 -4.1,-31.0 -4.8,-32.0 -2.0,-3.1 -5.6,-3.3 -6.7,-3.3 -0.4,-0.0 -0.9,-0.0 -1.4,-0.0 z m -1.9,6.6 c -9.3,12.0 -18.9,24.0 -25.9,32.4 -2.3,2.8 -4.3,5.1 -6.0,7.0 -1.7,1.9 -2.9,3.2 -3.8,4.0 l -0.3,0.3 -0.4,-0.1 c -1.0,-0.3 -2.5,-0.9 -4.4,-1.7 -2.3,-1.0 -5.2,-2.3 -8.8,-3.9 C 51.6,60.7 34.4,52.2 18.0,43.6 30.3,39.7 95.0,28.7 112.6,27.9 Z m 5.7,5.0 c 2.0,11.8 4.5,42.6 3.1,54.0 -1.8,-1.4 -10.1,-8.0 -19.8,-15.2 -3.0,-2.3 -5.9,-4.3 -8.4,-6.1 l -0.7,-0.5 0.5,-0.6 C 99.5,56.9 108.0,46.2 118.3,32.9 Z M 16.1,51.1 c 3.0,1.5 14.3,7.4 27.4,13.8 5.3,2.6 9.9,4.8 13.9,6.7 l 0.9,0.4 -0.7,0.8 C 50.3,81.2 40.6,92.8 28.8,107.2 24.5,96.7 17.9,65.0 16.1,51.1 Z m 71.5,19.7 0.6,0.4 c 7.8,5.5 18.1,13.2 27.9,21.0 C 104.9,95.1 53.2,107.9 36.0,110.3 46.6,97.4 57.3,84.7 65.1,75.8 l 0.4,-0.4 0.5,0.2 c 5.7,2.5 9.3,3.7 11.1,3.8 0.1,0.0 0.2,0.0 0.3,0.0 0.6,0 1.0,-0.1 1.4,-0.3 0.6,-0.2 2.0,-0.7 8.3,-7.7 z"
clipRule="evenodd" fill="currentColor" fillRule="evenodd"/>
clipRule="evenodd" fill="currentColor" fillRule="evenodd" />
</svg>
);
@ -143,27 +136,14 @@ function ListItem({ notification }: ListItemProps) {
<li key={notification.id} className="text-gray-500 dark:text-gray-400">
<NotificationUpdateForm isOpen={updateFormIsOpen} toggle={toggleUpdateForm} notification={notification} />
<div className="grid grid-cols-12 items-center py-4">
<div className="col-span-2 sm:col-span-1 px-6 flex items-center ">
<Switch
checked={notification.enabled}
onChange={onToggleMutation}
className={classNames(
notification.enabled ? "bg-blue-500" : "bg-gray-200 dark:bg-gray-600",
"relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
notification.enabled ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
<div className="grid grid-cols-12 items-center py-2">
<div className="col-span-2 sm:col-span-1 pl-1 py-0.5 sm:pl-5 flex items-center">
<Checkbox
value={notification.enabled}
setValue={onToggleMutation}
/>
</div>
<div className="col-span-8 md:col-span-6 pl-10 md:pl-12 pr-2 sm:pr-6 truncate block items-center text-sm font-medium text-gray-900 dark:text-white" title={notification.name}>
<div className="col-span-8 md:col-span-6 pl-10 sm:pl-12 pr-2 sm:pr-6 truncate block items-center text-sm font-medium text-gray-900 dark:text-white" title={notification.name}>
{notification.name}
</div>
<div className="hidden md:flex col-span-2 items-center">
@ -179,13 +159,12 @@ function ListItem({ notification }: ListItemProps) {
</div>
<div className="col-span-1 flex first-letter:px-6 whitespace-nowrap text-right text-sm font-medium">
<span
className="col-span-1 px-6 text-blue-600 dark:text-gray-300 hover:text-blue-900 dark:hover:text-blue-500 cursor-pointer"
className="col-span-1 px-0 sm:px-6 text-blue-600 dark:text-gray-300 hover:text-blue-900 dark:hover:text-blue-500 cursor-pointer"
onClick={toggleUpdateForm}
>
Edit
</span>
</div>
</div>
</li>
);

View file

@ -60,11 +60,11 @@ const RegexPlayground = () => {
return (
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div className="py-6 px-4 sm:p-6">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Application</h2>
<h2 className="text-lg leading-4 font-bold text-gray-900 dark:text-white">Application</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Regex playground. Experiment with your filters here. WIP.
Regex playground. Experiment with your filters here. WIP.
</p>
</div>
</div>
@ -73,20 +73,20 @@ const RegexPlayground = () => {
htmlFor="input-regex"
className="block text-sm font-medium text-gray-600 dark:text-gray-300"
>
RegExp filter
RegExp filter
</label>
<input
ref={regexRef}
id="input-regex"
type="text"
autoComplete="true"
className="mt-1 mb-4 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
className="mt-1 mb-4 block w-full border rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-815 dark:text-gray-100 sm:text-sm"
/>
<label
htmlFor="input-lines"
className="block text-sm font-medium text-gray-600 dark:text-gray-300"
>
Lines to match
Lines to match
</label>
<div
id="input-lines"
@ -95,10 +95,10 @@ const RegexPlayground = () => {
contentEditable
></div>
</div>
<div className="py-4 px-4 sm:p-6 lg:pb-8">
<div className="py-6 px-4 sm:p-6">
<div>
<h3 className="text-md leading-6 font-medium text-gray-900 dark:text-white">
Matches
Matches
</h3>
<p className="mt-1 text-lg text-gray-500 dark:text-gray-400">
{output}

View file

@ -12,40 +12,30 @@ 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";
function ReleaseSettings() {
return (
<div className="lg:col-span-9">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
const ReleaseSettings = () => (
<Section
title="Releases"
description="Manage release history."
>
<div className="border border-red-500 rounded">
<div className="py-6 px-4 sm:p-6">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">
Releases
</h2>
<h2 className="text-lg leading-4 font-bold text-gray-900 dark:text-white">Danger zone</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Manage release history.
This will clear release history in your database
</p>
</div>
</div>
<div className="py-6 px-4">
<div className="border border-red-500 rounded">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Danger zone</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
This will clear release history in your database
</p>
</div>
</div>
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<DeleteReleases />
</div>
</div>
<div className="py-6 px-4 sm:p-6">
<DeleteReleases />
</div>
</div>
);
}
</Section>
);
const getDurationLabel = (durationValue: number): string => {
const durationOptions: Record<number, string> = {
@ -98,7 +88,7 @@ function DeleteReleases() {
};
return (
<div className="flex justify-between items-center rounded-md">
<div className="flex flex-col sm:flex-row gap-2 justify-between items-center rounded-md">
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={deleteOlderMutation.isLoading}
@ -113,7 +103,7 @@ function DeleteReleases() {
<p className="text-sm font-medium text-gray-900 dark:text-white">Delete</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Delete releases older than select duration</p>
</label>
<div>
<div className="flex flex-wrap gap-2">
<select
name="duration"
id="duration"
@ -139,7 +129,7 @@ function DeleteReleases() {
<button
type="button"
onClick={toggleDeleteModal}
className="ml-2 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-red-700 hover:text-red-800 dark:text-white bg-red-200 dark:bg-red-700 hover:bg-red-300 dark:hover:bg-red-800 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-red-600"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-red-700 hover:text-red-800 dark:text-white bg-red-200 dark:bg-red-700 hover:bg-red-300 dark:hover:bg-red-800 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-red-600"
>
Delete
</button>

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { classNames } from "@utils";
type SectionProps = {
title: string;
description: string | React.ReactNode;
rightSide?: React.ReactNode;
children?: React.ReactNode;
};
export const Section = ({
title,
description,
rightSide,
children
}: SectionProps) => (
<div className="pb-6 px-4 lg:col-span-9">
<div
className={classNames(
"mt-6 mb-4",
rightSide
? "flex justify-between items-start flex-wrap sm:flex-nowrap gap-2"
: ""
)}
>
<div className="sm:px-2">
<h2 className="text-lg leading-4 font-bold text-gray-900 dark:text-white">{title}</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>
</div>
<div className="flex-shrink-0">
{rightSide ?? null}
</div>
</div>
{children}
</div>
);
interface RowItemProps {
label: string;
value?: string | React.ReactNode;
title?: string;
emptyText?: string;
rightSide?: React.ReactNode;
className?: string;
}
export const RowItem = ({
label,
value,
title,
emptyText,
rightSide,
className = "sm:col-span-3"
}: RowItemProps) => (
<div className="p-4 sm:px-6 sm:grid sm:grid-cols-4 sm:gap-4">
<div className="font-medium text-gray-900 dark:text-white text-sm self-center" title={title}>
{label}
</div>
<div
className={classNames(
className,
"mt-1 text-gray-900 dark:text-gray-300 text-sm break-all sm:mt-0"
)}
>
{value
? (
<>
{typeof (value) === "string" ? (
<span className="px-1.5 py-1 bg-gray-200 dark:bg-gray-700 rounded shadow text-ellipsis leading-7">
{value}
</span>
) : value}
{rightSide ?? null}
</>
)
: (emptyText ?? null)
}
</div>
</div>
);

View file

@ -1,4 +1,7 @@
const colors = require("tailwindcss/colors")
import { lerpColors } from "tailwind-lerp-colors";
import plugin from "tailwindcss/plugin";
const extendedColors = lerpColors();
module.exports = {
content: [
@ -23,11 +26,21 @@ module.exports = {
theme: {
extend: {
colors: {
gray: colors.zinc,
...extendedColors,
gray: {
...extendedColors.zinc,
815: "#232427"
}
},
margin: { // for the checkmarks used for regex validation in Filters/Advanced
"2.5": "0.625rem", // 10px, between mb-2 (8px) and mb-3 (12px)
},
textShadow: {
DEFAULT: "0 2px 4px var(--tw-shadow-color)"
},
boxShadow: {
table: "rgba(0, 0, 0, 0.1) 0px 4px 16px 0px"
}
},
},
variants: {
@ -35,5 +48,14 @@ module.exports = {
},
plugins: [
require("@tailwindcss/forms"),
plugin(function ({ matchUtilities, theme }) {
// Pipe --tw-shadow-color (i.e. shadow-cyan-500/50) to our new text-shadow
// Credits: https://www.hyperui.dev/blog/text-shadow-with-tailwindcss
// Use it like: text-shadow shadow-cyan-500/50
matchUtilities(
{ "text-shadow": (value) => ({ textShadow: value }) },
{ values: theme("textShadow") }
);
}),
],
}