enhancement(web): ui overhaul (#1155)

* Various WebUI changes and fixes.

* feat(tooltip): make tooltip display upwards

* fix(tooltip): place tooltip to the right

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

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

* fix(web): collapse sections

* fix(web):  improve freeleech section

* fix(web): rename action to action_components

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

* fix(web): align CollapsibleSection

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

* fix(web): remove invalid CSS class

* revert: vertical padding on switchgroup

added py-0 on the freeleech part instead

* feat(settings): add back log files

* fix(settings): irc channels and font sizes

* fix(components): radio select roundness

* fix(styling): various minor changes

* fix(filters): remove jitter fields

---------

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

View file

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

View file

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

View file

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

View file

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

16
web/pnpm-lock.yaml generated
View file

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

View file

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

View file

@ -4,36 +4,82 @@
*/ */
import { Switch } from "@headlessui/react"; import { Switch } from "@headlessui/react";
import { classNames } from "@utils";
interface CheckboxProps { interface CheckboxProps {
value: boolean; value: boolean;
setValue: (newValue: boolean) => void; setValue: (newValue: boolean) => void;
label: string; label?: string;
description?: string; description?: string;
className?: string;
disabled?: boolean;
} }
export const Checkbox = ({ label, description, value, setValue }: CheckboxProps) => ( export const Checkbox = ({
<Switch.Group as="li" className="py-4 flex items-center justify-between"> label,
<div className="flex flex-col"> description,
<Switch.Label as="p" className="text-sm font-medium whitespace-nowrap text-gray-900 dark:text-white" passive> value,
{label} className,
</Switch.Label> setValue,
{description === undefined ? null : ( disabled
<Switch.Description className="text-sm text-gray-500 dark:text-gray-400"> }: CheckboxProps) => (
{description} <Switch.Group
</Switch.Description> as="div"
)} className={classNames(className ?? "py-2", "flex items-center justify-between")}
</div> 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 <Switch
checked={value} checked={value}
onChange={setValue} onChange={(newValue) => {
className={ !disabled && setValue(newValue);
`${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`} 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 <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>
</Switch.Group> </Switch.Group>
); );

View file

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

View file

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

View file

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

View file

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

View file

@ -3,34 +3,5 @@
* SPDX-License-Identifier: GPL-2.0-or-later * SPDX-License-Identifier: GPL-2.0-or-later
*/ */
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; export * from "./ErrorPage";
export * from "./Warning";
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";

View file

@ -32,7 +32,10 @@ export const PageButton = ({ children, className, disabled, onClick }: ButtonPro
type="button" type="button"
className={classNames( className={classNames(
className ?? "", 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} disabled={disabled}
onClick={onClick} onClick={onClick}

View file

@ -8,7 +8,7 @@ import { toast } from "react-hot-toast";
import { formatDistanceToNowStrict } from "date-fns"; import { formatDistanceToNowStrict } from "date-fns";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/solid"; 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 { APIClient } from "@api/APIClient";
import { classNames, simplifyDate } from "@utils"; import { classNames, simplifyDate } from "@utils";
@ -95,7 +95,7 @@ const RetryActionButton = ({ status }: RetryActionButtonProps) => {
}; };
return ( 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> <span className="mr-1.5">Retry</span>
{mutation.isLoading {mutation.isLoading
? <RingResizeSpinner className="text-blue-500 w-4 h-4 iconHeight" aria-hidden="true" /> ? <RingResizeSpinner className="text-blue-500 w-4 h-4 iconHeight" aria-hidden="true" />
@ -117,8 +117,8 @@ interface StatusCellMapEntry {
const StatusCellMap: Record<string, StatusCellMapEntry> = { const StatusCellMap: Record<string, StatusCellMapEntry> = {
"PUSH_ERROR": { "PUSH_ERROR": {
colors: "bg-pink-100 text-pink-800 hover:bg-pink-300", colors: "bg-red-100 text-red-800 hover:bg-red-275",
icon: <ExclamationCircleIcon className="h-5 w-5" aria-hidden="true" />, icon: <XMarkIcon className="h-5 w-5" aria-hidden="true" />,
textFormatter: (status: ReleaseActionStatus) => ( textFormatter: (status: ReleaseActionStatus) => (
<> <>
<span> <span>
@ -159,7 +159,7 @@ const StatusCellMap: Record<string, StatusCellMapEntry> = {
) )
}, },
"PUSH_APPROVED": { "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" />, icon: <CheckIcon className="h-5 w-5" aria-hidden="true" />,
textFormatter: (status: ReleaseActionStatus) => ( textFormatter: (status: ReleaseActionStatus) => (
<> <>

View file

@ -7,7 +7,7 @@ import { PlusIcon } from "@heroicons/react/24/solid";
interface EmptySimpleProps { interface EmptySimpleProps {
title: string; title: string;
subtitle: string; subtitle?: string;
buttonText?: string; buttonText?: string;
buttonAction?: () => void; buttonAction?: () => void;
} }
@ -20,7 +20,9 @@ export const EmptySimple = ({
}: EmptySimpleProps) => ( }: EmptySimpleProps) => (
<div className="text-center py-8"> <div className="text-center py-8">
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">{title}</h3> <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 ? ( {buttonText && buttonAction ? (
<div className="mt-6"> <div className="mt-6">
<button <button

View file

@ -67,12 +67,12 @@ export const KeyField = ({ value }: KeyFieldProps) => {
type={isVisible ? "text" : "password"} type={isVisible ? "text" : "password"}
value={value} value={value}
readOnly={true} 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> </div>
<button <button
type="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} onClick={toggleVisibility}
title="show" title="show"
> >
@ -80,7 +80,7 @@ export const KeyField = ({ value }: KeyFieldProps) => {
</button> </button>
<button <button
type="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} onClick={handleCopyClick}
title="Copy to clipboard" title="Copy to clipboard"
> >

View file

@ -48,12 +48,12 @@ export const Header = () => {
return ( return (
<Disclosure <Disclosure
as="nav" 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 }) => ( {({ open }) => (
<> <>
<div className="max-w-screen-xl mx-auto sm:px-6 lg:px-8"> <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"> <div className="flex items-center justify-between h-16 px-4 sm:px-0">
<LeftNav /> <LeftNav />
<RightNav logoutMutation={logoutMutation.mutate} /> <RightNav logoutMutation={logoutMutation.mutate} />

View file

@ -12,6 +12,7 @@ import { classNames } from "@utils";
import { AuthContext } from "@utils/Context"; import { AuthContext } from "@utils/Context";
import { RightNavProps } from "./_shared"; import { RightNavProps } from "./_shared";
import { Cog6ToothIcon, ArrowLeftOnRectangleIcon } from "@heroicons/react/24/outline";
export const RightNav = (props: RightNavProps) => { export const RightNav = (props: RightNavProps) => {
const authContext = AuthContext.useValue(); const authContext = AuthContext.useValue();
@ -23,10 +24,10 @@ export const RightNav = (props: RightNavProps) => {
<> <>
<Menu.Button <Menu.Button
className={classNames( className={classNames(
open ? "bg-gray-200 dark:bg-gray-800" : "", 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 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-2xl text-sm font-medium", "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", "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"> <span className="hidden text-sm font-medium sm:block">
@ -52,7 +53,7 @@ export const RightNav = (props: RightNavProps) => {
> >
<Menu.Items <Menu.Items
static 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> <Menu.Item>
{({ active }) => ( {({ active }) => (
@ -62,9 +63,13 @@ export const RightNav = (props: RightNavProps) => {
active active
? "bg-gray-100 dark:bg-gray-600" ? "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 Settings
</Link> </Link>
)} )}
@ -80,9 +85,13 @@ export const RightNav = (props: RightNavProps) => {
active active
? "bg-gray-100 dark:bg-gray-600" ? "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 Log out
</button> </button>
)} )}
@ -95,4 +104,4 @@ export const RightNav = (props: RightNavProps) => {
</div> </div>
</div> </div>
); );
} };

View file

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

View file

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

View file

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

View file

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

View file

@ -3,16 +3,20 @@
* SPDX-License-Identifier: GPL-2.0-or-later * SPDX-License-Identifier: GPL-2.0-or-later
*/ */
import type { FieldProps, FieldValidator } from "formik";
import { Field } 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 { classNames } from "@utils";
import { useToggle } from "@hooks/hooks"; import { useToggle } from "@hooks/hooks";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/solid"; 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 { SelectFieldProps } from "./select";
import * as common from "./common";
import { DocsTooltip } from "@components/tooltips/DocsTooltip"; import { DocsTooltip } from "@components/tooltips/DocsTooltip";
import { Checkbox } from "@components/Checkbox";
interface TextFieldWideProps { interface TextFieldWideProps {
name: string; name: string;
@ -41,12 +45,12 @@ export const TextFieldWide = ({
}: TextFieldWideProps) => ( }: TextFieldWideProps) => (
<div hidden={hidden} className="space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4"> <div hidden={hidden} className="space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div> <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"> <div className="flex">
{tooltip ? ( {tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip> <DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label} ) : label}
<RequiredField required={required} /> <common.RequiredField required={required} />
</div> </div>
</label> </label>
</div> </div>
@ -64,7 +68,12 @@ export const TextFieldWide = ({
type="text" type="text"
value={field.value ? field.value : defaultValue ?? ""} value={field.value ? field.value : defaultValue ?? ""}
onChange={field.onChange} 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} placeholder={placeholder}
hidden={hidden} hidden={hidden}
required={required} required={required}
@ -76,22 +85,22 @@ export const TextFieldWide = ({
{help && ( {help && (
<p className="mt-2 text-sm text-gray-500" id={`${name}-description`}>{help}</p> <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>
</div> </div>
); );
interface PasswordFieldWideProps { interface PasswordFieldWideProps {
name: string; name: string;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
defaultValue?: string; defaultValue?: string;
help?: string; help?: string;
required?: boolean; required?: boolean;
autoComplete?: string; autoComplete?: string;
defaultVisible?: boolean; defaultVisible?: boolean;
tooltip?: JSX.Element; tooltip?: JSX.Element;
validate?: FieldValidator; validate?: FieldValidator;
} }
export const PasswordFieldWide = ({ export const PasswordFieldWide = ({
@ -111,12 +120,12 @@ export const PasswordFieldWide = ({
return ( return (
<div className="space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4"> <div className="space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div> <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"> <div className="flex">
{tooltip ? ( {tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip> <DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label} ) : label}
<RequiredField required={required} /> <common.RequiredField required={required} />
</div> </div>
</label> </label>
</div> </div>
@ -134,7 +143,12 @@ export const PasswordFieldWide = ({
value={field.value ? field.value : defaultValue ?? ""} value={field.value ? field.value : defaultValue ?? ""}
onChange={field.onChange} onChange={field.onChange}
type={isVisible ? "text" : "password"} 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} placeholder={placeholder}
required={required} required={required}
autoComplete={autoComplete} autoComplete={autoComplete}
@ -149,20 +163,20 @@ export const PasswordFieldWide = ({
{help && ( {help && (
<p className="mt-2 text-sm text-gray-500" id={`${name}-description`}>{help}</p> <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>
</div> </div>
); );
}; };
interface NumberFieldWideProps { interface NumberFieldWideProps {
name: string; name: string;
label?: string; label?: string;
help?: string; help?: string;
placeholder?: string; placeholder?: string;
defaultValue?: number; defaultValue?: number;
required?: boolean; required?: boolean;
tooltip?: JSX.Element; tooltip?: JSX.Element;
} }
export const NumberFieldWide = ({ export const NumberFieldWide = ({
@ -178,13 +192,13 @@ export const NumberFieldWide = ({
<div> <div>
<label <label
htmlFor={name} 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"> <div className="flex">
{tooltip ? ( {tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip> <DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label} ) : label}
<RequiredField required={required} /> <common.RequiredField required={required} />
</div> </div>
</label> </label>
</div> </div>
@ -202,9 +216,9 @@ export const NumberFieldWide = ({
onChange={(e) => { form.setFieldValue(field.name, parseInt(e.target.value)); }} onChange={(e) => { form.setFieldValue(field.name, parseInt(e.target.value)); }}
className={classNames( className={classNames(
meta.touched && meta.error meta.touched && meta.error
? "focus:ring-red-500 focus:border-red-500 border-red-500" ? "border-red-500 focus:ring-red-500 focus: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-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 dark:bg-gray-800 sm:text-sm dark:text-white rounded-md" "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) => { onWheel={(event) => {
if (event.currentTarget === document.activeElement) { if (event.currentTarget === document.activeElement) {
@ -219,18 +233,18 @@ export const NumberFieldWide = ({
{help && ( {help && (
<p className="mt-2 text-sm text-gray-500 dark:text-gray-500" id={`${name}-description`}>{help}</p> <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>
</div> </div>
); );
interface SwitchGroupWideProps { interface SwitchGroupWideProps {
name: string; name: string;
label: string; label: string;
description?: string; description?: string;
defaultValue?: boolean; defaultValue?: boolean;
className?: string; className?: string;
tooltip?: JSX.Element; tooltip?: JSX.Element;
} }
export const SwitchGroupWide = ({ export const SwitchGroupWide = ({
@ -262,138 +276,23 @@ export const SwitchGroupWide = ({
defaultValue={defaultValue as boolean} defaultValue={defaultValue as boolean}
type="checkbox" type="checkbox"
> >
{({ field, form }: FieldProps) => ( {({
<Switch field,
form: { setFieldValue }
}: FieldProps) => (
<Checkbox
{...field} {...field}
type="button" value={!!field.checked}
value={field.value} setValue={(value) => {
checked={field.checked ?? false} setFieldValue(field?.name ?? "", value);
onChange={(value: unknown) => {
form.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> </Field>
</Switch.Group> </Switch.Group>
</ul> </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 = ({ export const SelectFieldWide = ({
name, name,
label, label,
@ -405,7 +304,7 @@ export const SelectFieldWide = ({
<div> <div>
<label <label
htmlFor={name} 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"> <div className="flex">
{tooltip ? ( {tooltip ? (
@ -426,10 +325,12 @@ export const SelectFieldWide = ({
isClearable={true} isClearable={true}
isSearchable={true} isSearchable={true}
components={{ components={{
Input, Input: common.SelectInput,
Control, Control: common.SelectControl,
Menu, Menu: common.SelectMenu,
Option Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}} }}
placeholder={optionDefaultText} placeholder={optionDefaultText}
styles={{ styles={{

View file

@ -77,7 +77,7 @@ function RadioFieldsetWide({ name, legend, options }: props) {
checked checked
? "bg-blue-600 dark:bg-blue-500 border-transparent" ? "bg-blue-600 dark:bg-blue-500 border-transparent"
: "bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-300", : "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" aria-hidden="true"
/> />

View file

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

View file

@ -3,11 +3,13 @@
* SPDX-License-Identifier: GPL-2.0-or-later * SPDX-License-Identifier: GPL-2.0-or-later
*/ */
import type { FieldProps } from "formik";
import { Field } from "formik"; import { Field } from "formik";
import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select"; import Select from "react-select";
import { OptionBasicTyped } from "@domain/constants";
import CreatableSelect from "react-select/creatable"; 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"; import { DocsTooltip } from "@components/tooltips/DocsTooltip";
interface SelectFieldProps<T> { interface SelectFieldProps<T> {
@ -26,7 +28,7 @@ export function SelectFieldCreatable<T>({ name, label, help, placeholder, toolti
<div> <div>
<label <label
htmlFor={name} 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"> <div className="flex">
{tooltip ? ( {tooltip ? (
@ -47,10 +49,12 @@ export function SelectFieldCreatable<T>({ name, label, help, placeholder, toolti
isClearable={true} isClearable={true}
isSearchable={true} isSearchable={true}
components={{ components={{
Input, Input: common.SelectInput,
Control, Control: common.SelectControl,
Menu, Menu: common.SelectMenu,
Option Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}} }}
placeholder={placeholder ?? "Choose an option"} placeholder={placeholder ?? "Choose an option"}
styles={{ 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>) { export function SelectField<T>({ name, label, help, placeholder, options }: SelectFieldProps<T>) {
return ( return (
<div className="space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4"> <div className="space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div> <div>
<label <label
htmlFor={name} 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}
</label> </label>
@ -151,10 +114,12 @@ export function SelectField<T>({ name, label, help, placeholder, options }: Sele
{...field} {...field}
id={name} id={name}
components={{ components={{
Input, Input: common.SelectInput,
Control, Control: common.SelectControl,
Menu, Menu: common.SelectMenu,
Option Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}} }}
placeholder={placeholder ?? "Choose an option"} placeholder={placeholder ?? "Choose an option"}
styles={{ styles={{
@ -199,7 +164,7 @@ export function SelectFieldBasic<T>({ name, label, help, placeholder, tooltip, d
<div> <div>
<label <label
htmlFor={name} 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"> <div className="flex">
{tooltip ? ( {tooltip ? (
@ -218,10 +183,12 @@ export function SelectFieldBasic<T>({ name, label, help, placeholder, tooltip, d
{...field} {...field}
id={name} id={name}
components={{ components={{
Input, Input: common.SelectInput,
Control, Control: common.SelectControl,
Menu, Menu: common.SelectMenu,
Option Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}} }}
placeholder={placeholder ?? "Choose an option"} placeholder={placeholder ?? "Choose an option"}
styles={{ styles={{

View file

@ -3,80 +3,22 @@
* SPDX-License-Identifier: GPL-2.0-or-later * SPDX-License-Identifier: GPL-2.0-or-later
*/ */
import React from "react"; import type { FieldProps } from "formik";
import type { FieldInputProps, FieldMetaProps, FieldProps, FormikProps, FormikValues } from "formik";
import { Field } from "formik"; import { Field } from "formik";
import { Switch as HeadlessSwitch } from "@headlessui/react"; import { Switch as HeadlessSwitch } from "@headlessui/react";
import { classNames } from "@utils"; import { classNames } from "@utils";
import { DocsTooltip } from "@components/tooltips/DocsTooltip"; import { DocsTooltip } from "@components/tooltips/DocsTooltip";
import { Checkbox } from "@components/Checkbox";
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}/>;
interface SwitchGroupProps { interface SwitchGroupProps {
name: string; name: string;
label?: string; label?: string;
description?: string; description?: string | React.ReactNode;
className?: string; heading?: boolean;
heading?: boolean; tooltip?: JSX.Element;
tooltip?: JSX.Element; disabled?: boolean;
className?: string;
} }
const SwitchGroup = ({ const SwitchGroup = ({
@ -84,15 +26,23 @@ const SwitchGroup = ({
label, label,
description, description,
tooltip, tooltip,
heading heading,
disabled,
className
}: SwitchGroupProps) => ( }: 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"> {label && <div className="flex flex-col">
<HeadlessSwitch.Label <HeadlessSwitch.Label
passive passive
as={heading ? "h2" : "span"} as={heading ? "h2" : "span"}
className={classNames( 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" heading ? "text-lg" : "text-sm"
)} )}
> >
@ -103,7 +53,7 @@ const SwitchGroup = ({
</div> </div>
</HeadlessSwitch.Label> </HeadlessSwitch.Label>
{description && ( {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} {description}
</HeadlessSwitch.Description> </HeadlessSwitch.Description>
)} )}
@ -115,28 +65,15 @@ const SwitchGroup = ({
field, field,
form: { setFieldValue } form: { setFieldValue }
}: FieldProps) => ( }: FieldProps) => (
<Switch <Checkbox
{...field} {...field}
// type="button" className=""
value={field.value} value={!!field.checked}
checked={field.checked ?? false} setValue={(value) => {
onChange={value => {
setFieldValue(field?.name ?? "", value); setFieldValue(field?.name ?? "", value);
}} }}
className={classNames( disabled={disabled}
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>
)} )}
</Field> </Field>
</HeadlessSwitch.Group> </HeadlessSwitch.Group>

View file

@ -112,7 +112,7 @@ export const TextInput = <TFormValues extends Record<string, unknown>>({
)} )}
> >
{props.label && ( {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} {props.label}
</label> </label>
)} )}
@ -121,8 +121,10 @@ export const TextInput = <TFormValues extends Record<string, unknown>>({
name={name} name={name}
aria-invalid={hasError} aria-invalid={hasError}
className={classNames( className={classNames(
"mt-2 block w-full dark:bg-gray-800 dark:text-gray-100 rounded-md", "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 ? "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" 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} {...props}
{...(register && register(name, rules))} {...(register && register(name, rules))}
@ -162,7 +164,7 @@ export const PasswordInput = <TFormValues extends Record<string, unknown>>({
)} )}
> >
{props.label && ( {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} {props.label}
</label> </label>
)} )}
@ -173,8 +175,10 @@ export const PasswordInput = <TFormValues extends Record<string, unknown>>({
aria-invalid={hasError} aria-invalid={hasError}
type={isVisible ? "text" : "password"} type={isVisible ? "text" : "password"}
className={classNames( className={classNames(
"mt-2 block w-full dark:bg-gray-800 dark:text-gray-100 rounded-md", "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 ? "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" 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} {...props}
{...(register && register(name, rules))} {...(register && register(name, rules))}

View file

@ -15,9 +15,12 @@ type Props = {
}; };
const Toast: FC<Props> = ({ type, body, t }) => ( const Toast: FC<Props> = ({ type, body, t }) => (
<div className={classNames( <div
t?.visible ? "animate-enter" : "animate-leave", className={classNames(
"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")}> 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="p-4">
<div className="flex items-start"> <div className="flex items-start">
<div className="flex-shrink-0"> <div className="flex-shrink-0">

View file

@ -96,7 +96,7 @@ function SlideOver<DataType extends FormikValues>({
> >
{({ handleSubmit, values }) => ( {({ handleSubmit, values }) => (
<Form <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) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
handleSubmit(e); handleSubmit(e);

View file

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

View file

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

View file

@ -81,14 +81,14 @@ export function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
validate={validate} validate={validate}
> >
{({ values }) => ( {({ 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="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6"> <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="flex items-start justify-between space-x-3">
<div className="space-y-1"> <div className="space-y-1">
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">Create filter</Dialog.Title> <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"> <p className="text-sm text-gray-500 dark:text-gray-400">
Add new filter. Add new filter.
</p> </p>
</div> </div>
<div className="h-7 flex items-center"> <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" className="block text-sm font-medium text-gray-900 dark:text-white sm:mt-px sm:pt-2"
> >
Name Name
<span className="text-red-500"> *</span>
</label> </label>
</div> </div>
<Field name="name"> <Field name="name">
@ -126,7 +127,7 @@ export function FilterAddForm({ isOpen, toggle }: filterAddFormProps) {
{...field} {...field}
id="name" id="name"
type="text" 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 && {meta.touched && meta.error &&

View file

@ -70,7 +70,7 @@ export function APIKeyAddForm({ isOpen, toggle }: apiKeyAddFormProps) {
validate={validate} validate={validate}
> >
{({ values }) => ( {({ 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="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6"> <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="flex items-start justify-between space-x-3">
@ -116,7 +116,7 @@ export function APIKeyAddForm({ isOpen, toggle }: apiKeyAddFormProps) {
{...field} {...field}
id="name" id="name"
type="text" 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>} {meta.touched && meta.error && <span className="block mt-2 text-red-500">{meta.error}</span>}
</div> </div>

View file

@ -13,7 +13,7 @@ import { toast } from "react-hot-toast";
import { classNames, sleep } from "@utils"; import { classNames, sleep } from "@utils";
import DEBUG from "@components/debug"; import DEBUG from "@components/debug";
import { APIClient } from "@api/APIClient"; import { APIClient } from "@api/APIClient";
import {DownloadClientTypeOptions, DownloadRuleConditionOptions} from "@domain/constants"; import { DownloadClientTypeOptions, DownloadRuleConditionOptions } from "@domain/constants";
import Toast from "@components/notifications/Toast"; import Toast from "@components/notifications/Toast";
import { useToggle } from "@hooks/hooks"; import { useToggle } from "@hooks/hooks";
import { DeleteModal } from "@components/modals"; import { DeleteModal } from "@components/modals";
@ -26,7 +26,7 @@ import {
} from "@components/inputs"; } from "@components/inputs";
import { clientKeys } from "@screens/settings/DownloadClient"; import { clientKeys } from "@screens/settings/DownloadClient";
import { DocsLink, ExternalLink } from "@components/ExternalLink"; import { DocsLink, ExternalLink } from "@components/ExternalLink";
import {SelectFieldBasic} from "@components/inputs/select_wide"; import { SelectFieldBasic } from "@components/inputs/select_wide";
interface InitialValuesSettings { interface InitialValuesSettings {
basic?: { basic?: {
@ -474,11 +474,11 @@ function FormFieldsRulesQbit() {
{settings.rules?.ignore_slow_torrents === true && ( {settings.rules?.ignore_slow_torrents === true && (
<> <>
<SelectFieldBasic <SelectFieldBasic
name="settings.rules.ignore_slow_torrents_condition" name="settings.rules.ignore_slow_torrents_condition"
label="Ignore condition" label="Ignore condition"
placeholder="Select ignore condition" placeholder="Select ignore condition"
options={DownloadRuleConditionOptions} 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>} 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 <NumberFieldWide
name="settings.rules.download_speed_threshold" name="settings.rules.download_speed_threshold"
@ -750,7 +750,7 @@ export function DownloadClientAddForm({ isOpen, toggle }: formProps) {
> >
{({ handleSubmit, values }) => ( {({ handleSubmit, values }) => (
<Form <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} onSubmit={handleSubmit}
> >
<div className="flex-1"> <div className="flex-1">
@ -945,7 +945,7 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
{({ handleSubmit, values }) => { {({ handleSubmit, values }) => {
return ( return (
<Form <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} onSubmit={handleSubmit}
> >
<div className="flex-1"> <div className="flex-1">
@ -962,7 +962,7 @@ export function DownloadClientUpdateForm({ client, isOpen, toggle }: updateFormP
<div className="h-7 flex items-center"> <div className="h-7 flex items-center">
<button <button
type="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} onClick={toggle}
> >
<span className="sr-only">Close panel</span> <span className="sr-only">Close panel</span>

View file

@ -6,7 +6,7 @@
import { Fragment, useState } from "react"; import { Fragment, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 type { FieldProps } from "formik";
import { Field, Form, Formik, FormikValues } from "formik"; import { Field, Form, Formik, FormikValues } from "formik";
import { XMarkIcon } from "@heroicons/react/24/solid"; import { XMarkIcon } from "@heroicons/react/24/solid";
@ -15,47 +15,15 @@ import { Dialog, Transition } from "@headlessui/react";
import { classNames, sleep } from "@utils"; import { classNames, sleep } from "@utils";
import DEBUG from "@components/debug"; import DEBUG from "@components/debug";
import { APIClient } from "@api/APIClient"; import { APIClient } from "@api/APIClient";
import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
import { SlideOver } from "@components/panels"; import { SlideOver } from "@components/panels";
import Toast from "@components/notifications/Toast"; import Toast from "@components/notifications/Toast";
import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs";
import { SelectFieldBasic, SelectFieldCreatable } from "@components/inputs/select_wide"; import { SelectFieldBasic, SelectFieldCreatable } from "@components/inputs/select_wide";
import { FeedDownloadTypeOptions } from "@domain/constants"; import { FeedDownloadTypeOptions } from "@domain/constants";
import { feedKeys } from "@screens/settings/Feed"; import { feedKeys } from "@screens/settings/Feed";
import { indexerKeys } from "@screens/settings/Indexer"; import { indexerKeys } from "@screens/settings/Indexer";
import { DocsLink } from "@components/ExternalLink"; import { DocsLink } from "@components/ExternalLink";
import * as common from "@components/inputs/common";
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}
/>
);
// const isRequired = (message: string) => (value?: string | undefined) => (!!value ? undefined : message); // const isRequired = (message: string) => (value?: string | undefined) => (!!value ? undefined : message);
@ -73,51 +41,54 @@ function validateField(s: IndexerSetting) {
} }
const IrcSettingFields = (ind: IndexerDefinition, indexer: string) => { const IrcSettingFields = (ind: IndexerDefinition, indexer: string) => {
if (indexer !== "") { if (!indexer.length) {
return ( return null;
<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>
);
} }
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) => { const TorznabFeedSettingFields = (ind: IndexerDefinition, indexer: string) => {
@ -137,10 +108,10 @@ const TorznabFeedSettingFields = (ind: IndexerDefinition, indexer: string) => {
{ind.torznab.settings.map((f: IndexerSetting, idx: number) => { {ind.torznab.settings.map((f: IndexerSetting, idx: number) => {
switch (f.type) { switch (f.type) {
case "text": case "text":
return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />; return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />;
case "secret": 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 <PasswordFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} validate={validateField(f)} />;
} }
return null; return null;
})} })}
@ -176,10 +147,10 @@ const NewznabFeedSettingFields = (ind: IndexerDefinition, indexer: string) => {
{ind.newznab.settings.map((f: IndexerSetting, idx: number) => { {ind.newznab.settings.map((f: IndexerSetting, idx: number) => {
switch (f.type) { switch (f.type) {
case "text": case "text":
return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />; return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />;
case "secret": 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 <PasswordFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} validate={validateField(f)} />;
} }
return null; return null;
})} })}
@ -207,10 +178,10 @@ const RSSFeedSettingFields = (ind: IndexerDefinition, indexer: string) => {
{ind.rss.settings.map((f: IndexerSetting, idx: number) => { {ind.rss.settings.map((f: IndexerSetting, idx: number) => {
switch (f.type) { switch (f.type) {
case "text": case "text":
return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />; return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />;
case "secret": 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 <PasswordFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} validate={validateField(f)} />;
} }
return null; return null;
})} })}
@ -235,28 +206,28 @@ const SettingFields = (ind: IndexerDefinition, indexer: string) => {
<div key="opt"> <div key="opt">
{ind && ind.settings && ind.settings.map((f, idx: number) => { {ind && ind.settings && ind.settings.map((f, idx: number) => {
switch (f.type) { switch (f.type) {
case "text": case "text":
return ( return (
<TextFieldWide name={`settings.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} /> <TextFieldWide name={`settings.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} autoComplete="off" validate={validateField(f)} />
); );
case "secret": case "secret":
return ( return (
<PasswordFieldWide <PasswordFieldWide
name={`settings.${f.name}`} name={`settings.${f.name}`}
label={f.label} label={f.label}
required={f.required} required={f.required}
key={idx} key={idx}
help={f.help} help={f.help}
validate={validateField(f)} validate={validateField(f)}
tooltip={ tooltip={
<div> <div>
<p>This field does not take a full URL. Only use alphanumeric strings like <code>uqcdi67cibkx3an8cmdm</code>.</p> <p>This field does not take a full URL. Only use alphanumeric strings like <code>uqcdi67cibkx3an8cmdm</code>.</p>
<br /> <br />
<DocsLink href="https://autobrr.com/faqs#common-action-rejections" /> <DocsLink href="https://autobrr.com/faqs#common-action-rejections" />
</div> </div>
} }
/> />
); );
} }
return null; return null;
})} })}
@ -468,7 +439,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
onSubmit={onSubmit} onSubmit={onSubmit}
> >
{({ values }) => ( {({ 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="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6"> <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="flex items-start justify-between space-x-3">
@ -509,7 +480,14 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
<Select {...field} <Select {...field}
isClearable={true} isClearable={true}
isSearchable={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" placeholder="Choose an indexer"
styles={{ styles={{
singleValue: (base) => ({ singleValue: (base) => ({
@ -566,7 +544,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
name="base_url" name="base_url"
label="Base URL" label="Base URL"
help="Override baseurl if it's blocked by your ISP." 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 { interface UpdateProps {
isOpen: boolean; isOpen: boolean;
toggle: () => void; toggle: () => void;
indexer: IndexerDefinition; indexer: IndexerDefinition;
} }
export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) { export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
@ -852,7 +830,7 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
<input <input
type="text" type="text"
{...field} {...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>} {meta.touched && meta.error && <span>{meta.error}</span>}
</div> </div>
@ -866,7 +844,7 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
name="base_url" name="base_url"
label="Base URL" label="Base URL"
help="Override baseurl if it's blocked by your ISP." help="Override baseurl if it's blocked by your ISP."
options={indexer.urls.map(u => ({ value: u, label: u, key: u })) } options={indexer.urls.map(u => ({ value: u, label: u, key: u }))}
/> />
)} )}

View file

@ -10,56 +10,67 @@ import type { FieldProps } from "formik";
import type { FieldArrayRenderProps } from "formik"; import type { FieldArrayRenderProps } from "formik";
import { Field, FieldArray, FormikErrors, FormikValues } from "formik"; import { Field, FieldArray, FormikErrors, FormikValues } from "formik";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; 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 { Dialog } from "@headlessui/react";
import { IrcAuthMechanismTypeOptions, OptionBasicTyped } from "@domain/constants"; import { IrcAuthMechanismTypeOptions, OptionBasicTyped } from "@domain/constants";
import { ircKeys } from "@screens/settings/Irc"; import { ircKeys } from "@screens/settings/Irc";
import { APIClient } from "@api/APIClient"; 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 { SlideOver } from "@components/panels";
import Toast from "@components/notifications/Toast"; import Toast from "@components/notifications/Toast";
import * as common from "@components/inputs/common";
import { classNames } from "@utils";
interface ChannelsFieldArrayProps { interface ChannelsFieldArrayProps {
channels: IrcChannel[]; channels: IrcChannel[];
} }
const ChannelsFieldArray = ({ channels }: ChannelsFieldArrayProps) => ( const ChannelsFieldArray = ({ channels }: ChannelsFieldArrayProps) => (
<div className="p-6"> <div className="px-4">
<FieldArray name="channels"> <FieldArray name="channels">
{({ remove, push }: FieldArrayRenderProps) => ( {({ 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 && channels.length > 0 ? (
channels.map((_channel: IrcChannel, index: number) => { channels.map((channel: IrcChannel, index) => {
const isDisabled = channels[index].name === "#ptp-announce-dev"; const isDisabled = channel.name === "#ptp-announce-dev";
return ( return (
<div key={index} className="flex justify-between"> <div key={index} className="flex justify-between border dark:border-gray-700 dark:bg-gray-815 p-2 rounded-md">
<div className="flex"> <div className="flex gap-2">
<Field name={`channels.${index}.name`}> <Field name={`channels.${index}.name`}>
{({ field }: FieldProps) => ( {({ field, meta }: FieldProps) => (
<input <input
{...field} {...field}
type="text" type="text"
value={field.value ?? ""} value={field.value ?? ""}
onChange={field.onChange} onChange={field.onChange}
placeholder="#Channel" className={classNames(
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 meta.touched && meta.error
${isDisabled ? "disabled dark:bg-gray-800 dark:text-gray-500" : "dark:bg-gray-700 dark:text-white"}`} ? "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} disabled={isDisabled}
/> />
)} )}
</Field> </Field>
<Field name={`channels.${index}.password`}> <Field name={`channels.${index}.password`}>
{({ field }: FieldProps) => ( {({ field, meta }: FieldProps) => (
<input <input
{...field} {...field}
type="text" type="text"
value={field.value ?? ""} value={field.value ?? ""}
onChange={field.onChange} onChange={field.onChange}
placeholder="Password" placeholder="Channel 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 className={classNames(
${isDisabled ? "disabled dark:bg-gray-800 dark:text-gray-500" : "dark:bg-gray-700 dark:text-white"}`} 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} disabled={isDisabled}
/> />
)} )}
@ -68,8 +79,10 @@ const ChannelsFieldArray = ({ channels }: ChannelsFieldArrayProps) => (
<button <button
type="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 className={classNames(
${isDisabled ? "disabled hidden" : ""}`} "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)} onClick={() => remove(index)}
disabled={isDisabled} disabled={isDisabled}
> >
@ -204,7 +217,16 @@ export function IrcNetworkAddForm({ isOpen, toggle }: AddFormProps) {
/> />
<PasswordFieldWide name="invite_command" label="Invite command" /> <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> </div>
)} )}
</SlideOver> </SlideOver>
@ -324,7 +346,7 @@ export function IrcNetworkUpdateForm({
required={true} required={true}
/> />
<SwitchGroupWideRed name="enabled" label="Enabled" /> <SwitchGroupWide name="enabled" label="Enabled" />
<TextFieldWide <TextFieldWide
name="server" name="server"
label="Server" label="Server"
@ -388,12 +410,20 @@ export function IrcNetworkUpdateForm({
label="Password" label="Password"
help="NickServ / SASL password." help="NickServ / SASL password."
/> />
</div> </div>
<PasswordFieldWide name="invite_command" label="Invite command" /> <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> </div>
)} )}
</SlideOver> </SlideOver>
@ -429,10 +459,12 @@ function SelectField<T>({ name, label, options }: SelectFieldProps<T>) {
isClearable={true} isClearable={true}
isSearchable={true} isSearchable={true}
components={{ components={{
Input, Input: common.SelectInput,
Control, Control: common.SelectControl,
Menu, Menu: common.SelectMenu,
Option Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}} }}
placeholder="Choose a type" placeholder="Choose a type"
styles={{ styles={{
@ -468,44 +500,3 @@ function SelectField<T>({ name, label, options }: SelectFieldProps<T>) {
</div> </div>
); );
} }
const Input = (props: InputProps) => {
return (
<components.Input
{...props}
inputClassName="outline-none border-none shadow-none focus:ring-transparent"
className="text-gray-400 dark:text-gray-100"
children={props.children}
/>
);
};
const Control = (props: ControlProps) => {
return (
<components.Control
{...props}
className="p-1 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
children={props.children}
/>
);
};
const Menu = (props: MenuProps) => {
return (
<components.Menu
{...props}
className="dark:bg-gray-800 border border-gray-300 dark:border-gray-700 dark:text-gray-400 rounded-md shadow-sm"
children={props.children}
/>
);
};
const Option = (props: OptionProps) => {
return (
<components.Option
{...props}
className="dark:text-gray-400 dark:bg-gray-800 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
children={props.children}
/>
);
};

View file

@ -8,60 +8,21 @@ import { Fragment } from "react";
import type { FieldProps } from "formik"; import type { FieldProps } from "formik";
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik"; import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
import { XMarkIcon } from "@heroicons/react/24/solid"; 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 { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast"; 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 { 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 { 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 { 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) => { import { componentMapType } from "./DownloadClientForms";
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}
/>
);
};
function FormFieldsDiscord() { function FormFieldsDiscord() {
return ( return (
@ -295,7 +256,7 @@ export function NotificationAddForm({ isOpen, toggle }: AddProps) {
validate={validate} validate={validate}
> >
{({ values }) => ( {({ 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="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6"> <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="flex items-start justify-between space-x-3">
@ -347,10 +308,12 @@ export function NotificationAddForm({ isOpen, toggle }: AddProps) {
isClearable={true} isClearable={true}
isSearchable={true} isSearchable={true}
components={{ components={{
Input, Input: common.SelectInput,
Control, Control: common.SelectControl,
Menu, Menu: common.SelectMenu,
Option Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}} }}
placeholder="Choose a type" placeholder="Choose a type"
styles={{ styles={{
@ -574,8 +537,14 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP
<Select {...field} <Select {...field}
isClearable={true} isClearable={true}
isSearchable={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" placeholder="Choose a type"
styles={{ styles={{
singleValue: (base) => ({ singleValue: (base) => ({

File diff suppressed because one or more lines are too long

View file

@ -7,7 +7,7 @@ import { Stats } from "./dashboard/Stats";
import { ActivityTable } from "./dashboard/ActivityTable"; import { ActivityTable } from "./dashboard/ActivityTable";
export const Dashboard = () => ( 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 /> <Stats />
<ActivityTable /> <ActivityTable />
</div> </div>

View file

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

View file

@ -22,7 +22,7 @@ export const Releases = () => {
<div className="mt-6 mb-4 mx-auto flex flex-col max-w-screen-xl px-4 sm:px-6 lg:px-8"> <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> <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. The search engine uses a special pattern-matching engine to filter out results.
Please Please
<button <button
@ -30,7 +30,7 @@ export const Releases = () => {
e.preventDefault(); e.preventDefault();
setIsHintOpen((state) => !state); 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 click here
{isHintOpen ? ( {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"> <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 Search tips
</div> </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. 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 /> <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 /> <br />
- Underscore (<Code>_</Code>) - for matching any <i>single</i> character (equivalent to <Code>.</Code> in Regex) - Underscore (<Code>_</Code>) - for matching any <i>single</i> character (equivalent to <Code>.</Code> in Regex)
<br /><br /> <br /><br />
@ -88,9 +88,9 @@ export const Releases = () => {
</div> </div>
) : null} ) : null}
</div> </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 /> <ReleaseTable />
</div> </div>
</main> </main>
); );
} };

View file

@ -54,14 +54,15 @@ function SubNavLink({ item }: NavLinkProps) {
to={item.href} to={item.href}
end end
className={({ isActive }) => classNames( 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", "transition group border-l-4 px-3 py-2 flex items-center text-sm font-medium",
isActive ? 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" : "" ? "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} aria-current={splitLocation[2] === item.href ? "page" : undefined}
> >
<item.icon <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" aria-hidden="true"
/> />
<span className="truncate">{item.name}</span> <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> <h1 className="text-3xl font-bold text-black dark:text-white">Settings</h1>
</div> </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">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg"> <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-200 dark:divide-gray-700 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x"> <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}/> <SidebarNav subNavigation={subNavigation}/>
<Suspense <Suspense
fallback={ fallback={

View file

@ -9,6 +9,8 @@ import { useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { RocketLaunchIcon } from "@heroicons/react/24/outline";
import { APIClient } from "@api/APIClient"; import { APIClient } from "@api/APIClient";
import { AuthContext } from "@utils/Context"; import { AuthContext } from "@utils/Context";
import Toast from "@components/notifications/Toast"; import Toast from "@components/notifications/Toast";
@ -63,60 +65,57 @@ export const Login = () => {
const onSubmit = (data: LoginFormFields) => loginMutation.mutate(data); const onSubmit = (data: LoginFormFields) => loginMutation.mutate(data);
return ( return (
<div className="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8"> <div className="min-h-screen flex flex-col justify-center px-3">
<div className="sm:mx-auto sm:w-full sm:max-w-md mb-6"> <div className="mx-auto w-full max-w-md mb-6">
<Logo className="mx-auto h-12" /> <Logo className="mx-auto h-12" />
<h1 className="text-center text-gray-900 dark:text-gray-200 font-bold pt-2 text-2xl"> <h1 className="text-center text-gray-900 dark:text-gray-200 font-bold pt-2 text-2xl">
autobrr autobrr
</h1> </h1>
</div> </div>
<div className="sm:mx-auto sm:w-full sm:max-w-md shadow-lg"> <div className="mx-auto w-full max-w-md rounded-2xl shadow-lg">
<div className="bg-white dark:bg-gray-800 py-10 px-4 sm:rounded-lg sm:px-10"> <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)}> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-6"> <TextInput<LoginFormFields>
<TextInput<LoginFormFields> name="username"
name="username" id="username"
id="username" label="username"
label="username" type="text"
type="text" register={register}
register={register} rules={{ required: "Username is required" }}
rules={{ required: "Username is required" }} errors={formState.errors}
errors={formState.errors} autoComplete="username"
autoComplete="username" />
/> <PasswordInput<LoginFormFields>
<PasswordInput<LoginFormFields> name="password"
name="password" id="password"
id="password" label="password"
label="password" register={register}
register={register} rules={{ required: "Password is required" }}
rules={{ required: "Password is required" }} errors={formState.errors}
errors={formState.errors} autoComplete="current-password"
autoComplete="current-password" />
/> <button
</div> 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"
<div className="mt-6"> >
<button <RocketLaunchIcon className="w-4 h-4 mr-1.5" />
type="submit" Sign in
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" </button>
>
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>
</form> </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> </div>
</div> </div>

View file

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

View file

@ -85,9 +85,9 @@ function Table({ columns, data }: TableProps) {
// Render the UI for your table // Render the UI for your table
return ( return (
<div className="inline-block min-w-full mt-4 mb-2 align-middle"> <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"> <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 divide-y divide-gray-200 dark:divide-gray-700"> <table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-50 dark:bg-gray-800"> <thead className="bg-gray-100 dark:bg-gray-850">
{headerGroups.map((headerGroup) => { {headerGroups.map((headerGroup) => {
const { key: rowKey, ...rowRest } = headerGroup.getHeaderGroupProps(); const { key: rowKey, ...rowRest } = headerGroup.getHeaderGroupProps();
return ( return (
@ -100,7 +100,7 @@ function Table({ columns, data }: TableProps) {
<th <th
key={`${rowKey}-${columnKey}`} key={`${rowKey}-${columnKey}`}
scope="col" 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} {...columnRest}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -127,7 +127,7 @@ function Table({ columns, data }: TableProps) {
</thead> </thead>
<tbody <tbody
{...getTableBodyProps()} {...getTableBodyProps()}
className="divide-y divide-gray-200 dark:divide-gray-700" className="divide-y divide-gray-150 dark:divide-gray-750"
> >
{page.map((row) => { {page.map((row) => {
prepareRow(row); prepareRow(row);

View file

@ -15,7 +15,7 @@ interface StatsItemProps {
const StatsItem = ({ name, placeholder, value }: StatsItemProps) => ( const StatsItem = ({ name, placeholder, value }: StatsItemProps) => (
<div <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" title="All time"
> >
<dt> <dt>

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -21,7 +21,7 @@ interface ModalLowerProps extends ImporterProps {
const ModalUpper = ({ children }: { children: React.ReactNode; }) => ( 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="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"> <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) Import filter (in JSON or autodl-irssi format)
</Dialog.Title> </Dialog.Title>
@ -31,10 +31,10 @@ const ModalUpper = ({ children }: { children: React.ReactNode; }) => (
); );
const ModalLower = ({ isOpen, setIsOpen, onImportClick }: ModalLowerProps) => ( 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 <button
type="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) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
if (isOpen) { if (isOpen) {
@ -105,7 +105,7 @@ const ImportJSON = async (inputFilterText: string) => {
/> />
); );
} }
} };
const ImportAutodlIrssi = async (inputText: string) => { const ImportAutodlIrssi = async (inputText: string) => {
const parser = new AutodlIrssiConfigParser(); const parser = new AutodlIrssiConfigParser();
@ -162,7 +162,7 @@ const ImportAutodlIrssi = async (inputText: string) => {
/> />
); );
} }
} };
export const Importer = ({ export const Importer = ({
isOpen, 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"> <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> <ModalUpper>
<textarea <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)" placeholder="Paste your filter data here (either autobrr JSON format or your entire autodl-irssi config)"
value={inputFilterText} value={inputFilterText}
onChange={(event) => { onChange={(event) => {
@ -288,4 +288,4 @@ export const Importer = ({
</Dialog> </Dialog>
</Transition.Root> </Transition.Root>
); );
} };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -95,6 +95,7 @@ export const ReleaseTable = () => {
)} )}
> >
<Tooltip <Tooltip
requiresClick
label={props.cell.value} label={props.cell.value}
maxWidth="max-w-[90vw]" maxWidth="max-w-[90vw]"
> >
@ -226,9 +227,9 @@ export const ReleaseTable = () => {
)) ))
)} )}
</div> </div>
<div className="bg-white shadow-lg dark:bg-gray-800 rounded-md overflow-auto"> <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 divide-y divide-gray-200 dark:divide-gray-700"> <table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-50 dark:bg-gray-800"> <thead className="bg-gray-100 dark:bg-gray-800">
<tr> <tr>
<th <th
scope="col" scope="col"
@ -244,7 +245,7 @@ export const ReleaseTable = () => {
</tr> </tr>
</thead> </thead>
<tbody className=" divide-gray-200 dark:divide-gray-700"> <tbody className="divide-y divide-gray-150 dark:divide-gray-700">
<tr <tr
className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]"> className="flex justify-between py-4 text-sm font-medium box-content text-gray-900 dark:text-gray-300 max-w-[96px] sm:max-w-[216px] md:max-w-[360px] lg:max-w-[640px] xl:max-w-[840px]">
<td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td> <td className="first:pl-5 pl-3 pr-3 whitespace-nowrap ">&nbsp;</td>
@ -316,13 +317,12 @@ export const ReleaseTable = () => {
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div className="flex items-baseline gap-x-2"> <div className="flex items-baseline gap-x-2">
<span className="text-sm text-gray-700 dark:text-gray-500"> <span className="text-sm text-gray-700 dark:text-gray-500">
Page <span className="font-medium">{pageIndex + 1}</span> of <span Page <span className="font-medium">{pageIndex + 1}</span> of <span className="font-medium">{pageOptions.length}</span>
className="font-medium">{pageOptions.length}</span>
</span> </span>
<label> <label>
<span className="sr-only bg-gray-700">Items Per Page</span> <span className="sr-only bg-gray-700">Items Per Page</span>
<select <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} value={pageSize}
onChange={e => { onChange={e => {
setPageSize(Number(e.target.value)); setPageSize(Number(e.target.value));
@ -392,9 +392,9 @@ export const ReleaseTable = () => {
)) ))
)} )}
</div> </div>
<div className="bg-white shadow-lg dark:bg-gray-800 rounded-md overflow-auto"> <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 divide-y divide-gray-200 dark:divide-gray-700"> <table {...getTableProps()} className="min-w-full rounded-md divide-y divide-gray-200 dark:divide-gray-750">
<thead className="bg-gray-50 dark:bg-gray-800"> <thead className="bg-gray-100 dark:bg-gray-850">
{headerGroups.map((headerGroup) => { {headerGroups.map((headerGroup) => {
const { key: rowKey, ...rowRest } = headerGroup.getHeaderGroupProps(); const { key: rowKey, ...rowRest } = headerGroup.getHeaderGroupProps();
return ( return (
@ -407,7 +407,7 @@ export const ReleaseTable = () => {
<th <th
key={`${rowKey}-${columnKey}`} key={`${rowKey}-${columnKey}`}
scope="col" 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} {...columnRest}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -434,7 +434,7 @@ export const ReleaseTable = () => {
</thead> </thead>
<tbody <tbody
{...getTableBodyProps()} {...getTableBodyProps()}
className="divide-y divide-gray-200 dark:divide-gray-700" className="divide-y divide-gray-150 dark:divide-gray-750"
> >
{page.map((row) => { {page.map((row) => {
prepareRow(row); prepareRow(row);
@ -474,7 +474,7 @@ export const ReleaseTable = () => {
<label> <label>
<span className="sr-only bg-gray-700">Items Per Page</span> <span className="sr-only bg-gray-700">Items Per Page</span>
<select <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} value={pageSize}
onChange={e => { onChange={e => {
setPageSize(Number(e.target.value)); setPageSize(Number(e.target.value));
@ -482,7 +482,7 @@ export const ReleaseTable = () => {
> >
{[5, 10, 20, 50].map(pageSize => ( {[5, 10, 20, 50].map(pageSize => (
<option key={pageSize} value={pageSize}> <option key={pageSize} value={pageSize}>
Show {pageSize} {pageSize} entries
</option> </option>
))} ))}
</select> </select>
@ -495,29 +495,31 @@ export const ReleaseTable = () => {
onClick={() => gotoPage(0)} onClick={() => gotoPage(0)}
disabled={!canPreviousPage} disabled={!canPreviousPage}
> >
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">First</span> <span className="sr-only">First</span>
<ChevronDoubleLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" /> <ChevronDoubleLeftIcon className="w-4 h-4" aria-hidden="true" />
</DataTable.PageButton> </DataTable.PageButton>
<DataTable.PageButton <DataTable.PageButton
className="pl-1 pr-2"
onClick={() => previousPage()} onClick={() => previousPage()}
disabled={!canPreviousPage} 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 mr-1" aria-hidden="true" />
<ChevronLeftIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" /> <span>Prev</span>
</DataTable.PageButton> </DataTable.PageButton>
<DataTable.PageButton <DataTable.PageButton
className="pl-2 pr-1"
onClick={() => nextPage()} onClick={() => nextPage()}
disabled={!canNextPage}> disabled={!canNextPage}>
<span className="sr-only text-gray-400 dark:text-gray-500 dark:bg-gray-700">Next</span> <span>Next</span>
<ChevronRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" /> <ChevronRightIcon className="w-4 h-4 ml-1" aria-hidden="true" />
</DataTable.PageButton> </DataTable.PageButton>
<DataTable.PageButton <DataTable.PageButton
className="rounded-r-md" className="rounded-r-md"
onClick={() => gotoPage(pageCount - 1)} onClick={() => gotoPage(pageCount - 1)}
disabled={!canNextPage} 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" aria-hidden="true" />
<ChevronDoubleRightIcon className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" /> <span className="sr-only">Last</span>
</DataTable.PageButton> </DataTable.PageButton>
</nav> </nav>
</div> </div>

View file

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

View file

@ -16,6 +16,8 @@ import { APIClient } from "@api/APIClient";
import { useToggle } from "@hooks/hooks"; import { useToggle } from "@hooks/hooks";
import { classNames } from "@utils"; import { classNames } from "@utils";
import { EmptySimple } from "@components/emptystates"; import { EmptySimple } from "@components/emptystates";
import { Section } from "./_components";
import { PlusIcon } from "@heroicons/react/24/solid";
export const apiKeys = { export const apiKeys = {
all: ["api_keys"] as const, all: ["api_keys"] as const,
@ -37,55 +39,44 @@ function APISettings() {
}); });
return ( return (
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9"> <Section
<div className="pb-6 py-6 px-4 sm:p-6 lg:pb-8"> title="API keys"
<APIKeyAddForm isOpen={addFormIsOpen} toggle={toggleAddForm} /> 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"> {data && data.length > 0 ? (
<div className="ml-4 mt-4"> <ul className="min-w-full relative">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white"> <li className="hidden sm:grid grid-cols-12 gap-4 mb-2 border-b border-gray-200 dark:border-gray-700">
API keys <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">
</h3> Name
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> </div>
Manage API keys. <div className="col-span-8 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</p> Key
</div> </div>
<div className="ml-4 mt-4 flex-shrink-0"> </li>
<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 ? ( {data.map((k, idx) => <APIListItem key={idx} apikey={k} />)}
<section className="mt-6 light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md"> </ul>
<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"> <EmptySimple
<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"> title="No API keys"
Name subtitle=""
</div> buttonAction={toggleAddForm}
<div className="col-span-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"> buttonText="Create API key"
Key />
</div> )}
</li> </Section>
{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>
); );
} }
@ -131,7 +122,7 @@ function APIListItem({ apikey }: ApiKeyItemProps) {
/> />
<div className="sm:grid grid-cols-12 gap-4 items-center py-2"> <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="flex justify-between">
<div className="pl-1 py-2">{apikey.name}</div> <div className="pl-1 py-2">{apikey.name}</div>
<div> <div>
@ -151,7 +142,7 @@ function APIListItem({ apikey }: ApiKeyItemProps) {
</div> </div>
</div> </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} /> <KeyField value={apikey.key} />
</div> </div>

View file

@ -7,79 +7,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { APIClient } from "@api/APIClient"; import { APIClient } from "@api/APIClient";
import { Checkbox } from "@components/Checkbox";
import { SettingsContext } from "@utils/Context"; import { SettingsContext } from "@utils/Context";
import { GithubRelease } from "@app/types/Update"; import { Checkbox } from "@components/Checkbox";
import Toast from "@components/notifications/Toast"; import Toast from "@components/notifications/Toast";
import { ExternalLink } from "@components/ExternalLink"; import { ExternalLink } from "@components/ExternalLink";
interface RowItemProps { import { Section, RowItem } from "./_components";
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>
);
};
function ApplicationSettings() { function ApplicationSettings() {
const [settings, setSettings] = SettingsContext.use(); const [settings, setSettings] = SettingsContext.use();
const { isLoading, data } = useQuery({ const { data } = useQuery({
queryKey: ["config"], queryKey: ["config"],
queryFn: APIClient.config.get, queryFn: APIClient.config.get,
retry: false, retry: false,
@ -105,8 +43,9 @@ function ApplicationSettings() {
} }
}); });
const toggleCheckUpdateMutation = useMutation((value: boolean) => APIClient.config.update({ check_for_updates: value }).then(() => value), { const toggleCheckUpdateMutation = useMutation({
onSuccess: (value: boolean) => { 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} />); toast.custom(t => <Toast type="success" body={`${value ? "You will now be notified of new updates." : "You will no longer be notified of new updates."}`} t={t} />);
queryClient.invalidateQueries({ queryKey: ["config"] }); queryClient.invalidateQueries({ queryKey: ["config"] });
checkUpdateMutation.mutate(); checkUpdateMutation.mutate();
@ -114,20 +53,16 @@ function ApplicationSettings() {
}); });
return ( return (
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9"> <Section
<div className="py-6 px-4 sm:p-6 lg:pb-8"> title="Application"
<div> description="Application settings. Change in config.toml and restart to take effect."
<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"> <div className="-mx-4 divide-y divide-gray-150 dark:divide-gray-750">
Application settings. Change in config.toml and restart to take effect. <form className="mt-6 mb-4" action="#" method="POST">
</p> {data && (
</div> <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">
<form className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9" action="#" method="POST"> <label htmlFor="host" className="block ml-px text-xs font-bold text-gray-700 dark:text-white uppercase tracking-wide">
{!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">
Host Host
</label> </label>
<input <input
@ -136,12 +71,12 @@ function ApplicationSettings() {
id="host" id="host"
value={data.host} value={data.host}
disabled={true} 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>
<div className="col-span-6 sm:col-span-4"> <div className="col-span-12 sm:col-span-4">
<label htmlFor="port" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide"> <label htmlFor="port" className="block ml-px text-xs font-bold text-gray-700 dark:text-white uppercase tracking-wide">
Port Port
</label> </label>
<input <input
@ -150,12 +85,12 @@ function ApplicationSettings() {
id="port" id="port"
value={data.port} value={data.port}
disabled={true} 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>
<div className="col-span-6 sm:col-span-4"> <div className="col-span-12 sm:col-span-4">
<label htmlFor="base_url" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide"> <label htmlFor="base_url" className="block ml-px text-xs font-bold text-gray-700 dark:text-white uppercase tracking-wide">
Base url Base url
</label> </label>
<input <input
@ -164,64 +99,68 @@ function ApplicationSettings() {
id="base_url" id="base_url"
value={data.base_url} value={data.base_url}
disabled={true} 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>
</div> </div>
)} )}
</form> </form>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700"> <RowItem
<div className="px-4 py-5 sm:p-0"> label="Version"
<dl className="sm:divide-y divide-gray-200 dark:divide-gray-700"> value={data?.version}
<RowItemVersion label="Version" value={data?.version} newUpdate={updateData ?? undefined} /> rightSide={
{data?.commit && <RowItem label="Commit" value={data.commit} />} updateData && updateData.html_url ? (
{data?.date && <RowItem label="Build date" value={data.date} />} <ExternalLink
<RowItem label="Application" value={data?.application} /> href={updateData.html_url}
<RowItem label="Config path" value={data?.config_dir} /> 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"
<RowItem label="Database" value={data?.database} /> >
</dl> {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> </div>
<ul className="divide-y divide-gray-200 dark:divide-gray-700"> <Checkbox
<div className="px-4 sm:px-6 py-1"> label="Check for updates"
<Checkbox description="Get notified of new updates."
label="WebUI Debug mode" value={data?.check_for_updates ?? true}
value={settings.debug} className="p-4 sm:px-6"
setValue={ setValue={(newValue: boolean) => {
(newValue: boolean) => setSettings((prevState) => ({ toggleCheckUpdateMutation.mutate(newValue);
...prevState, }}
debug: newValue />
})) <Checkbox
} label="Dark theme"
/> description="Switch between dark and light theme."
</div> value={settings.darkTheme}
<div className="px-4 sm:px-6 py-1"> className="p-4 sm:px-6"
<Checkbox setValue={
label="Check for updates" (newValue: boolean) => setSettings((prevState) => ({
description="Get notified of new updates." ...prevState,
value={data?.check_for_updates ?? true} darkTheme: newValue
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>
</div> </div>
</div> </Section>
); );
} }

View file

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

View file

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

View file

@ -6,16 +6,18 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { useToggle } from "@hooks/hooks";
import { classNames } from "@utils";
import { EmptySimple } from "@components/emptystates";
import { APIClient } from "@api/APIClient"; 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 { componentMapType } from "@forms/settings/DownloadClientForms";
import { Section } from "./_components";
export const indexerKeys = { export const indexerKeys = {
all: ["indexers"] as const, all: ["indexers"] as const,
lists: () => [...indexerKeys.all, "list"] as const, lists: () => [...indexerKeys.all, "list"] as const,
@ -85,7 +87,7 @@ const ImplementationBadgeIRC = () => (
); );
const ImplementationBadgeTorznab = () => ( 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 Torznab
</span> </span>
); );
@ -131,6 +133,10 @@ const ListItem = ({ indexer }: ListItemProps) => {
updateMutation.mutate(newState); updateMutation.mutate(newState);
}; };
if (!indexer) {
return null;
}
return ( return (
<li> <li>
<div className="grid grid-cols-12 items-center py-1.5"> <div className="grid grid-cols-12 items-center py-1.5">
@ -139,25 +145,8 @@ const ListItem = ({ indexer }: ListItemProps) => {
toggle={toggleUpdate} toggle={toggleUpdate}
indexer={indexer} indexer={indexer}
/> />
<div className="col-span-2 sm:col-span-1 flex px-6 items-center sm:px-6"> <div className="col-span-2 sm:col-span-1 flex pl-1 sm:pl-5 items-center">
<Switch <Checkbox value={indexer.enabled ?? false} setValue={onToggleMutation} />
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> </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"> <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} {indexer.name}
@ -194,71 +183,64 @@ function IndexerSettings() {
} }
return ( 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} /> <IndexerAddForm isOpen={addIndexerIsOpen} toggle={toggleAddIndexer} />
<div className="py-6 px-4 sm:p-6 lg:pb-8"> <div className="flex flex-col">
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap"> {sortedIndexers.items.length ? (
<div className="ml-4 mt-4"> <ul className="min-w-full relative">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white"> <li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
Indexers <div
</h3> 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"
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> onClick={() => sortedIndexers.requestSort("enabled")}
Indexer settings for IRC, RSS, Newznab, and Torznab based indexers.<br /> >
Generic feeds can be added here by selecting the Generic indexer. Enabled <span className="sort-indicator">{sortedIndexers.getSortIndicator("enabled")}</span>
</p> </div>
</div> <div
<div className="ml-4 mt-4 flex-shrink-0"> 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"
<button onClick={() => sortedIndexers.requestSort("name")}
type="button" >
onClick={toggleAddIndexer} Name <span className="sort-indicator">{sortedIndexers.getSortIndicator("name")}</span>
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" </div>
> <div
Add new 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"
</button> onClick={() => sortedIndexers.requestSort("implementation")}
</div> >
</div> Implementation <span className="sort-indicator">{sortedIndexers.getSortIndicator("implementation")}</span>
</div>
<div className="flex flex-col mt-6"> </li>
{data && data.length > 0 ? ( {sortedIndexers.items.map((indexer) => (
<section className="light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md"> <ListItem indexer={indexer} key={indexer.id} />
<ol className="min-w-full relative"> ))}
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700"> </ul>
<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" <EmptySimple
onClick={() => sortedIndexers.requestSort("enabled")} title="No indexers"
> subtitle=""
Enabled <span className="sort-indicator">{sortedIndexers.getSortIndicator("enabled")}</span> buttonText="Add new indexer"
</div> buttonAction={toggleAddIndexer}
<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> </div>
</div> </Section>
); );
} }

View file

@ -5,8 +5,8 @@
import { Fragment, useRef, useState, useMemo, useEffect, MouseEvent } from "react"; import { Fragment, useRef, useState, useMemo, useEffect, MouseEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { LockClosedIcon, LockOpenIcon } from "@heroicons/react/24/solid"; import { LockClosedIcon, LockOpenIcon, PlusIcon } from "@heroicons/react/24/solid";
import { Menu, Switch, Transition } from "@headlessui/react"; import { Menu, Transition } from "@headlessui/react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { import {
ArrowsPointingInIcon, ArrowsPointingInIcon,
@ -29,6 +29,8 @@ import { SettingsContext } from "@utils/Context";
import { Checkbox } from "@components/Checkbox"; import { Checkbox } from "@components/Checkbox";
// import { useForm } from "react-hook-form"; // import { useForm } from "react-hook-form";
import { Section } from "./_components";
export const ircKeys = { export const ircKeys = {
all: ["irc_networks"] as const, all: ["irc_networks"] as const,
lists: () => [...ircKeys.all, "list"] as const, lists: () => [...ircKeys.all, "list"] as const,
@ -106,112 +108,99 @@ const IrcSettings = () => {
const sortedNetworks = useSort(data || []); const sortedNetworks = useSort(data || []);
return ( 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} /> <IrcNetworkAddForm isOpen={addNetworkIsOpen} toggle={toggleAddNetwork} />
<div className="py-6 px-4 md:p-6 lg:pb-8"> <div className="flex justify-between flex-col md:flex-row px-1">
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap md:flex-nowrap"> <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">
<div className="ml-4 mt-4"> <li className="flex items-center">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white"> <span
IRC className="mr-2 flex h-4 w-4 relative"
</h3> title="Network healthy"
<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"
> >
Add new <span className="animate-ping inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
</button> <span className="inline-flex absolute rounded-full h-4 w-4 bg-green-500" />
</div> </span>
</div> <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"> <li className="flex items-center md:pl-2">
<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"> <span
<li className="flex items-center"> className="mr-2 flex h-4 w-4 rounded-full opacity-75 bg-yellow-400 over:text-yellow-600"
<span title="Network unhealthy"
className="mr-2 flex h-4 w-4 relative" />
title="Network healthy" <span className="text-sm text-gray-800 dark:text-gray-500">Network unhealthy</span>
> </li>
<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"> <li className="flex items-center md:pl-2">
<span <span
className="mr-2 flex h-4 w-4 rounded-full opacity-75 bg-yellow-400 over:text-yellow-600" className="mr-2 flex h-4 w-4 rounded-full opacity-75 bg-gray-500"
title="Network unhealthy" title="Network disabled"
/>
<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"}
> >
{expandNetworks </span>
? <span className="flex items-center">Collapse <ArrowsPointingInIcon className="ml-1 w-4 h-4"/></span> <span className="text-sm text-gray-800 dark:text-gray-500">Network disabled</span>
: <span className="flex items-center">Expand <ArrowsPointingOutIcon className="ml-1 w-4 h-4"/></span> </li>
}</button> </ul>
<IRCLogsDropdown/> <div className="flex gap-x-2">
</div> <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> </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>
</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> <li>
<div <div
className={classNames( 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" 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) => { onClick={(e) => {
@ -261,25 +250,11 @@ const ListItem = ({ network, expanded }: ListItemProps) => {
toggle={toggleUpdate} toggle={toggleUpdate}
network={network} network={network}
/> />
<div className="col-span-2 md:col-span-1 flex pl-5 text-gray-500 dark:text-gray-400"> <div className="col-span-2 md:col-span-1 flex pl-1 sm:pl-5 text-gray-500 dark:text-gray-400">
<Switch <Checkbox
onClick={(e) => e.stopPropagation()} value={network.enabled}
checked={network.enabled} setValue={onToggleMutation}
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> </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="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"> <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 className="mr-3 flex h-3 w-3 rounded-full opacity-75 bg-gray-500" />
)} )}
</span> </span>
<div className="block truncate"> <div className="block text-sm truncate">
{network.name} {network.name}
</div> </div>
</div> </div>
@ -330,13 +305,13 @@ const ListItem = ({ network, expanded }: ListItemProps) => {
)} /> )} />
)} )}
</div> </div>
<p className="block truncate"> <p className="block text-sm truncate">
{network.server}:{network.port} {network.server}:{network.port}
</p> </p>
</div> </div>
</div> </div>
<div className="hidden md:flex col-span-3 items-center md:pl-6 text-gray-500 dark:text-gray-400"> <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} {network.nick}
</div> </div>
</div> </div>
@ -345,25 +320,25 @@ const ListItem = ({ network, expanded }: ListItemProps) => {
</div> </div>
</div> </div>
{(edit || expanded) && ( {(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"> <div className="min-w-full">
{network.channels.length > 0 ? ( {network.channels.length > 0 ? (
<ol> <ul>
<li className="grid grid-cols-12 gap-4 border-b border-gray-200 dark:border-gray-700"> <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 Channel
</div> </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 Monitoring since
</div> </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 Last announce
</div> </div>
</li> </li>
{network.channels.map((c) => ( {network.channels.map((c) => (
<ChannelItem key={`${network.id}.${c.id}`} network={network} channel={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"> <div className="flex text-center justify-center py-4 dark:text-gray-500">
<p>No channels!</p> <p>No channels!</p>
@ -387,12 +362,12 @@ const ChannelItem = ({ network, channel }: ChannelItemProps) => {
return ( return (
<li <li
className={classNames( className={classNames(
"mb-2 text-gray-500 dark:text-gray-400", "mb-2 text-gray-500 dark:text-gray-400 hover:cursor-pointer rounded-md",
viewChannel ? "bg-gray-200 dark:bg-gray-800 rounded-md" : "" viewChannel ? "bg-gray-200 dark:bg-gray-800 rounded-md" : "hover:bg-gray-300 dark:hover:bg-gray-800"
)} )}
> >
<div <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} onClick={toggleView}
> >
<div className="col-span-4 flex items-center md:px-6"> <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" className="mr-3 flex h-3 w-3 relative"
title="monitoring" title="monitoring"
> >
<span className="animate-ping inline-flex h-full w-full rounded-full bg-green-400 opacity-75"/> <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="inline-flex absolute rounded-full h-3 w-3 bg-green-500" />
</span> </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} {channel.name}
</span> </span>
@ -432,7 +407,7 @@ const ChannelItem = ({ network, channel }: ChannelItemProps) => {
</div> </div>
</div> </div>
{viewChannel && ( {viewChannel && (
<Events network={network} channel={channel.name}/> <Events network={network} channel={channel.name} />
)} )}
</li> </li>
); );
@ -459,7 +434,7 @@ const ListItemDropdown = ({
queryClient.invalidateQueries({ queryKey: ircKeys.lists() }); queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: ircKeys.detail(network.id) }); 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(); toggleDeleteModal();
} }
@ -471,7 +446,7 @@ const ListItemDropdown = ({
queryClient.invalidateQueries({ queryKey: ircKeys.lists() }); queryClient.invalidateQueries({ queryKey: ircKeys.lists() });
queryClient.invalidateQueries({ queryKey: ircKeys.detail(network.id) }); 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" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items <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"> <div className="px-1 py-1">
<Menu.Item> <Menu.Item>
@ -623,7 +598,6 @@ interface EventsProps {
} }
export const Events = ({ network, channel }: EventsProps) => { export const Events = ({ network, channel }: EventsProps) => {
const [logs, setLogs] = useState<IrcEvent[]>([]); const [logs, setLogs] = useState<IrcEvent[]>([]);
const [settings] = SettingsContext.use(); const [settings] = SettingsContext.use();
@ -700,8 +674,8 @@ export const Events = ({ network, channel }: EventsProps) => {
onClick={toggleFullscreen} onClick={toggleFullscreen}
> >
{isFullscreen {isFullscreen
? <span className="flex items-center"><ArrowsPointingInIcon 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>} : <span className="flex items-center"><ArrowsPointingOutIcon className="w-5 h-5" /></span>}
</button> </button>
</div> </div>
<div <div
@ -759,7 +733,7 @@ const IRCLogsDropdown = () => {
return ( return (
<Menu as="div" className="relative"> <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"> <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> </Menu.Button>
<Transition <Transition
as={Fragment} as={Fragment}
@ -771,7 +745,7 @@ const IRCLogsDropdown = () => {
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items <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> <Menu.Item>
{() => ( {() => (

View file

@ -5,126 +5,55 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-hot-toast"; 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 { APIClient } from "@api/APIClient";
import { GithubRelease } from "@app/types/Update";
import Toast from "@components/notifications/Toast"; import Toast from "@components/notifications/Toast";
import { LogLevelOptions, SelectOption } from "@domain/constants"; import { LogLevelOptions, SelectOption } from "@domain/constants";
import { Section, RowItem } from "./_components";
import * as common from "@components/inputs/common";
import { LogFiles } from "@screens/Logs"; import { LogFiles } from "@screens/Logs";
interface RowItemProps { type SelectWrapperProps = {
label: string; id: string;
value?: string; value: unknown;
title?: string; onChange: any;
emptyText?: string; options: unknown[];
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>
);
}; };
interface RowItemNumberProps { const SelectWrapper = ({ id, value, onChange, options }: SelectWrapperProps) => (
label: string; <Select
value?: string | number; id={id}
title?: string; components={{
unit?: string; Input: common.SelectInput,
} Control: common.SelectControl,
Menu: common.SelectMenu,
const RowItemNumber = ({ label, value, title, unit }: RowItemNumberProps) => { Option: common.SelectOption,
return ( IndicatorSeparator: common.IndicatorSeparator,
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-4 sm:gap-4 sm:px-6"> DropdownIndicator: common.DropdownIndicator
<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"> placeholder="Choose a type"
<span className="px-1.5 py-1 bg-gray-200 dark:bg-gray-700 rounded shadow truncate">{value}</span> styles={{
{unit && singleValue: (base) => ({
<span className="ml-1 text-sm text-gray-700 dark:text-gray-400">{unit}</span> ...base,
} color: "unset"
</dd> })
</div> }}
); theme={(theme) => ({
}; ...theme,
spacing: {
const Input = (props: InputProps) => { ...theme.spacing,
return ( controlHeight: 30,
<components.Input baseUnit: 2
{...props} }
inputClassName="outline-none border-none shadow-none focus:ring-transparent" })}
className="text-gray-400 dark:text-gray-100" value={value && options.find((o: any) => o.value == value)}
children={props.children} onChange={onChange}
/> options={options}
); />
}; );
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>
);
};
function LogSettings() { function LogSettings() {
const { isLoading, data } = useQuery({ const { isLoading, data } = useQuery({
@ -140,58 +69,58 @@ function LogSettings() {
const setLogLevelUpdateMutation = useMutation({ const setLogLevelUpdateMutation = useMutation({
mutationFn: (value: string) => APIClient.config.update({ log_level: value }), mutationFn: (value: string) => APIClient.config.update({ log_level: value }),
onSuccess: () => { 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"] }); queryClient.invalidateQueries({ queryKey: ["config"] });
} }
}); });
return ( return (
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9"> <Section
<div className="py-6 px-4 sm:p-6 lg:pb-8"> title="Logs"
<div> description={
<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"> Configure log level, log size rotation, etc. You can download your old log files
Set level, size etc. {" "}
</p> <Link
</div> 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>
<div className="divide-y divide-gray-200 dark:divide-gray-700"> </Section>
<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>
); );
} }

View file

@ -4,16 +4,17 @@
*/ */
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Switch } from "@headlessui/react";
import { APIClient } from "@api/APIClient"; import { APIClient } from "@api/APIClient";
import { EmptySimple } from "@components/emptystates"; import { EmptySimple } from "@components/emptystates";
import { useToggle } from "@hooks/hooks"; import { useToggle } from "@hooks/hooks";
import { NotificationAddForm, NotificationUpdateForm } from "@forms/settings/NotificationForms"; import { NotificationAddForm, NotificationUpdateForm } from "@forms/settings/NotificationForms";
import { classNames } from "@utils";
import { componentMapType } from "@forms/settings/DownloadClientForms"; import { componentMapType } from "@forms/settings/DownloadClientForms";
import Toast from "@components/notifications/Toast"; import Toast from "@components/notifications/Toast";
import toast from "react-hot-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 = { export const notificationKeys = {
all: ["notifications"] as const, all: ["notifications"] as const,
@ -28,50 +29,42 @@ function NotificationSettings() {
const { data } = useQuery({ const { data } = useQuery({
queryKey: notificationKeys.lists(), queryKey: notificationKeys.lists(),
queryFn: APIClient.notifications.getAll, queryFn: APIClient.notifications.getAll,
refetchOnWindowFocus: false } refetchOnWindowFocus: false
}
); );
return ( 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} /> <NotificationAddForm isOpen={addNotificationsIsOpen} toggle={toggleAddNotifications} />
<div className="py-6 px-4 sm:p-6 lg:pb-8"> {data && data.length > 0 ? (
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap"> <ul className="min-w-full">
<div className="ml-4 mt-4"> <li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Notifications</h3> <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>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> <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>
Send notifications on events. <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>
</p> <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>
</div> </li>
<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 ? {data.map((n) => <ListItem key={n.id} notification={n} />)}
<section className="mt-6 light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md"> </ul>
<ol className="min-w-full"> ) : (
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700"> <EmptySimple title="No notifications" subtitle="" buttonText="Create new notification" buttonAction={toggleAddNotifications} />
<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> </Section>
<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>
); );
} }
@ -95,14 +88,14 @@ const TelegramIcon = () => (
const PushoverIcon = () => ( const PushoverIcon = () => (
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" className="mr-2 h-4"> <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" <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> </svg>
); );
const GotifyIcon = () => ( const GotifyIcon = () => (
<svg viewBox="0 0 140 140" xmlns="http://www.w3.org/2000/svg" className="mr-2 h-4"> <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" <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> </svg>
); );
@ -143,27 +136,14 @@ function ListItem({ notification }: ListItemProps) {
<li key={notification.id} className="text-gray-500 dark:text-gray-400"> <li key={notification.id} className="text-gray-500 dark:text-gray-400">
<NotificationUpdateForm isOpen={updateFormIsOpen} toggle={toggleUpdateForm} notification={notification} /> <NotificationUpdateForm isOpen={updateFormIsOpen} toggle={toggleUpdateForm} notification={notification} />
<div className="grid grid-cols-12 items-center py-4"> <div className="grid grid-cols-12 items-center py-2">
<div className="col-span-2 sm:col-span-1 px-6 flex items-center "> <div className="col-span-2 sm:col-span-1 pl-1 py-0.5 sm:pl-5 flex items-center">
<Switch <Checkbox
checked={notification.enabled} value={notification.enabled}
onChange={onToggleMutation} setValue={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> </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} {notification.name}
</div> </div>
<div className="hidden md:flex col-span-2 items-center"> <div className="hidden md:flex col-span-2 items-center">
@ -179,13 +159,12 @@ function ListItem({ notification }: ListItemProps) {
</div> </div>
<div className="col-span-1 flex first-letter:px-6 whitespace-nowrap text-right text-sm font-medium"> <div className="col-span-1 flex first-letter:px-6 whitespace-nowrap text-right text-sm font-medium">
<span <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} onClick={toggleUpdateForm}
> >
Edit Edit
</span> </span>
</div> </div>
</div> </div>
</li> </li>
); );

View file

@ -60,11 +60,11 @@ const RegexPlayground = () => {
return ( return (
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9"> <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> <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"> <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> </p>
</div> </div>
</div> </div>
@ -73,20 +73,20 @@ const RegexPlayground = () => {
htmlFor="input-regex" htmlFor="input-regex"
className="block text-sm font-medium text-gray-600 dark:text-gray-300" className="block text-sm font-medium text-gray-600 dark:text-gray-300"
> >
RegExp filter RegExp filter
</label> </label>
<input <input
ref={regexRef} ref={regexRef}
id="input-regex" id="input-regex"
type="text" type="text"
autoComplete="true" 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 <label
htmlFor="input-lines" htmlFor="input-lines"
className="block text-sm font-medium text-gray-600 dark:text-gray-300" className="block text-sm font-medium text-gray-600 dark:text-gray-300"
> >
Lines to match Lines to match
</label> </label>
<div <div
id="input-lines" id="input-lines"
@ -95,10 +95,10 @@ const RegexPlayground = () => {
contentEditable contentEditable
></div> ></div>
</div> </div>
<div className="py-4 px-4 sm:p-6 lg:pb-8"> <div className="py-6 px-4 sm:p-6">
<div> <div>
<h3 className="text-md leading-6 font-medium text-gray-900 dark:text-white"> <h3 className="text-md leading-6 font-medium text-gray-900 dark:text-white">
Matches Matches
</h3> </h3>
<p className="mt-1 text-lg text-gray-500 dark:text-gray-400"> <p className="mt-1 text-lg text-gray-500 dark:text-gray-400">
{output} {output}

View file

@ -12,40 +12,30 @@ import Toast from "@components/notifications/Toast";
import { releaseKeys } from "@screens/releases/ReleaseTable"; import { releaseKeys } from "@screens/releases/ReleaseTable";
import { useToggle } from "@hooks/hooks"; import { useToggle } from "@hooks/hooks";
import { DeleteModal } from "@components/modals"; import { DeleteModal } from "@components/modals";
import { Section } from "./_components";
function ReleaseSettings() { const ReleaseSettings = () => (
return ( <Section
<div className="lg:col-span-9"> title="Releases"
<div className="py-6 px-4 sm:p-6 lg:pb-8"> description="Manage release history."
>
<div className="border border-red-500 rounded">
<div className="py-6 px-4 sm:p-6">
<div> <div>
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white"> <h2 className="text-lg leading-4 font-bold text-gray-900 dark:text-white">Danger zone</h2>
Releases
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> <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> </p>
</div> </div>
</div> </div>
<div className="py-6 px-4"> <div className="py-6 px-4 sm:p-6">
<div className="border border-red-500 rounded"> <DeleteReleases />
<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> </div>
</div> </div>
); </Section>
} );
const getDurationLabel = (durationValue: number): string => { const getDurationLabel = (durationValue: number): string => {
const durationOptions: Record<number, string> = { const durationOptions: Record<number, string> = {
@ -98,7 +88,7 @@ function DeleteReleases() {
}; };
return ( 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 <DeleteModal
isOpen={deleteModalIsOpen} isOpen={deleteModalIsOpen}
isLoading={deleteOlderMutation.isLoading} 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 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> <p className="text-sm text-gray-500 dark:text-gray-400">Delete releases older than select duration</p>
</label> </label>
<div> <div className="flex flex-wrap gap-2">
<select <select
name="duration" name="duration"
id="duration" id="duration"
@ -139,7 +129,7 @@ function DeleteReleases() {
<button <button
type="button" type="button"
onClick={toggleDeleteModal} 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 Delete
</button> </button>

View file

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

View file

@ -1,4 +1,7 @@
const colors = require("tailwindcss/colors") import { lerpColors } from "tailwind-lerp-colors";
import plugin from "tailwindcss/plugin";
const extendedColors = lerpColors();
module.exports = { module.exports = {
content: [ content: [
@ -23,11 +26,21 @@ module.exports = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
gray: colors.zinc, ...extendedColors,
gray: {
...extendedColors.zinc,
815: "#232427"
}
}, },
margin: { // for the checkmarks used for regex validation in Filters/Advanced margin: { // for the checkmarks used for regex validation in Filters/Advanced
"2.5": "0.625rem", // 10px, between mb-2 (8px) and mb-3 (12px) "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: { variants: {
@ -35,5 +48,14 @@ module.exports = {
}, },
plugins: [ plugins: [
require("@tailwindcss/forms"), 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") }
);
}),
], ],
} }