mirror of
https://github.com/idanoo/autobrr
synced 2025-07-22 16:29:12 +00:00
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:
parent
a274d9ddce
commit
e842a7bd42
84 changed files with 4378 additions and 4361 deletions
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
16
web/pnpm-lock.yaml
generated
|
@ -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'}
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -29,4 +29,4 @@ export const SectionLoader = ({ $size }: SectionLoaderProps) => {
|
|||
<RingResizeSpinner className={classNames(SIZE[$size], "text-blue-500")} />
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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!";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
35
web/src/components/alerts/Warning.tsx
Normal file
35
web/src/components/alerts/Warning.tsx
Normal 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>
|
||||
);
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
);
|
||||
|
|
|
@ -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) => (
|
||||
<>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
);
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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))}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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" }
|
||||
];
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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> = {
|
||||
|
|
315
web/src/screens/filters/sections/Actions.tsx
Normal file
315
web/src/screens/filters/sections/Actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
458
web/src/screens/filters/sections/Advanced.tsx
Normal file
458
web/src/screens/filters/sections/Advanced.tsx
Normal 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>
|
||||
);
|
370
web/src/screens/filters/sections/External.tsx
Normal file
370
web/src/screens/filters/sections/External.tsx
Normal 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;
|
||||
}
|
||||
};
|
123
web/src/screens/filters/sections/General.tsx
Normal file
123
web/src/screens/filters/sections/General.tsx
Normal 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>
|
||||
);
|
||||
};
|
199
web/src/screens/filters/sections/MoviesAndTV.tsx
Normal file
199
web/src/screens/filters/sections/MoviesAndTV.tsx
Normal 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>
|
||||
);
|
187
web/src/screens/filters/sections/Music.tsx
Normal file
187
web/src/screens/filters/sections/Music.tsx
Normal 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>
|
||||
);
|
157
web/src/screens/filters/sections/_components.tsx
Normal file
157
web/src/screens/filters/sections/_components.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
|
@ -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>
|
||||
</>
|
||||
);
|
|
@ -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>
|
||||
</>
|
||||
);
|
|
@ -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>
|
||||
</>
|
||||
);
|
|
@ -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>
|
||||
</>
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -0,0 +1,6 @@
|
|||
export * from "./ActionDeluge";
|
||||
export * from "./ActionQBittorrent";
|
||||
export * from "./ActionRTorrent";
|
||||
export * from "./ActionTransmission";
|
||||
export * from "./ActionPorla";
|
||||
export * from "./OtherActions";
|
5
web/src/screens/filters/sections/action_components/shared.d.ts
vendored
Normal file
5
web/src/screens/filters/sections/action_components/shared.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
interface ClientActionProps {
|
||||
idx: number;
|
||||
action: Action;
|
||||
clients: DownloadClient[];
|
||||
}
|
6
web/src/screens/filters/sections/index.ts
Normal file
6
web/src/screens/filters/sections/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export * from "./General";
|
||||
export * from "./Advanced";
|
||||
export * from "./MoviesAndTV";
|
||||
export * from "./Music";
|
||||
export * from "./External";
|
||||
export * from "./Actions";
|
|
@ -141,7 +141,7 @@ export const PushStatusSelectColumnFilter = ({
|
|||
</ListboxFilter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const SearchColumnFilter = ({
|
||||
column: { filterValue, setFilter, id }
|
||||
|
@ -163,4 +163,4 @@ export const SearchColumnFilter = ({
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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 "> </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>
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }) => (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
{() => (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
84
web/src/screens/settings/_components.tsx
Normal file
84
web/src/screens/settings/_components.tsx
Normal 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>
|
||||
);
|
|
@ -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") }
|
||||
);
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue