mirror of
https://github.com/idanoo/autobrr
synced 2025-07-25 17:59:14 +00:00
feat: add webui
This commit is contained in:
parent
a838d994a6
commit
773e57afe6
59 changed files with 19794 additions and 0 deletions
24
web/src/components/EmptyListState.tsx
Normal file
24
web/src/components/EmptyListState.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import React from "react";
|
||||
|
||||
interface props {
|
||||
text: string;
|
||||
buttonText?: string;
|
||||
buttonOnClick?: any;
|
||||
}
|
||||
|
||||
export function EmptyListState({ text, buttonText, buttonOnClick }: props) {
|
||||
return (
|
||||
<div className="px-4 py-12 flex flex-col items-center">
|
||||
<p className="text-center text-gray-500">{text}</p>
|
||||
{buttonText && buttonOnClick && (
|
||||
<button
|
||||
type="button"
|
||||
className="relative inline-flex items-center px-4 py-2 mt-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
onClick={buttonOnClick}
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
665
web/src/components/FilterActionList.tsx
Normal file
665
web/src/components/FilterActionList.tsx
Normal file
|
@ -0,0 +1,665 @@
|
|||
import {Action, DownloadClient} from "../domain/interfaces";
|
||||
import React, {Fragment, useEffect, useRef } from "react";
|
||||
import {Dialog, Listbox, Switch, Transition} from '@headlessui/react'
|
||||
import {classNames} from "../styles/utils";
|
||||
import {CheckIcon, ChevronRightIcon, ExclamationIcon, SelectorIcon,} from "@heroicons/react/solid";
|
||||
import {useToggle} from "../hooks/hooks";
|
||||
import {useMutation} from "react-query";
|
||||
import {queryClient} from "..";
|
||||
import {Field, Form} from "react-final-form";
|
||||
import {TextField} from "./inputs";
|
||||
import DEBUG from "./debug";
|
||||
import APIClient from "../api/APIClient";
|
||||
|
||||
interface radioFieldsetOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const actionTypeOptions: radioFieldsetOption[] = [
|
||||
{label: "Test", value: "TEST"},
|
||||
{label: "Watch dir", value: "WATCH_FOLDER"},
|
||||
{label: "Exec", value: "EXEC"},
|
||||
{label: "qBittorrent", value: "QBITTORRENT"},
|
||||
{label: "Deluge", value: "DELUGE"},
|
||||
];
|
||||
|
||||
interface FilterListProps {
|
||||
actions: Action[];
|
||||
clients: DownloadClient[];
|
||||
filterID: number;
|
||||
}
|
||||
|
||||
export function FilterActionList({actions, clients, filterID}: FilterListProps) {
|
||||
useEffect(() => {
|
||||
// console.log("render list")
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{actions.map((action, idx) => (
|
||||
<ListItem action={action} clients={clients} filterID={filterID} key={action.id} idx={idx} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListItemProps {
|
||||
action: Action;
|
||||
clients: DownloadClient[];
|
||||
filterID: number;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
function ListItem({action, clients, filterID, idx}: ListItemProps) {
|
||||
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false)
|
||||
const [edit, toggleEdit] = useToggle(false)
|
||||
|
||||
const deleteMutation = useMutation((actionID: number) => APIClient.actions.delete(actionID), {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['filter',filterID]);
|
||||
toggleDeleteModal()
|
||||
}
|
||||
})
|
||||
|
||||
const enabledMutation = useMutation((actionID: number) => APIClient.actions.toggleEnable(actionID), {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['filter',filterID]);
|
||||
}
|
||||
})
|
||||
|
||||
const updateMutation = useMutation((action: Action) => APIClient.actions.update(action), {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['filter',filterID]);
|
||||
}
|
||||
})
|
||||
|
||||
const toggleActive = () => {
|
||||
enabledMutation.mutate(action.id)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
}, [action])
|
||||
|
||||
const cancelButtonRef = useRef(null)
|
||||
|
||||
const deleteAction = () => {
|
||||
deleteMutation.mutate(action.id)
|
||||
}
|
||||
|
||||
const onSubmit = (action: Action) => {
|
||||
// TODO clear data depending on type
|
||||
updateMutation.mutate(action)
|
||||
};
|
||||
|
||||
const TypeForm = (action: Action) => {
|
||||
switch (action.type) {
|
||||
case "TEST":
|
||||
return (
|
||||
<div className="py-4">
|
||||
<div className="rounded-md bg-yellow-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true"/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">Notice</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<p>
|
||||
The test action does nothing except to show if the filter works.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case "EXEC":
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||
<TextField name="exec_cmd" label="Command" columns={6} placeholder="Path to program eg. /bin/test"/>
|
||||
<TextField name="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="watch_folder" label="Watch folder" columns={6} placeholder="Watch directory eg. /home/user/rwatch"/>
|
||||
</div>
|
||||
)
|
||||
case "QBITTORRENT":
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||
|
||||
<div className="col-span-6 sm:col-span-6">
|
||||
<Field
|
||||
name="client_id"
|
||||
type="select"
|
||||
render={({input}) => (
|
||||
<Listbox value={input.value} onChange={input.onChange}>
|
||||
{({open}) => (
|
||||
<>
|
||||
<Listbox.Label
|
||||
className="block text-xs font-bold text-gray-700 uppercase tracking-wide">Client</Listbox.Label>
|
||||
<div className="mt-2 relative">
|
||||
<Listbox.Button
|
||||
className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||
<span
|
||||
className="block truncate">{input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"}</span>
|
||||
{/*<span className="block truncate">Choose a client</span>*/}
|
||||
<span
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<SelectorIcon className="h-5 w-5 text-gray-400" aria-hidden="true"/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options
|
||||
static
|
||||
className="absolute z-10 mt-1 w-full bg-white 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"
|
||||
>
|
||||
{clients.filter((c) => c.type === action.type).map((client: any) => (
|
||||
<Listbox.Option
|
||||
key={client.id}
|
||||
className={({active}) =>
|
||||
classNames(
|
||||
active ? 'text-white bg-indigo-600' : 'text-gray-900',
|
||||
'cursor-default select-none relative py-2 pl-3 pr-9'
|
||||
)
|
||||
}
|
||||
value={client.id}
|
||||
>
|
||||
{({selected, active}) => (
|
||||
<>
|
||||
<span className={classNames(selected ? 'font-semibold' : 'font-normal', 'block truncate')}>
|
||||
{client.name}
|
||||
</span>
|
||||
|
||||
{selected ? (
|
||||
<span
|
||||
className={classNames(
|
||||
active ? 'text-white' : 'text-indigo-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4'
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true"/>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-6 sm:col-span-6">
|
||||
<TextField name="save_path" label="Save path" columns={6}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||
<TextField name="category" label="Category" columns={6}/>
|
||||
<TextField name="tags" label="Tags" columns={6}/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
|
||||
Limit upload speed (kb/s)
|
||||
</label>
|
||||
|
||||
<Field name="limit_upload_speed">
|
||||
{({input, meta}) => (
|
||||
<div className="sm:col-span-2">
|
||||
<input
|
||||
type="number"
|
||||
{...input}
|
||||
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
|
||||
/>
|
||||
{meta.touched && meta.error &&
|
||||
<span>{meta.error}</span>}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
|
||||
Limit download speed (kb/s)
|
||||
</label>
|
||||
|
||||
<Field name="limit_download_speed">
|
||||
{({input, meta}) => (
|
||||
<div className="sm:col-span-2">
|
||||
<input
|
||||
type="number"
|
||||
{...input}
|
||||
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
|
||||
/>
|
||||
{meta.touched && meta.error &&
|
||||
<span>{meta.error}</span>}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case "DELUGE":
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<Field
|
||||
name="client_id"
|
||||
type="select"
|
||||
render={({input}) => (
|
||||
<Listbox value={input.value} onChange={input.onChange}>
|
||||
{({open}) => (
|
||||
<>
|
||||
<Listbox.Label
|
||||
className="block text-xs font-bold text-gray-700 uppercase tracking-wide">Client</Listbox.Label>
|
||||
<div className="mt-2 relative">
|
||||
<Listbox.Button
|
||||
className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||
<span
|
||||
className="block truncate">{input.value ? clients.find(c => c.id === input.value)!.name : "Choose a client"}</span>
|
||||
{/*<span className="block truncate">Choose a client</span>*/}
|
||||
<span
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<SelectorIcon className="h-5 w-5 text-gray-400" aria-hidden="true"/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options
|
||||
static
|
||||
className="absolute z-10 mt-1 w-full bg-white 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"
|
||||
>
|
||||
{clients.filter((c) => c.type === action.type).map((client: any) => (
|
||||
<Listbox.Option
|
||||
key={client.id}
|
||||
className={({active}) =>
|
||||
classNames(
|
||||
active ? 'text-white bg-indigo-600' : 'text-gray-900',
|
||||
'cursor-default select-none relative py-2 pl-3 pr-9'
|
||||
)
|
||||
}
|
||||
value={client.id}
|
||||
>
|
||||
{({selected, active}) => (
|
||||
<>
|
||||
<span className={classNames(selected ? 'font-semibold' : 'font-normal', 'block truncate')}>
|
||||
{client.name}
|
||||
</span>
|
||||
|
||||
{selected ? (
|
||||
<span
|
||||
className={classNames(
|
||||
active ? 'text-white' : 'text-indigo-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4'
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true"/>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}/>
|
||||
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<TextField name="save_path" label="Save path" columns={6}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 col-span-12 sm:col-span-6">
|
||||
<TextField name="label" label="Label" columns={6}/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
|
||||
Limit upload speed (kb/s)
|
||||
</label>
|
||||
|
||||
<Field name="limit_upload_speed">
|
||||
{({input, meta}) => (
|
||||
<div className="sm:col-span-2">
|
||||
<input
|
||||
type="number"
|
||||
{...input}
|
||||
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
|
||||
/>
|
||||
{meta.touched && meta.error &&
|
||||
<span>{meta.error}</span>}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
|
||||
Limit download speed (kb/s)
|
||||
</label>
|
||||
|
||||
<Field name="limit_download_speed">
|
||||
{({input, meta}) => (
|
||||
<div className="sm:col-span-2">
|
||||
<input
|
||||
type="number"
|
||||
{...input}
|
||||
className="block w-full shadow-sm sm:text-sm focus:ring-indigo-500 focus:border-indigo-500 border-gray-300 rounded-md"
|
||||
/>
|
||||
{meta.touched && meta.error &&
|
||||
<span>{meta.error}</span>}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return <p>default</p>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={action.id}>
|
||||
<div className={classNames(idx % 2 === 0 ? 'bg-white' : 'bg-gray-50', "flex items-center sm:px-6 hover:bg-gray-50")}>
|
||||
<Switch
|
||||
checked={action.enabled}
|
||||
onChange={toggleActive}
|
||||
className={classNames(
|
||||
action.enabled ? 'bg-teal-500' : 'bg-gray-200',
|
||||
'z-10 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-light-blue-500'
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
action.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>
|
||||
<button className="px-4 py-4 w-full flex block" 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-indigo-600 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">{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">
|
||||
<Transition.Root show={deleteModalIsOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
static
|
||||
className="fixed z-10 inset-0 overflow-y-auto"
|
||||
initialFocus={cancelButtonRef}
|
||||
open={deleteModalIsOpen}
|
||||
onClose={toggleDeleteModal}
|
||||
>
|
||||
<div
|
||||
className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"/>
|
||||
</Transition.Child>
|
||||
|
||||
{/* This element is to trick the browser into centering the modal contents. */}
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div
|
||||
className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div
|
||||
className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ExclamationIcon className="h-6 w-6 text-red-600" aria-hidden="true"/>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<Dialog.Title as="h3"
|
||||
className="text-lg leading-6 font-medium text-gray-900">
|
||||
Remove filter action
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
Are you sure you want to remove this action?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={deleteAction}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={toggleDeleteModal}
|
||||
ref={cancelButtonRef}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
|
||||
<Form
|
||||
initialValues={{
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
enabled: action.enabled,
|
||||
type: action.type,
|
||||
watch_folder: action.watch_folder,
|
||||
exec_cmd: action.exec_cmd,
|
||||
exec_args: action.exec_args,
|
||||
category: action.category,
|
||||
tags: action.tags,
|
||||
label: action.label,
|
||||
save_path: action.save_path,
|
||||
paused: action.paused,
|
||||
ignore_rules: action.ignore_rules,
|
||||
limit_upload_speed: action.limit_upload_speed || 0,
|
||||
limit_download_speed: action.limit_download_speed || 0,
|
||||
filter_id: action.filter_id,
|
||||
client_id: action.client_id,
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({handleSubmit, values}) => {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
|
||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||
<div className="col-span-6">
|
||||
|
||||
<Field
|
||||
name="type"
|
||||
type="select"
|
||||
render={({input}) => (
|
||||
<Listbox value={input.value} onChange={input.onChange}>
|
||||
{({open}) => (
|
||||
<>
|
||||
<Listbox.Label
|
||||
className="block text-xs font-bold text-gray-700 uppercase tracking-wide">Type</Listbox.Label>
|
||||
<div className="mt-2 relative">
|
||||
<Listbox.Button
|
||||
className="bg-white relative w-full border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
|
||||
<span
|
||||
className="block truncate">{input.value ? actionTypeOptions.find(c => c.value === input.value)!.label : "Choose a type"}</span>
|
||||
<span
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<SelectorIcon className="h-5 w-5 text-gray-400" aria-hidden="true"/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options
|
||||
static
|
||||
className="absolute z-10 mt-1 w-full bg-white 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"
|
||||
>
|
||||
{actionTypeOptions.map((opt) => (
|
||||
<Listbox.Option
|
||||
key={opt.value}
|
||||
className={({active}) =>
|
||||
classNames(
|
||||
active ? 'text-white bg-indigo-600' : 'text-gray-900',
|
||||
'cursor-default select-none relative py-2 pl-3 pr-9'
|
||||
)
|
||||
}
|
||||
value={opt.value}
|
||||
>
|
||||
{({selected, active}) => (
|
||||
<>
|
||||
<span className={classNames(selected ? 'font-semibold' : 'font-normal', 'block truncate')}>
|
||||
{opt.label}
|
||||
</span>
|
||||
|
||||
{selected ? (
|
||||
<span
|
||||
className={classNames(
|
||||
active ? 'text-white' : 'text-indigo-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4'
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true"/>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}/>
|
||||
</div>
|
||||
|
||||
<TextField name="name" label="Name" columns={6}/>
|
||||
|
||||
</div>
|
||||
|
||||
{TypeForm(values)}
|
||||
|
||||
<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 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
|
||||
onClick={toggleDeleteModal}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white border border-gray-300 rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-light-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-4 relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DEBUG values={values}/>
|
||||
</form>
|
||||
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
)
|
||||
}
|
15
web/src/components/debug.tsx
Normal file
15
web/src/components/debug.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import React from "react";
|
||||
|
||||
const DEBUG = ({ values }: any) => {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-1/2 mx-auto mt-2 flex flex-col mt-12 mb-12">
|
||||
<pre className="mt-2">{JSON.stringify(values, 0 as any, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DEBUG;
|
27
web/src/components/empty/EmptySimple.tsx
Normal file
27
web/src/components/empty/EmptySimple.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import {PlusIcon} from "@heroicons/react/solid";
|
||||
|
||||
interface props {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
buttonText: string;
|
||||
buttonAction: any;
|
||||
}
|
||||
|
||||
const EmptySimple = ({ title, subtitle, buttonText, buttonAction}: props) => (
|
||||
<div className="text-center py-8">
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">{title}</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">{subtitle}</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={buttonAction}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<PlusIcon className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
|
||||
{buttonText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default EmptySimple;
|
15
web/src/components/headings/TitleSubtitle.tsx
Normal file
15
web/src/components/headings/TitleSubtitle.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import React from "react";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
const TitleSubtitle: React.FC<Props> = ({ title, subtitle }) => (
|
||||
<div>
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900">{title}</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">{subtitle}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default TitleSubtitle;
|
20
web/src/components/inputs/Error.tsx
Normal file
20
web/src/components/inputs/Error.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import React from "react";
|
||||
import { Field } from "react-final-form";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
classNames?: string;
|
||||
subscribe?: any;
|
||||
}
|
||||
|
||||
const Error: React.FC<Props> = ({ name, classNames }) => (
|
||||
<Field
|
||||
name={name}
|
||||
subscribe={{ touched: true, error: true }}
|
||||
render={({ meta: { touched, error } }) =>
|
||||
touched && error ? <span className={classNames}>{error}</span> : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
export default Error;
|
50
web/src/components/inputs/MultiSelectField.tsx
Normal file
50
web/src/components/inputs/MultiSelectField.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import React from "react";
|
||||
import {Field} from "react-final-form";
|
||||
import MultiSelect from "react-multi-select-component";
|
||||
import {classNames, COL_WIDTHS} from "../../styles/utils";
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
options?: [] | any;
|
||||
name: string;
|
||||
className?: string;
|
||||
columns?: COL_WIDTHS;
|
||||
}
|
||||
|
||||
const MultiSelectField: React.FC<Props> = ({
|
||||
name,
|
||||
label,
|
||||
options,
|
||||
className,
|
||||
columns
|
||||
}) => (
|
||||
<div
|
||||
className={classNames(
|
||||
columns ? `col-span-${columns}` : "col-span-12"
|
||||
)}
|
||||
>
|
||||
<label
|
||||
className="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
|
||||
htmlFor={label}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<Field
|
||||
name={name}
|
||||
parse={val => val && val.map((item: any) => item.value)}
|
||||
format={val =>
|
||||
val &&
|
||||
val.map((item: any) => options.find((o: any) => o.value === item))
|
||||
}
|
||||
render={({input, meta}) => (
|
||||
<MultiSelect
|
||||
{...input}
|
||||
options={options}
|
||||
labelledBy={name}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MultiSelectField;
|
60
web/src/components/inputs/RadioFieldset.tsx
Normal file
60
web/src/components/inputs/RadioFieldset.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import React from "react";
|
||||
import {Field} from "react-final-form";
|
||||
|
||||
export interface radioFieldsetOption {
|
||||
label: string;
|
||||
description: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface props {
|
||||
name: string;
|
||||
legend: string;
|
||||
options: radioFieldsetOption[];
|
||||
}
|
||||
|
||||
const RadioFieldset: React.FC<props> = ({ name, legend,options }) => (
|
||||
<fieldset>
|
||||
<div className="space-y-2 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:px-6 sm:py-5">
|
||||
<div>
|
||||
<legend className="text-sm font-medium text-gray-900">{legend}</legend>
|
||||
</div>
|
||||
<div className="space-y-5 sm:col-span-2">
|
||||
<div className="space-y-5 sm:mt-0">
|
||||
|
||||
{options.map((opt, idx) => (
|
||||
<div className="relative flex items-start" key={idx}>
|
||||
<div className="absolute flex items-center h-5">
|
||||
<Field
|
||||
name={name}
|
||||
type="radio"
|
||||
render={({input}) => (
|
||||
<input
|
||||
{...input}
|
||||
id={name}
|
||||
value={opt.value}
|
||||
// type="radio"
|
||||
checked={input.checked}
|
||||
className="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="pl-7 text-sm">
|
||||
<label htmlFor={opt.value} className="font-medium text-gray-900">
|
||||
{opt.label}
|
||||
</label>
|
||||
<p id={opt.value+"_description"} className="text-gray-500">
|
||||
{opt.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
|
||||
export default RadioFieldset;
|
55
web/src/components/inputs/SwitchGroup.tsx
Normal file
55
web/src/components/inputs/SwitchGroup.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import React from "react";
|
||||
import {Switch} from "@headlessui/react";
|
||||
import {Field} from "react-final-form";
|
||||
import {classNames} from "../../styles/utils";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SwitchGroup: React.FC<Props> = ({name, label, description}) => (
|
||||
<ul className="mt-2 divide-y divide-gray-200">
|
||||
<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"
|
||||
passive>
|
||||
{label}
|
||||
</Switch.Label>
|
||||
{description && (
|
||||
<Switch.Description className="text-sm text-gray-500">
|
||||
{description}
|
||||
</Switch.Description>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
name={name}
|
||||
render={({input: {onChange, checked, value}}) => (
|
||||
<Switch
|
||||
value={value}
|
||||
checked={value}
|
||||
onChange={onChange}
|
||||
className={classNames(
|
||||
value ? 'bg-teal-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-light-blue-500'
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
</Switch.Group>
|
||||
</ul>
|
||||
)
|
||||
|
||||
export default SwitchGroup;
|
40
web/src/components/inputs/TextAreaWide.tsx
Normal file
40
web/src/components/inputs/TextAreaWide.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import {Field} from "react-final-form";
|
||||
import React from "react";
|
||||
import Error from "./Error";
|
||||
import {classNames} from "../../styles/utils";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const TextAreaWide: React.FC<Props> = ({name, label, placeholder, required, className}) => (
|
||||
<div
|
||||
className="space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
|
||||
<div>
|
||||
|
||||
<label htmlFor={name} className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2">
|
||||
{label} {required && <span className="text-gray-500">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Field
|
||||
name={name}
|
||||
render={({input, meta}) => (
|
||||
<textarea
|
||||
{...input}
|
||||
id={name}
|
||||
className={classNames(meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 focus:border-indigo-500 border-gray-300", "block w-full shadow-sm sm:text-sm rounded-md")}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Error name={name} classNames="block text-red-500 mt-2"/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default TextAreaWide;
|
45
web/src/components/inputs/TextField.tsx
Normal file
45
web/src/components/inputs/TextField.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { Field } from "react-final-form";
|
||||
import React from "react";
|
||||
import Error from "./Error";
|
||||
import {classNames} from "../../styles/utils";
|
||||
|
||||
type COL_WIDTHS = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
columns?: COL_WIDTHS;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TextField: React.FC<Props> = ({ name, label, placeholder, columns , className}) => (
|
||||
<div
|
||||
className={classNames(
|
||||
columns ? `col-span-${columns}` : "col-span-12"
|
||||
)}
|
||||
>
|
||||
{label && (
|
||||
<label htmlFor={name} className="block text-xs font-bold text-gray-700 uppercase tracking-wide">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Field
|
||||
name={name}
|
||||
render={({input, meta}) => (
|
||||
<input
|
||||
{...input}
|
||||
id={name}
|
||||
type="text"
|
||||
className="mt-2 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<Error name={name} classNames="text-red mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default TextField;
|
41
web/src/components/inputs/TextFieldWide.tsx
Normal file
41
web/src/components/inputs/TextFieldWide.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import {Field} from "react-final-form";
|
||||
import React from "react";
|
||||
import Error from "./Error";
|
||||
import {classNames} from "../../styles/utils";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
const TextFieldWide: React.FC<Props> = ({name, label, placeholder, required, className}) => (
|
||||
<div
|
||||
className="space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6 sm:py-5">
|
||||
<div>
|
||||
|
||||
<label htmlFor={name} className="block text-sm font-medium text-gray-900 sm:mt-px sm:pt-2">
|
||||
{label} {required && <span className="text-gray-500">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Field
|
||||
name={name}
|
||||
render={({input, meta}) => (
|
||||
<input
|
||||
{...input}
|
||||
id={name}
|
||||
type="text"
|
||||
className={classNames(meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 focus:border-indigo-500 border-gray-300", "block w-full shadow-sm sm:text-sm rounded-md")}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Error name={name} classNames="block text-red-500 mt-2"/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default TextFieldWide;
|
6
web/src/components/inputs/index.ts
Normal file
6
web/src/components/inputs/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export { default as TextField } from "./TextField";
|
||||
export { default as TextFieldWide } from "./TextFieldWide";
|
||||
export { default as TextAreaWide } from "./TextAreaWide";
|
||||
export { default as MultiSelectField } from "./MultiSelectField";
|
||||
export { default as RadioFieldset } from "./RadioFieldset";
|
||||
export { default as SwitchGroup } from "./SwitchGroup";
|
92
web/src/components/modals/Delete.tsx
Normal file
92
web/src/components/modals/Delete.tsx
Normal file
|
@ -0,0 +1,92 @@
|
|||
import {Fragment} from "react";
|
||||
import {Dialog, Transition} from "@headlessui/react";
|
||||
import {ExclamationIcon} from "@heroicons/react/solid";
|
||||
|
||||
interface props {
|
||||
isOpen: boolean;
|
||||
buttonRef: any;
|
||||
toggle: any;
|
||||
deleteAction: any;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const DeleteModal = ({ isOpen, buttonRef, toggle, deleteAction, title, text }: props) => (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
static
|
||||
className="fixed z-10 inset-0 overflow-y-auto"
|
||||
initialFocus={buttonRef}
|
||||
open={isOpen}
|
||||
onClose={toggle}
|
||||
>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* This element is to trick the browser into centering the modal contents. */}
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ExclamationIcon className="h-6 w-6 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={deleteAction}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={toggle}
|
||||
ref={buttonRef}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
)
|
||||
|
||||
export default DeleteModal;
|
1
web/src/components/modals/index.ts
Normal file
1
web/src/components/modals/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as DeleteModal } from "./Delete";
|
Loading…
Add table
Add a link
Reference in a new issue