feat: add torznab feed support (#246)

* feat(torznab): initial impl

* feat: torznab processing

* feat: torznab more scheduling

* feat: feeds web

* feat(feeds): create on indexer create

* feat(feeds): update migration

* feat(feeds): restart on update

* feat(feeds): set cron schedule

* feat(feeds): use basic empty state

* chore: remove duplicate migrations

* feat: parse release size from torznab

* chore: cleanup unused code
This commit is contained in:
Ludvig Lundgren 2022-04-25 12:58:54 +02:00 committed by GitHub
parent d4d864cd2c
commit bb62e724a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 2408 additions and 361 deletions

View file

@ -37,7 +37,12 @@ export async function HttpClient<T>(
if ([403, 404].includes(response.status))
return Promise.reject(new Error(response.statusText));
if ([201, 204].includes(response.status))
// 201 comes from a POST and can contain data
if ([201].includes(response.status))
return await response.json();
// 204 ok no data
if ([204].includes(response.status))
return Promise.resolve(response);
if (response.ok) {
@ -51,7 +56,7 @@ export async function HttpClient<T>(
const appClient = {
Get: <T>(endpoint: string) => HttpClient<T>(endpoint, "GET"),
Post: (endpoint: string, data: any) => HttpClient<void>(endpoint, "POST", { body: data }),
Post: <T>(endpoint: string, data: any) => HttpClient<void | T>(endpoint, "POST", { body: data }),
Put: (endpoint: string, data: any) => HttpClient<void>(endpoint, "PUT", { body: data }),
Patch: (endpoint: string, data: any) => HttpClient<void>(endpoint, "PATCH", { body: data }),
Delete: (endpoint: string) => HttpClient<void>(endpoint, "DELETE")
@ -90,6 +95,13 @@ export const APIClient = {
toggleEnable: (id: number, enabled: boolean) => appClient.Put(`api/filters/${id}/enabled`, { enabled }),
delete: (id: number) => appClient.Delete(`api/filters/${id}`),
},
feeds: {
find: () => appClient.Get<Feed[]>("api/feeds"),
create: (feed: FeedCreate) => appClient.Post("api/feeds", feed),
toggleEnable: (id: number, enabled: boolean) => appClient.Patch(`api/feeds/${id}/enabled`, { enabled }),
update: (feed: Feed) => appClient.Put(`api/feeds/${feed.id}`, feed),
delete: (id: number) => appClient.Delete(`api/feeds/${id}`),
},
indexers: {
// returns indexer options for all currently present/enabled indexers
getOptions: () => appClient.Get<Indexer[]>("api/indexer/options"),
@ -97,7 +109,7 @@ export const APIClient = {
getAll: () => appClient.Get<IndexerDefinition[]>("api/indexer"),
// returns all possible indexer definitions
getSchema: () => appClient.Get<IndexerDefinition[]>("api/indexer/schema"),
create: (indexer: Indexer) => appClient.Post("api/indexer", indexer),
create: (indexer: Indexer) => appClient.Post<Indexer>("api/indexer", indexer),
update: (indexer: Indexer) => appClient.Put("api/indexer", indexer),
delete: (id: number) => appClient.Delete(`api/indexer/${id}`),
},

View file

@ -1,5 +1,18 @@
import { PlusIcon } from "@heroicons/react/solid";
interface EmptyBasicProps {
title: string;
subtitle?: string;
}
export const EmptyBasic = ({ title, subtitle }: EmptyBasicProps) => (
<div className="text-center py-16">
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">{title}</h3>
{subtitle ?? <p className="mt-1 text-sm text-gray-500 dark:text-gray-200">{subtitle}</p>}
</div>
)
interface EmptySimpleProps {
title: string;
subtitle: string;

View file

@ -0,0 +1,115 @@
import {useMutation} from "react-query";
import {APIClient} from "../../api/APIClient";
import {queryClient} from "../../App";
import {toast} from "react-hot-toast";
import Toast from "../../components/notifications/Toast";
import {SlideOver} from "../../components/panels";
import {NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide} from "../../components/inputs";
import {ImplementationMap} from "../../screens/settings/Feed";
interface UpdateProps {
isOpen: boolean;
toggle: any;
feed: Feed;
}
export function FeedUpdateForm({isOpen, toggle, feed}: UpdateProps) {
const mutation = useMutation(
(feed: Feed) => APIClient.feeds.update(feed),
{
onSuccess: () => {
queryClient.invalidateQueries(["feeds"]);
toast.custom((t) => <Toast type="success" body={`${feed.name} was updated successfully`} t={t}/>)
toggle();
},
}
);
const deleteMutation = useMutation(
(feedID: number) => APIClient.feeds.delete(feedID),
{
onSuccess: () => {
queryClient.invalidateQueries(["feeds"]);
toast.custom((t) => <Toast type="success" body={`${feed.name} was deleted.`} t={t}/>)
},
}
);
const onSubmit = (formData: any) => {
mutation.mutate(formData);
}
const deleteAction = () => {
deleteMutation.mutate(feed.id);
};
const initialValues = {
id: feed.id,
indexer: feed.indexer,
enabled: feed.enabled,
type: feed.type,
name: feed.name,
url: feed.url,
api_key: feed.api_key,
interval: feed.interval,
}
return (
<SlideOver
type="UPDATE"
title="Feed"
isOpen={isOpen}
toggle={toggle}
onSubmit={onSubmit}
deleteAction={deleteAction}
initialValues={initialValues}
>
{(values) => (
<div>
<TextFieldWide name="name" label="Name" required={true}/>
<div className="space-y-4 divide-y divide-gray-200 dark:divide-gray-700">
<div className="py-4 flex items-center justify-between 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="type"
className="block text-sm font-medium text-gray-900 dark:text-white"
>
Type
</label>
</div>
<div className="flex justify-end sm:col-span-2">
{ImplementationMap[feed.type]}
</div>
</div>
<div className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<SwitchGroupWide name="enabled" label="Enabled"/>
</div>
</div>
{componentMap[values.type]}
</div>
)}
</SlideOver>
)
}
function FormFieldsTorznab() {
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-5">
<TextFieldWide
name="url"
label="URL"
help="Torznab url"
/>
<PasswordFieldWide name="api_key" label="API key" />
<NumberFieldWide name="interval" label="Refresh interval" help="Minutes. Recommended 15-30. To low and risk ban." />
</div>
);
}
const componentMap: any = {
TORZNAB: <FormFieldsTorznab/>,
};

View file

@ -8,7 +8,7 @@ import type { FieldProps } from "formik";
import { XIcon } from "@heroicons/react/solid";
import { Dialog, Transition } from "@headlessui/react";
import { sleep } from "../../utils";
import {sleep, slugify} from "../../utils";
import { queryClient } from "../../App";
import DEBUG from "../../components/debug";
import { APIClient } from "../../api/APIClient";
@ -81,12 +81,37 @@ const IrcSettingFields = (ind: IndexerDefinition, indexer: string) => {
}
return null
})}
</div>
)}
</Fragment>
)
}
}
{/* <div hidden={false}>
<TextFieldWide name="irc.server" label="Server" defaultValue={ind.irc.server} />
<NumberFieldWide name="irc.port" label="Port" defaultValue={ind.irc.port} />
<SwitchGroupWide name="irc.tls" label="TLS" defaultValue={ind.irc.tls} />
</div> */}
const FeedSettingFields = (ind: IndexerDefinition, indexer: string) => {
if (indexer !== "") {
return (
<Fragment>
{ind && ind.torznab && ind.torznab.settings && (
<div className="">
<div className="px-6 space-y-1">
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">Torznab</Dialog.Title>
<p className="text-sm text-gray-500 dark:text-gray-200">
Torznab feed
</p>
</div>
<TextFieldWide name="name" label="Name" defaultValue={""} />
{ind.torznab.settings.map((f: IndexerSetting, idx: number) => {
switch (f.type) {
case "text":
return <TextFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} />
case "secret":
return <PasswordFieldWide name={`feed.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} />
}
return null
})}
</div>
)}
</Fragment>
@ -119,6 +144,22 @@ const SettingFields = (ind: IndexerDefinition, indexer: string) => {
}
}
function slugIdentifier(name: string) {
const l = name.toLowerCase()
const r = l.replaceAll("torznab", "")
return slugify(`torznab-${r}`)
}
// interface initialValues {
// enabled: boolean;
// identifier: string;
// implementation: string;
// name: string;
// irc?: Record<string, unknown>;
// feed?: Record<string, unknown>;
// settings?: Record<string, unknown>;
// }
interface AddProps {
isOpen: boolean;
toggle: any;
@ -151,104 +192,77 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
(network: IrcNetworkCreate) => APIClient.irc.createNetwork(network)
);
const feedMutation = useMutation(
(feed: FeedCreate) => APIClient.feeds.create(feed)
);
const onSubmit = (formData: any) => {
const ind = data && data.find(i => i.identifier === formData.identifier);
if (!ind)
return;
const channels: IrcChannel[] = [];
if (ind.irc.channels.length) {
ind.irc.channels.forEach(element => {
channels.push({
id: 0,
enabled: true,
name: element,
password: "",
detached: false,
monitoring: false
if (formData.implementation === "torznab") {
// create slug for indexer identifier as "torznab-indexer_name"
const name = slugIdentifier(formData.name)
const createFeed: FeedCreate = {
name: formData.name,
enabled: false,
type: "TORZNAB",
url: formData.feed.url,
api_key: formData.feed.api_key,
interval: 30,
indexer: name,
indexer_id: 0,
}
mutation.mutate(formData, {
onSuccess: (indexer) => {
createFeed.indexer_id = indexer!.id
feedMutation.mutate(createFeed)
}
});
return;
}
if (formData.implementation === "irc") {
const channels: IrcChannel[] = [];
if (ind.irc?.channels.length) {
ind.irc.channels.forEach(element => {
channels.push({
id: 0,
enabled: true,
name: element,
password: "",
detached: false,
monitoring: false
});
});
}
const network: IrcNetworkCreate = {
name: ind.irc.network,
pass: "",
enabled: false,
connected: false,
server: ind.irc.server,
port: ind.irc.port,
tls: ind.irc.tls,
nickserv: formData.irc.nickserv,
invite_command: formData.irc.invite_command,
channels: channels,
}
mutation.mutate(formData, {
onSuccess: () => {
ircMutation.mutate(network)
}
});
}
const network: IrcNetworkCreate = {
name: ind.irc.network,
pass: "",
enabled: false,
connected: false,
server: ind.irc.server,
port: ind.irc.port,
tls: ind.irc.tls,
nickserv: formData.irc.nickserv,
invite_command: formData.irc.invite_command,
channels: channels,
}
mutation.mutate(formData, {
onSuccess: () => ircMutation.mutate(network)
});
};
const renderSettingFields = (indexer: string) => {
if (indexer !== "") {
const ind = data && data.find(i => i.identifier === indexer);
return (
<div key="opt">
{ind && ind.settings && ind.settings.map((f: any, idx: number) => {
switch (f.type) {
case "text":
return (
<TextFieldWide name={`settings.${f.name}`} label={f.label} key={idx} help={f.help} defaultValue="" />
)
case "secret":
return (
<PasswordFieldWide name={`settings.${f.name}`} label={f.label} key={idx} help={f.help} defaultValue="" />
)
}
return null
})}
<div hidden={true}>
<TextFieldWide name="name" label="Name" defaultValue={ind?.name} />
</div>
</div>
)
}
}
const renderIrcSettingFields = (indexer: string) => {
if (indexer !== "") {
const ind = data && data.find(i => i.identifier === indexer);
return (
<Fragment>
{ind && ind.irc && ind.irc.settings && (
<div className="border-t border-gray-200 dark:border-gray-700 py-5">
<div className="px-6 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, channels and invite commands are configured automatically.
</p>
</div>
{ind.irc.settings.map((f: IndexerSetting, idx: number) => {
switch (f.type) {
case "text":
return <TextFieldWide name={`irc.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} />
case "secret":
return <PasswordFieldWide name={`irc.${f.name}`} label={f.label} required={f.required} key={idx} help={f.help} defaultValue={f.default} />
}
return null
})}
{/* <div hidden={false}>
<TextFieldWide name="irc.server" label="Server" defaultValue={ind.irc.server} />
<NumberFieldWide name="irc.port" label="Port" defaultValue={ind.irc.port} />
<SwitchGroupWide name="irc.tls" label="TLS" defaultValue={ind.irc.tls} />
</div> */}
</div>
)}
</Fragment>
)
}
}
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
@ -271,10 +285,10 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
initialValues={{
enabled: true,
identifier: "",
implementation: "irc",
name: "",
irc: {
invite_command: "",
},
irc: {},
feed: {},
settings: {},
}}
onSubmit={onSubmit}
@ -344,8 +358,9 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
setFieldValue(field.name, option?.value ?? "")
const ind = data!.find(i => i.identifier === option.value);
setFieldValue("implementation", ind?.implementation ? ind.implementation : "irc")
setIndexer(ind!)
if (ind!.irc.settings) {
if (ind!.irc?.settings) {
ind!.irc.settings.forEach((s) => {
setFieldValue(`irc.${s.name}`, s.default ?? "")
})
@ -371,6 +386,7 @@ export function IndexerAddForm({ isOpen, toggle }: AddProps) {
</div>
{IrcSettingFields(indexer, values.identifier)}
{FeedSettingFields(indexer, values.identifier)}
</div>
<div
@ -440,7 +456,7 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
}
const renderSettingFields = (settings: IndexerSetting[]) => {
if (settings === undefined) {
if (settings === undefined || settings === null) {
return null
}
@ -468,6 +484,7 @@ export function IndexerUpdateForm({ isOpen, toggle, indexer }: UpdateProps) {
name: indexer.name,
enabled: indexer.enabled,
identifier: indexer.identifier,
implementation: indexer.implementation,
settings: indexer.settings?.reduce(
(o: Record<string, string>, obj: IndexerSetting) => ({
...o,

View file

@ -1,4 +1,4 @@
import {BellIcon, CogIcon, CollectionIcon, DownloadIcon, KeyIcon} from '@heroicons/react/outline'
import {BellIcon, ChatAlt2Icon, CogIcon, CollectionIcon, DownloadIcon, KeyIcon, RssIcon} from '@heroicons/react/outline'
import {NavLink, Route, Switch as RouteSwitch, useLocation, useRouteMatch} from "react-router-dom";
import { classNames } from "../utils";
@ -9,16 +9,17 @@ import DownloadClientSettings from "./settings/DownloadClient";
import { RegexPlayground } from './settings/RegexPlayground';
import ReleaseSettings from "./settings/Releases";
import NotificationSettings from "./settings/Notifications";
import FeedSettings from "./settings/Feed";
const subNavigation = [
{name: 'Application', href: '', icon: CogIcon, current: true},
{name: 'Indexers', href: 'indexers', icon: KeyIcon, current: false},
{name: 'IRC', href: 'irc', icon: KeyIcon, current: false},
{name: 'IRC', href: 'irc', icon: ChatAlt2Icon, current: false},
{name: 'Feeds', href: 'feeds', icon: RssIcon, current: false},
{name: 'Clients', href: 'clients', icon: DownloadIcon, current: false},
{name: 'Notifications', href: 'notifications', icon: BellIcon, current: false},
{name: 'Releases', href: 'releases', icon: CollectionIcon, current: false},
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
// {name: 'Actions', href: 'actions', icon: PlayIcon, current: false},
// {name: 'Rules', href: 'rules', icon: ClipboardCheckIcon, current: false},
]
@ -73,7 +74,7 @@ export default function Settings() {
</header>
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg">
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<SidebarNav url={url} subNavigation={subNavigation}/>
@ -86,6 +87,10 @@ export default function Settings() {
<IndexerSettings/>
</Route>
<Route path={`${url}/feeds`}>
<FeedSettings/>
</Route>
<Route path={`${url}/irc`}>
<IrcSettings/>
</Route>
@ -102,10 +107,6 @@ export default function Settings() {
<ReleaseSettings/>
</Route>
{/*<Route path={`${url}/actions`}>
<ActionSettings/>
</Route>*/}
<Route path={`${url}/regex-playground`}>
<RegexPlayground />
</Route>

View file

@ -0,0 +1,279 @@
import { useToggle } from "../../hooks/hooks";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { APIClient } from "../../api/APIClient";
import { Menu, Switch, Transition } from "@headlessui/react";
import type {FieldProps} from "formik";
import {classNames} from "../../utils";
import {Fragment, useRef, useState} from "react";
import {toast} from "react-hot-toast";
import Toast from "../../components/notifications/Toast";
import {queryClient} from "../../App";
import {DeleteModal} from "../../components/modals";
import {
DotsHorizontalIcon,
PencilAltIcon,
SwitchHorizontalIcon,
TrashIcon
} from "@heroicons/react/outline";
import {FeedUpdateForm} from "../../forms/settings/FeedForms";
import {EmptyBasic} from "../../components/emptystates";
function FeedSettings() {
const {data} = useQuery<Feed[], Error>('feeds', APIClient.feeds.find,
{
refetchOnWindowFocus: false
}
)
return (
<div className="divide-y divide-gray-200 lg:col-span-9">
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap">
<div className="ml-4 mt-4">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Feeds</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Manage torznab feeds.
</p>
</div>
</div>
{data && data.length > 0 ?
<section className="mt-6 light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md">
<ol className="min-w-full relative">
<li className="grid grid-cols-12 gap-4 border-b border-gray-200 dark:border-gray-700">
<div
className="col-span-2 px-6 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 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name
</div>
<div
className="col-span-2 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Type
</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">Events</div>*/}
</li>
{data && data.map((f) => (
<ListItem key={f.id} feed={f}/>
))}
</ol>
</section>
: <EmptyBasic title="No feeds" subtitle="Setup via indexers" />}
</div>
</div>
)
}
const ImplementationTorznab = () => (
<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"
>
Torznab
</span>
)
export const ImplementationMap: any = {
"TORZNAB": <ImplementationTorznab/>,
};
interface ListItemProps {
feed: Feed;
}
function ListItem({feed}: ListItemProps) {
const [updateFormIsOpen, toggleUpdateForm] = useToggle(false)
const [enabled, setEnabled] = useState(feed.enabled)
const updateMutation = useMutation(
(status: boolean) => APIClient.feeds.toggleEnable(feed.id, status),
{
onSuccess: () => {
toast.custom((t) => <Toast type="success"
body={`${feed.name} was ${enabled ? "disabled" : "enabled"} successfully`}
t={t}/>)
queryClient.invalidateQueries(["feeds"]);
queryClient.invalidateQueries(["feeds", feed?.id]);
}
}
);
const toggleActive = (status: boolean) => {
setEnabled(status);
updateMutation.mutate(status);
}
return (
<li key={feed.id} className="text-gray-500 dark:text-gray-400">
<FeedUpdateForm isOpen={updateFormIsOpen} toggle={toggleUpdateForm} feed={feed}/>
<div className="grid grid-cols-12 gap-4 items-center py-4">
<div className="col-span-2 flex items-center sm:px-6 ">
<Switch
checked={feed.enabled}
onChange={toggleActive}
className={classNames(
feed.enabled ? 'bg-teal-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 className="col-span-6 flex items-center sm:px-6 text-sm font-medium text-gray-900 dark:text-white">
{feed.name}
</div>
<div className="col-span-2 flex items-center sm:px-6">
{ImplementationMap[feed.type]}
</div>
<div className="col-span-1 flex items-center sm:px-6">
<FeedItemDropdown
feed={feed}
onToggle={toggleActive}
toggleUpdate={toggleUpdateForm}
/>
</div>
</div>
</li>
)
}
interface FeedItemDropdownProps {
feed: Feed;
onToggle: (newState: boolean) => void;
toggleUpdate: () => void;
}
const FeedItemDropdown = ({
feed,
onToggle,
toggleUpdate,
}: FeedItemDropdownProps) => {
const cancelModalButtonRef = useRef(null);
const queryClient = useQueryClient();
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const deleteMutation = useMutation(
(id: number) => APIClient.feeds.delete(id),
{
onSuccess: () => {
queryClient.invalidateQueries(["feeds"]);
queryClient.invalidateQueries(["feeds", feed.id]);
toast.custom((t) => <Toast type="success" body={`Feed ${feed?.name} was deleted`} t={t}/>);
}
}
);
return (
<Menu as="div">
<DeleteModal
isOpen={deleteModalIsOpen}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={() => {
deleteMutation.mutate(feed.id);
toggleDeleteModal();
}}
title={`Remove feed: ${feed.name}`}
text="Are you sure you want to remove this feed? This action cannot be undone."
/>
<Menu.Button className="px-4 py-2">
<DotsHorizontalIcon
className="w-5 h-5 text-gray-700 hover:text-gray-900 dark:text-gray-100 dark:hover:text-gray-400"
aria-hidden="true"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute right-0 w-56 mt-2 origin-top-right bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700 rounded-md shadow-lg ring-1 ring-black ring-opacity-10 focus:outline-none"
>
<div className="px-1 py-1">
<Menu.Item>
{({active}) => (
<button
className={classNames(
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
)}
onClick={() => toggleUpdate()}
>
<PencilAltIcon
className={classNames(
active ? "text-white" : "text-blue-500",
"w-5 h-5 mr-2"
)}
aria-hidden="true"
/>
Edit
</button>
)}
</Menu.Item>
<Menu.Item>
{({active}) => (
<button
className={classNames(
active ? "bg-blue-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
)}
onClick={() => onToggle(!feed.enabled)}
>
<SwitchHorizontalIcon
className={classNames(
active ? "text-white" : "text-blue-500",
"w-5 h-5 mr-2"
)}
aria-hidden="true"
/>
Toggle
</button>
)}
</Menu.Item>
</div>
<div className="px-1 py-1">
<Menu.Item>
{({active}) => (
<button
className={classNames(
active ? "bg-red-600 text-white" : "text-gray-900 dark:text-gray-300",
"font-medium group flex rounded-md items-center w-full px-2 py-2 text-sm"
)}
onClick={() => toggleDeleteModal()}
>
<TrashIcon
className={classNames(
active ? "text-white" : "text-red-500",
"w-5 h-5 mr-2"
)}
aria-hidden="true"
/>
Delete
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
);
}
export default FeedSettings;

View file

@ -6,6 +6,27 @@ import { classNames } from "../../utils";
import { EmptySimple } from "../../components/emptystates";
import { APIClient } from "../../api/APIClient";
const ImplementationIRC = () => (
<span
className="mr-2 inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-green-200 dark:bg-green-400 text-green-800 dark:text-green-800"
>
IRC
</span>
)
const ImplementationTorznab = () => (
<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"
>
Torznab
</span>
)
const implementationMap: any = {
"irc": <ImplementationIRC/>,
"torznab": <ImplementationTorznab />,
};
const ListItem = ({ indexer }: any) => {
const [updateIsOpen, toggleUpdate] = useToggle(false)
@ -33,6 +54,7 @@ const ListItem = ({ indexer }: any) => {
</Switch>
</td>
<td className="px-6 py-4 w-full whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">{indexer.name}</td>
<td className="px-6 py-4 w-full whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">{implementationMap[indexer.implementation]}</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<span className="text-indigo-600 dark:text-gray-300 hover:text-indigo-900 dark:hover:text-blue-500 cursor-pointer" onClick={toggleUpdate}>
Edit
@ -98,6 +120,12 @@ function IndexerSettings() {
>
Name
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
>
Implementation
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span>
</th>

23
web/src/types/Feed.d.ts vendored Normal file
View file

@ -0,0 +1,23 @@
interface Feed {
id: number;
indexer: string;
name: string;
type: string;
enabled: boolean;
url: string;
interval: number;
api_key: string;
created_at: Date;
updated_at: Date;
}
interface FeedCreate {
indexer: string;
name: string;
type: string;
enabled: boolean;
url: string;
interval: number;
api_key: string;
indexer_id: number;
}

View file

@ -3,7 +3,7 @@ interface Indexer {
name: string;
identifier: string;
enabled: boolean;
type?: string;
implementation: string;
settings: Array<IndexerSetting>;
}
@ -11,6 +11,7 @@ interface IndexerDefinition {
id?: number;
name: string;
identifier: string;
implementation: string;
enabled?: boolean;
description: string;
language: string;
@ -20,6 +21,7 @@ interface IndexerDefinition {
supports: string[];
settings: IndexerSetting[];
irc: IndexerIRC;
torznab: IndexerTorznab;
parse: IndexerParse;
}
@ -46,6 +48,11 @@ interface IndexerIRC {
settings: IndexerSetting[];
}
interface IndexerTorznab {
minInterval: number;
settings: IndexerSetting[];
}
interface IndexerParse {
type: string;
lines: IndexerParseLines[];

View file

@ -73,4 +73,13 @@ export function IsEmptyDate(date: string) {
)
}
return "n/a"
}
}
export function slugify(str: string) {
return str
.normalize('NFKD')
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.trim()
.replace(/[-\s]+/g, '-');
}