summaryrefslogtreecommitdiffhomepage
path: root/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/ErrorBoundary.tsx29
-rw-r--r--frontend/src/components/ItemOverview.tsx204
-rw-r--r--frontend/src/components/LanguageSelector.tsx2
-rw-r--r--frontend/src/components/SearchBar.tsx69
-rw-r--r--frontend/src/components/async.tsx73
-rw-r--r--frontend/src/components/inputs/FileBrowser.tsx57
-rw-r--r--frontend/src/components/inputs/blacklist.tsx44
-rw-r--r--frontend/src/components/modals/HistoryModal.tsx107
-rw-r--r--frontend/src/components/modals/ItemEditorModal.tsx14
-rw-r--r--frontend/src/components/modals/ManualSearchModal.tsx87
-rw-r--r--frontend/src/components/modals/MovieUploadModal.tsx30
-rw-r--r--frontend/src/components/modals/SeriesUploadModal.tsx39
-rw-r--r--frontend/src/components/modals/SubtitleToolModal.tsx26
-rw-r--r--frontend/src/components/modals/SubtitleUploadModal.tsx2
-rw-r--r--frontend/src/components/modals/hooks.tsx2
-rw-r--r--frontend/src/components/tables/AsyncPageTable.tsx128
-rw-r--r--frontend/src/components/tables/PageTable.tsx2
-rw-r--r--frontend/src/components/tables/QueryPageTable.tsx77
-rw-r--r--frontend/src/components/tables/index.tsx2
-rw-r--r--frontend/src/components/tables/plugins/useDefaultSettings.tsx2
-rw-r--r--frontend/src/components/views/HistoryView.tsx36
-rw-r--r--frontend/src/components/views/ItemView.tsx213
-rw-r--r--frontend/src/components/views/WantedView.tsx60
23 files changed, 897 insertions, 408 deletions
diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx
new file mode 100644
index 000000000..e419d6da5
--- /dev/null
+++ b/frontend/src/components/ErrorBoundary.tsx
@@ -0,0 +1,29 @@
+import UIError from "pages/UIError";
+import React from "react";
+
+interface State {
+ error: Error | null;
+}
+
+class ErrorBoundary extends React.Component<{}, State> {
+ constructor(props: {}) {
+ super(props);
+ this.state = { error: null };
+ }
+
+ componentDidCatch(error: Error) {
+ this.setState({ error });
+ }
+
+ render() {
+ const { children } = this.props;
+ const { error } = this.state;
+ if (error) {
+ return <UIError error={error}></UIError>;
+ }
+
+ return children;
+ }
+}
+
+export default ErrorBoundary;
diff --git a/frontend/src/components/ItemOverview.tsx b/frontend/src/components/ItemOverview.tsx
new file mode 100644
index 000000000..7915a2ed4
--- /dev/null
+++ b/frontend/src/components/ItemOverview.tsx
@@ -0,0 +1,204 @@
+import {
+ faBookmark as farBookmark,
+ faClone as fasClone,
+ faFolder,
+} from "@fortawesome/free-regular-svg-icons";
+import {
+ faBookmark,
+ faLanguage,
+ faMusic,
+ faStream,
+ faTags,
+ IconDefinition,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import React, { FunctionComponent, useMemo } from "react";
+import {
+ Badge,
+ Col,
+ Container,
+ Image,
+ OverlayTrigger,
+ Popover,
+ Row,
+} from "react-bootstrap";
+import { BuildKey, isMovie } from "utilities";
+import {
+ useLanguageProfileBy,
+ useProfileItemsToLanguages,
+} from "utilities/languages";
+import { LanguageText } from ".";
+
+interface Props {
+ item: Item.Base;
+ details?: { icon: IconDefinition; text: string }[];
+}
+
+const ItemOverview: FunctionComponent<Props> = (props) => {
+ const { item, details } = props;
+
+ const detailBadges = useMemo(() => {
+ const badges: (JSX.Element | null)[] = [];
+ badges.push(
+ <DetailBadge key="file-path" icon={faFolder} desc="File Path">
+ {item.path}
+ </DetailBadge>
+ );
+
+ badges.push(
+ ...(details?.map((val, idx) => (
+ <DetailBadge key={BuildKey(idx, "detail", val.text)} icon={val.icon}>
+ {val.text}
+ </DetailBadge>
+ )) ?? [])
+ );
+
+ if (item.tags.length > 0) {
+ badges.push(
+ <DetailBadge key="tags" icon={faTags} desc="Tags">
+ {item.tags.join("|")}
+ </DetailBadge>
+ );
+ }
+
+ return badges;
+ }, [details, item.path, item.tags]);
+
+ const audioBadges = useMemo(
+ () =>
+ item.audio_language.map((v, idx) => (
+ <DetailBadge
+ key={BuildKey(idx, "audio", v.code2)}
+ icon={faMusic}
+ desc="Audio Language"
+ >
+ {v.name}
+ </DetailBadge>
+ )),
+ [item.audio_language]
+ );
+
+ const profile = useLanguageProfileBy(item.profileId);
+ const profileItems = useProfileItemsToLanguages(profile);
+
+ const languageBadges = useMemo(() => {
+ const badges: (JSX.Element | null)[] = [];
+
+ if (profile) {
+ badges.push(
+ <DetailBadge
+ key="language-profile"
+ icon={faStream}
+ desc="Languages Profile"
+ >
+ {profile.name}
+ </DetailBadge>
+ );
+
+ badges.push(
+ ...profileItems.map((v, idx) => (
+ <DetailBadge
+ key={BuildKey(idx, "lang", v.code2)}
+ icon={faLanguage}
+ desc="Language"
+ >
+ <LanguageText long text={v}></LanguageText>
+ </DetailBadge>
+ ))
+ );
+ }
+
+ return badges;
+ }, [profile, profileItems]);
+
+ const alternativePopover = useMemo(
+ () => (
+ <Popover id="item-overview-alternative">
+ <Popover.Title>Alternate Titles</Popover.Title>
+ <Popover.Content>
+ {item.alternativeTitles.map((v, idx) => (
+ <li key={idx}>{v}</li>
+ ))}
+ </Popover.Content>
+ </Popover>
+ ),
+ [item.alternativeTitles]
+ );
+
+ return (
+ <Container
+ fluid
+ style={{
+ backgroundRepeat: "no-repeat",
+ backgroundSize: "cover",
+ backgroundPosition: "top center",
+ backgroundImage: `url('${item.fanart}')`,
+ }}
+ >
+ <Row
+ className="p-4 pb-4"
+ style={{
+ backgroundColor: "rgba(0,0,0,0.7)",
+ }}
+ >
+ <Col sm="auto">
+ <Image
+ className="d-none d-sm-block my-2"
+ style={{
+ maxHeight: 250,
+ }}
+ src={item.poster}
+ ></Image>
+ </Col>
+ <Col>
+ <Container fluid className="text-white">
+ <Row>
+ {isMovie(item) ? (
+ <FontAwesomeIcon
+ className="mx-2 mt-2"
+ title={item.monitored ? "monitored" : "unmonitored"}
+ icon={item.monitored ? faBookmark : farBookmark}
+ size="2x"
+ ></FontAwesomeIcon>
+ ) : null}
+ <h1>{item.title}</h1>
+ <span hidden={item.alternativeTitles.length === 0}>
+ <OverlayTrigger overlay={alternativePopover}>
+ <FontAwesomeIcon
+ className="mx-2"
+ icon={fasClone}
+ ></FontAwesomeIcon>
+ </OverlayTrigger>
+ </span>
+ </Row>
+ <Row>{detailBadges}</Row>
+ <Row>{audioBadges}</Row>
+ <Row>{languageBadges}</Row>
+ <Row>
+ <span>{item.overview}</span>
+ </Row>
+ </Container>
+ </Col>
+ </Row>
+ </Container>
+ );
+};
+
+interface ItemBadgeProps {
+ icon: IconDefinition;
+ children: string | JSX.Element;
+ desc?: string;
+}
+
+const DetailBadge: FunctionComponent<ItemBadgeProps> = ({
+ icon,
+ desc,
+ children,
+}) => (
+ <Badge title={desc} variant="secondary" className="mr-2 my-1 text-truncate">
+ <FontAwesomeIcon icon={icon}></FontAwesomeIcon>
+ <span className="ml-1">{children}</span>
+ </Badge>
+);
+
+export default ItemOverview;
diff --git a/frontend/src/components/LanguageSelector.tsx b/frontend/src/components/LanguageSelector.tsx
index f2466e1bc..1d6271a64 100644
--- a/frontend/src/components/LanguageSelector.tsx
+++ b/frontend/src/components/LanguageSelector.tsx
@@ -1,5 +1,5 @@
+import { Selector, SelectorProps } from "components";
import React, { useMemo } from "react";
-import { Selector, SelectorProps } from "../components";
interface Props {
options: readonly Language.Info[];
diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx
index 86ad517a8..f8de27ec4 100644
--- a/frontend/src/components/SearchBar.tsx
+++ b/frontend/src/components/SearchBar.tsx
@@ -1,3 +1,5 @@
+import { useServerSearch } from "apis/hooks";
+import { uniqueId } from "lodash";
import React, {
FunctionComponent,
useCallback,
@@ -9,6 +11,34 @@ import { Dropdown, Form } from "react-bootstrap";
import { useHistory } from "react-router";
import { useThrottle } from "rooks";
+function useSearch(query: string) {
+ const { data } = useServerSearch(query, query.length > 0);
+
+ return useMemo(
+ () =>
+ data?.map((v) => {
+ let link: string;
+ let id: string;
+ if (v.sonarrSeriesId) {
+ link = `/series/${v.sonarrSeriesId}`;
+ id = `series-${v.sonarrSeriesId}`;
+ } else if (v.radarrId) {
+ link = `/movies/${v.radarrId}`;
+ id = `movie-${v.radarrId}`;
+ } else {
+ link = "";
+ id = uniqueId("unknown");
+ }
+
+ return {
+ name: `${v.title} (${v.year})`,
+ link,
+ id,
+ };
+ }) ?? [],
+ [data]
+ );
+}
export interface SearchResult {
id: string;
name: string;
@@ -17,43 +47,30 @@ export interface SearchResult {
interface Props {
className?: string;
- onSearch: (text: string) => Promise<SearchResult[]>;
onFocus?: () => void;
onBlur?: () => void;
}
export const SearchBar: FunctionComponent<Props> = ({
- onSearch,
onFocus,
onBlur,
className,
}) => {
- const [text, setText] = useState("");
-
- const [results, setResults] = useState<SearchResult[]>([]);
+ const [display, setDisplay] = useState("");
+ const [query, setQuery] = useState("");
- const history = useHistory();
-
- const search = useCallback(
- (value: string) => {
- if (value === "") {
- setResults([]);
- } else {
- onSearch(value).then((res) => setResults(res));
- }
- },
- [onSearch]
- );
+ const [debounce] = useThrottle(setQuery, 500);
+ useEffect(() => {
+ debounce(display);
+ }, [debounce, display]);
- const [debounceSearch] = useThrottle(search, 500);
+ const results = useSearch(query);
- useEffect(() => {
- debounceSearch(text);
- }, [text, debounceSearch]);
+ const history = useHistory();
const clear = useCallback(() => {
- setText("");
- setResults([]);
+ setDisplay("");
+ setQuery("");
}, []);
const items = useMemo(() => {
@@ -76,7 +93,7 @@ export const SearchBar: FunctionComponent<Props> = ({
return (
<Dropdown
- show={text.length !== 0}
+ show={query.length !== 0}
className={className}
onFocus={onFocus}
onBlur={onBlur}
@@ -91,8 +108,8 @@ export const SearchBar: FunctionComponent<Props> = ({
type="text"
size="sm"
placeholder="Search..."
- value={text}
- onChange={(e) => setText(e.currentTarget.value)}
+ value={display}
+ onChange={(e) => setDisplay(e.currentTarget.value)}
></Form.Control>
<Dropdown.Menu style={{ maxHeight: 256, overflowY: "auto" }}>
{items}
diff --git a/frontend/src/components/async.tsx b/frontend/src/components/async.tsx
index 12b87fcf0..105cd567e 100644
--- a/frontend/src/components/async.tsx
+++ b/frontend/src/components/async.tsx
@@ -4,38 +4,35 @@ import {
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { isEmpty } from "lodash";
import React, {
FunctionComponent,
PropsWithChildren,
useCallback,
useEffect,
- useMemo,
useState,
} from "react";
import { Button, ButtonProps } from "react-bootstrap";
+import { UseQueryResult } from "react-query";
import { useTimeoutWhen } from "rooks";
import { LoadingIndicator } from ".";
-import { Selector, SelectorProps } from "./inputs";
-interface Props<T extends Async.Base<any>> {
- ctx: T;
- children: FunctionComponent<T>;
+interface QueryOverlayProps {
+ result: UseQueryResult<unknown, unknown>;
+ children: React.ReactElement;
}
-export function AsyncOverlay<T extends Async.Base<any>>(props: Props<T>) {
- const { ctx, children } = props;
- if (
- ctx.state === "uninitialized" ||
- (ctx.state === "loading" && isEmpty(ctx.content))
- ) {
+export const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
+ children,
+ result: { isLoading, isError, error },
+}) => {
+ if (isLoading) {
return <LoadingIndicator></LoadingIndicator>;
- } else if (ctx.state === "failed") {
- return <p>{ctx.error}</p>;
- } else {
- return children(ctx);
+ } else if (isError) {
+ return <p>{error as string}</p>;
}
-}
+
+ return children;
+};
interface PromiseProps<T> {
promise: () => Promise<T>;
@@ -58,48 +55,6 @@ export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) {
}
}
-type AsyncSelectorProps<V, T extends Async.Item<V[]>> = {
- state: T;
- update: () => void;
- label: (item: V) => string;
-};
-
-type RemovedSelectorProps<T, M extends boolean> = Omit<
- SelectorProps<T, M>,
- "loading" | "options" | "onFocus"
->;
-
-export function AsyncSelector<
- V,
- T extends Async.Item<V[]>,
- M extends boolean = false
->(props: Override<AsyncSelectorProps<V, T>, RemovedSelectorProps<V, M>>) {
- const { label, state, update, ...selector } = props;
-
- const options = useMemo<SelectorOption<V>[]>(
- () =>
- state.content?.map((v) => ({
- label: label(v),
- value: v,
- })) ?? [],
- [state, label]
- );
-
- return (
- <Selector
- loading={state.state === "loading"}
- options={options}
- label={label}
- onFocus={() => {
- if (state.state === "uninitialized") {
- update();
- }
- }}
- {...selector}
- ></Selector>
- );
-}
-
interface AsyncButtonProps<T> {
as?: ButtonProps["as"];
variant?: ButtonProps["variant"];
diff --git a/frontend/src/components/inputs/FileBrowser.tsx b/frontend/src/components/inputs/FileBrowser.tsx
index 8b1b15927..4dfe8c80e 100644
--- a/frontend/src/components/inputs/FileBrowser.tsx
+++ b/frontend/src/components/inputs/FileBrowser.tsx
@@ -1,6 +1,7 @@
import { faFile, faFolder } from "@fortawesome/free-regular-svg-icons";
import { faReply } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useFileSystem } from "apis/hooks";
import React, {
FunctionComponent,
useEffect,
@@ -31,21 +32,22 @@ function extractPath(raw: string) {
interface Props {
defaultValue?: string;
- load: (path: string) => Promise<FileTree[]>;
+ type: "sonarr" | "radarr" | "bazarr";
onChange?: (path: string) => void;
drop?: DropdownProps["drop"];
}
export const FileBrowser: FunctionComponent<Props> = ({
defaultValue,
+ type,
onChange,
- load,
drop,
}) => {
const [show, canShow] = useState(false);
const [text, setText] = useState(defaultValue ?? "");
const [path, setPath] = useState(() => extractPath(text));
- const [loading, setLoading] = useState(true);
+
+ const { data: tree, isFetching } = useFileSystem(type, path, show);
const filter = useMemo(() => {
const idx = getLastSeparator(text);
@@ -57,10 +59,8 @@ export const FileBrowser: FunctionComponent<Props> = ({
return path.slice(0, idx + 1);
}, [path]);
- const [tree, setTree] = useState<FileTree[]>([]);
-
- const requestItems = useMemo(() => {
- if (loading) {
+ const requestItems = () => {
+ if (isFetching) {
return (
<Dropdown.Item>
<Spinner size="sm" animation="border"></Spinner>
@@ -70,19 +70,21 @@ export const FileBrowser: FunctionComponent<Props> = ({
const elements = [];
- elements.push(
- ...tree
- .filter((v) => v.name.startsWith(filter))
- .map((v) => (
- <Dropdown.Item eventKey={v.path} key={v.name}>
- <FontAwesomeIcon
- icon={v.children ? faFolder : faFile}
- className="mr-2"
- ></FontAwesomeIcon>
- <span>{v.name}</span>
- </Dropdown.Item>
- ))
- );
+ if (tree) {
+ elements.push(
+ ...tree
+ .filter((v) => v.name.startsWith(filter))
+ .map((v) => (
+ <Dropdown.Item eventKey={v.path} key={v.name}>
+ <FontAwesomeIcon
+ icon={v.children ? faFolder : faFile}
+ className="mr-2"
+ ></FontAwesomeIcon>
+ <span>{v.name}</span>
+ </Dropdown.Item>
+ ))
+ );
+ }
if (elements.length === 0) {
elements.push(<Dropdown.Header key="no-files">No Files</Dropdown.Header>);
@@ -100,7 +102,7 @@ export const FileBrowser: FunctionComponent<Props> = ({
} else {
return elements;
}
- }, [tree, filter, previous, loading]);
+ };
useEffect(() => {
if (text === path) {
@@ -116,17 +118,6 @@ export const FileBrowser: FunctionComponent<Props> = ({
const input = useRef<HTMLInputElement>(null);
- useEffect(() => {
- if (show) {
- setLoading(true);
- load(path)
- .then((res) => {
- setTree(res);
- })
- .finally(() => setLoading(false));
- }
- }, [path, load, show]);
-
return (
<Dropdown
show={show}
@@ -165,7 +156,7 @@ export const FileBrowser: FunctionComponent<Props> = ({
className="w-100"
style={{ maxHeight: 256, overflowY: "auto" }}
>
- {requestItems}
+ {requestItems()}
</Dropdown.Menu>
</Dropdown>
);
diff --git a/frontend/src/components/inputs/blacklist.tsx b/frontend/src/components/inputs/blacklist.tsx
new file mode 100644
index 000000000..fe079a925
--- /dev/null
+++ b/frontend/src/components/inputs/blacklist.tsx
@@ -0,0 +1,44 @@
+import { faFileExcel } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import React, { FunctionComponent } from "react";
+import { AsyncButton } from "..";
+
+interface Props {
+ history: History.Base;
+ update?: () => void;
+ promise: (form: FormType.AddBlacklist) => Promise<void>;
+}
+
+export const BlacklistButton: FunctionComponent<Props> = ({
+ history,
+ update,
+ promise,
+}) => {
+ const { provider, subs_id, language, subtitles_path, blacklisted } = history;
+
+ if (subs_id && provider && language) {
+ return (
+ <AsyncButton
+ size="sm"
+ variant="light"
+ noReset
+ disabled={blacklisted}
+ promise={() => {
+ const { code2 } = language;
+ const form: FormType.AddBlacklist = {
+ provider,
+ subs_id,
+ subtitles_path,
+ language: code2,
+ };
+ return promise(form);
+ }}
+ onSuccess={update}
+ >
+ <FontAwesomeIcon icon={faFileExcel}></FontAwesomeIcon>
+ </AsyncButton>
+ );
+ } else {
+ return null;
+ }
+};
diff --git a/frontend/src/components/modals/HistoryModal.tsx b/frontend/src/components/modals/HistoryModal.tsx
index 6a95547f3..7fe8a40f6 100644
--- a/frontend/src/components/modals/HistoryModal.tsx
+++ b/frontend/src/components/modals/HistoryModal.tsx
@@ -1,10 +1,19 @@
-import React, { FunctionComponent, useCallback, useMemo } from "react";
+import {
+ useEpisodeAddBlacklist,
+ useEpisodeHistory,
+ useMovieAddBlacklist,
+ useMovieHistory,
+} from "apis/hooks";
+import React, { FunctionComponent, useMemo } from "react";
import { Column } from "react-table";
-import { useDidUpdate } from "rooks";
-import { HistoryIcon, LanguageText, PageTable, TextPopover } from "..";
-import { EpisodesApi, MoviesApi, useAsyncRequest } from "../../apis";
-import { BlacklistButton } from "../../DisplayItem/generic/blacklist";
-import { AsyncOverlay } from "../async";
+import {
+ HistoryIcon,
+ LanguageText,
+ PageTable,
+ QueryOverlay,
+ TextPopover,
+} from "..";
+import { BlacklistButton } from "../inputs/blacklist";
import BaseModal, { BaseModalProps } from "./BaseModal";
import { useModalPayload } from "./hooks";
@@ -13,19 +22,9 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
const movie = useModalPayload<Item.Movie>(modal.modalKey);
- const [history, updateHistory] = useAsyncRequest(
- MoviesApi.historyBy.bind(MoviesApi)
- );
-
- const update = useCallback(() => {
- if (movie) {
- updateHistory(movie.radarrId);
- }
- }, [movie, updateHistory]);
+ const history = useMovieHistory(movie?.radarrId);
- useDidUpdate(() => {
- update();
- }, [movie?.radarrId]);
+ const { data } = history;
const columns = useMemo<Column<History.Movie>[]>(
() => [
@@ -74,33 +73,30 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
// Actions
accessor: "blacklisted",
Cell: ({ row }) => {
- const original = row.original;
+ const { radarrId } = row.original;
+ const { mutateAsync } = useMovieAddBlacklist();
return (
<BlacklistButton
- update={update}
- promise={(form) =>
- MoviesApi.addBlacklist(original.radarrId, form)
- }
- history={original}
+ update={history.refetch}
+ promise={(form) => mutateAsync({ id: radarrId, form })}
+ history={row.original}
></BlacklistButton>
);
},
},
],
- [update]
+ [history.refetch]
);
return (
<BaseModal title={`History - ${movie?.title ?? ""}`} {...modal}>
- <AsyncOverlay ctx={history}>
- {({ content }) => (
- <PageTable
- emptyText="No History Found"
- columns={columns}
- data={content?.data ?? []}
- ></PageTable>
- )}
- </AsyncOverlay>
+ <QueryOverlay result={history}>
+ <PageTable
+ emptyText="No History Found"
+ columns={columns}
+ data={data ?? []}
+ ></PageTable>
+ </QueryOverlay>
</BaseModal>
);
};
@@ -112,19 +108,9 @@ export const EpisodeHistoryModal: FunctionComponent<
> = (props) => {
const episode = useModalPayload<Item.Episode>(props.modalKey);
- const [history, updateHistory] = useAsyncRequest(
- EpisodesApi.historyBy.bind(EpisodesApi)
- );
-
- const update = useCallback(() => {
- if (episode) {
- updateHistory(episode.sonarrEpisodeId);
- }
- }, [episode, updateHistory]);
+ const history = useEpisodeHistory(episode?.sonarrEpisodeId);
- useDidUpdate(() => {
- update();
- }, [episode?.sonarrEpisodeId]);
+ const { data } = history;
const columns = useMemo<Column<History.Episode>[]>(
() => [
@@ -174,33 +160,36 @@ export const EpisodeHistoryModal: FunctionComponent<
accessor: "blacklisted",
Cell: ({ row }) => {
const original = row.original;
- const { sonarrSeriesId, sonarrEpisodeId } = original;
+
+ const { sonarrEpisodeId, sonarrSeriesId } = original;
+ const { mutateAsync } = useEpisodeAddBlacklist();
return (
<BlacklistButton
history={original}
- update={update}
promise={(form) =>
- EpisodesApi.addBlacklist(sonarrSeriesId, sonarrEpisodeId, form)
+ mutateAsync({
+ seriesId: sonarrSeriesId,
+ episodeId: sonarrEpisodeId,
+ form,
+ })
}
></BlacklistButton>
);
},
},
],
- [update]
+ []
);
return (
<BaseModal title={`History - ${episode?.title ?? ""}`} {...props}>
- <AsyncOverlay ctx={history}>
- {({ content }) => (
- <PageTable
- emptyText="No History Found"
- columns={columns}
- data={content?.data ?? []}
- ></PageTable>
- )}
- </AsyncOverlay>
+ <QueryOverlay result={history}>
+ <PageTable
+ emptyText="No History Found"
+ columns={columns}
+ data={data ?? []}
+ ></PageTable>
+ </QueryOverlay>
</BaseModal>
);
};
diff --git a/frontend/src/components/modals/ItemEditorModal.tsx b/frontend/src/components/modals/ItemEditorModal.tsx
index cc8a93468..d123250f3 100644
--- a/frontend/src/components/modals/ItemEditorModal.tsx
+++ b/frontend/src/components/modals/ItemEditorModal.tsx
@@ -1,9 +1,8 @@
+import { useIsAnyActionRunning, useLanguageProfiles } from "apis/hooks";
import React, { FunctionComponent, useMemo, useState } from "react";
import { Container, Form } from "react-bootstrap";
+import { GetItemId } from "utilities";
import { AsyncButton, Selector } from "../";
-import { useIsAnyTaskRunningWithId } from "../../@modules/task/hooks";
-import { useLanguageProfiles } from "../../@redux/hooks";
-import { GetItemId } from "../../utilities";
import BaseModal, { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
@@ -15,14 +14,13 @@ interface Props {
const Editor: FunctionComponent<Props & BaseModalProps> = (props) => {
const { onSuccess, submit, ...modal } = props;
- const profiles = useLanguageProfiles();
+ const { data: profiles } = useLanguageProfiles();
const { payload, closeModal } = useModalInformation<Item.Base>(
modal.modalKey
);
- // TODO: Separate movies and series
- const hasTask = useIsAnyTaskRunningWithId([GetItemId(payload ?? {})]);
+ const hasTask = useIsAnyActionRunning();
const profileOptions = useMemo<SelectorOption<number>[]>(
() =>
@@ -43,6 +41,10 @@ const Editor: FunctionComponent<Props & BaseModalProps> = (props) => {
promise={() => {
if (payload) {
const itemId = GetItemId(payload);
+ if (!itemId) {
+ return null;
+ }
+
return submit({
id: [itemId],
profileid: [id],
diff --git a/frontend/src/components/modals/ManualSearchModal.tsx b/frontend/src/components/modals/ManualSearchModal.tsx
index 2fb50bf99..853d93a49 100644
--- a/frontend/src/components/modals/ManualSearchModal.tsx
+++ b/frontend/src/components/modals/ManualSearchModal.tsx
@@ -6,10 +6,12 @@ import {
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { dispatchTask } from "@modules/task";
+import { createTask } from "@modules/task/utilities";
+import { useEpisodesProvider, useMoviesProvider } from "apis/hooks";
import React, {
FunctionComponent,
useCallback,
- useEffect,
useMemo,
useState,
} from "react";
@@ -24,6 +26,7 @@ import {
Row,
} from "react-bootstrap";
import { Column } from "react-table";
+import { GetItemId, isMovie } from "utilities";
import {
BaseModal,
BaseModalProps,
@@ -32,20 +35,10 @@ import {
PageTable,
useModalPayload,
} from "..";
-import { dispatchTask } from "../../@modules/task";
-import { createTask } from "../../@modules/task/utilities";
-import { ProvidersApi } from "../../apis";
-import { GetItemId, isMovie } from "../../utilities";
import "./msmStyle.scss";
type SupportType = Item.Movie | Item.Episode;
-enum SearchState {
- Ready,
- Searching,
- Finished,
-}
-
interface Props<T extends SupportType> {
download: (item: T, result: SearchResultType) => Promise<void>;
}
@@ -55,30 +48,35 @@ export function ManualSearchModal<T extends SupportType>(
) {
const { download, ...modal } = props;
- const [result, setResult] = useState<SearchResultType[]>([]);
- const [searchState, setSearchState] = useState(SearchState.Ready);
-
const item = useModalPayload<T>(modal.modalKey);
- const search = useCallback(async () => {
+ const [episodeId, setEpisodeId] = useState<number | undefined>(undefined);
+ const [radarrId, setRadarrId] = useState<number | undefined>(undefined);
+
+ const episodes = useEpisodesProvider(episodeId);
+ const movies = useMoviesProvider(radarrId);
+
+ const isInitial = episodeId === undefined && radarrId === undefined;
+ const isFetching = episodes.isFetching || movies.isFetching;
+
+ const results = useMemo(
+ () => [...(episodes.data ?? []), ...(movies.data ?? [])],
+ [episodes.data, movies.data]
+ );
+
+ const search = useCallback(() => {
+ setEpisodeId(undefined);
+ setRadarrId(undefined);
if (item) {
- setSearchState(SearchState.Searching);
- let results: SearchResultType[] = [];
if (isMovie(item)) {
- results = await ProvidersApi.movies(item.radarrId);
+ setRadarrId(item.radarrId);
+ movies.refetch();
} else {
- results = await ProvidersApi.episodes(item.sonarrEpisodeId);
+ setEpisodeId(item.sonarrEpisodeId);
+ episodes.refetch();
}
- setResult(results);
- setSearchState(SearchState.Finished);
}
- }, [item]);
-
- useEffect(() => {
- if (item !== null) {
- setSearchState(SearchState.Ready);
- }
- }, [item]);
+ }, [episodes, item, movies]);
const columns = useMemo<Column<SearchResultType>[]>(
() => [
@@ -214,8 +212,8 @@ export function ManualSearchModal<T extends SupportType>(
[download, item]
);
- const content = useMemo<JSX.Element>(() => {
- if (searchState === SearchState.Ready) {
+ const content = () => {
+ if (isInitial) {
return (
<div className="px-4 py-5">
<p className="mb-3 small">{item?.path ?? ""}</p>
@@ -224,7 +222,7 @@ export function ManualSearchModal<T extends SupportType>(
</Button>
</div>
);
- } else if (searchState === SearchState.Searching) {
+ } else if (isFetching) {
return <LoadingIndicator animation="grow"></LoadingIndicator>;
} else {
return (
@@ -233,24 +231,21 @@ export function ManualSearchModal<T extends SupportType>(
<PageTable
emptyText="No Result"
columns={columns}
- data={result}
+ data={results}
></PageTable>
</React.Fragment>
);
}
- }, [searchState, columns, result, search, item?.path]);
+ };
- const footer = useMemo(
- () => (
- <Button
- variant="light"
- hidden={searchState !== SearchState.Finished}
- onClick={search}
- >
- Search Again
- </Button>
- ),
- [searchState, search]
+ const footer = (
+ <Button
+ variant="light"
+ hidden={isFetching === true || isInitial === true}
+ onClick={search}
+ >
+ Search Again
+ </Button>
);
const title = useMemo(() => {
@@ -270,13 +265,13 @@ export function ManualSearchModal<T extends SupportType>(
return (
<BaseModal
- closeable={searchState !== SearchState.Searching}
+ closeable={isFetching === false}
size="xl"
title={title}
footer={footer}
{...modal}
>
- {content}
+ {content()}
</BaseModal>
);
}
diff --git a/frontend/src/components/modals/MovieUploadModal.tsx b/frontend/src/components/modals/MovieUploadModal.tsx
index f96e77089..a5e2705b1 100644
--- a/frontend/src/components/modals/MovieUploadModal.tsx
+++ b/frontend/src/components/modals/MovieUploadModal.tsx
@@ -1,8 +1,11 @@
+import { dispatchTask } from "@modules/task";
+import { createTask } from "@modules/task/utilities";
+import { useMovieSubtitleModification } from "apis/hooks";
import React, { FunctionComponent, useCallback } from "react";
-import { dispatchTask } from "../../@modules/task";
-import { createTask } from "../../@modules/task/utilities";
-import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks";
-import { MoviesApi } from "../../apis";
+import {
+ useLanguageProfileBy,
+ useProfileItemsToLanguages,
+} from "utilities/languages";
import { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
import SubtitleUploadModal, {
@@ -19,7 +22,7 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
const { payload } = useModalInformation<Item.Movie>(modal.modalKey);
- const profile = useProfileBy(payload?.profileId);
+ const profile = useLanguageProfileBy(payload?.profileId);
const availableLanguages = useProfileItemsToLanguages(profile);
@@ -27,6 +30,10 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
return list;
}, []);
+ const {
+ upload: { mutateAsync },
+ } = useMovieSubtitleModification();
+
const validate = useCallback<Validator<Payload>>(
(item) => {
if (item.language === null) {
@@ -64,23 +71,20 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => {
.map((v) => {
const { file, language, forced, hi } = v;
- return createTask(
- file.name,
- radarrId,
- MoviesApi.uploadSubtitles.bind(MoviesApi),
+ return createTask(file.name, radarrId, mutateAsync, {
radarrId,
- {
+ form: {
file,
forced,
hi,
language: language!.code2,
- }
- );
+ },
+ });
});
dispatchTask(TaskGroupName, tasks, "Uploading...");
},
- [payload]
+ [mutateAsync, payload]
);
return (
diff --git a/frontend/src/components/modals/SeriesUploadModal.tsx b/frontend/src/components/modals/SeriesUploadModal.tsx
index 6f6245905..d7c6d359c 100644
--- a/frontend/src/components/modals/SeriesUploadModal.tsx
+++ b/frontend/src/components/modals/SeriesUploadModal.tsx
@@ -1,9 +1,13 @@
+import { dispatchTask } from "@modules/task";
+import { createTask } from "@modules/task/utilities";
+import { useEpisodeSubtitleModification } from "apis/hooks";
+import api from "apis/raw";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Column } from "react-table";
-import { dispatchTask } from "../../@modules/task";
-import { createTask } from "../../@modules/task/utilities";
-import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks";
-import { EpisodesApi, SubtitlesApi } from "../../apis";
+import {
+ useLanguageProfileBy,
+ useProfileItemsToLanguages,
+} from "utilities/languages";
import { Selector } from "../inputs";
import { BaseModalProps } from "./BaseModal";
import { useModalInformation } from "./hooks";
@@ -28,17 +32,21 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
}) => {
const { payload } = useModalInformation<Item.Series>(modal.modalKey);
- const profile = useProfileBy(payload?.profileId);
+ const profile = useLanguageProfileBy(payload?.profileId);
const availableLanguages = useProfileItemsToLanguages(profile);
+ const {
+ upload: { mutateAsync },
+ } = useEpisodeSubtitleModification();
+
const update = useCallback(
async (list: PendingSubtitle<Payload>[]) => {
const newList = [...list];
const names = list.map((v) => v.file.name);
if (names.length > 0) {
- const results = await SubtitlesApi.info(names);
+ const results = await api.subtitles.info(names);
// TODO: Optimization
newList.forEach((v) => {
@@ -85,14 +93,14 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
return;
}
- const { sonarrSeriesId: seriesid } = payload;
+ const { sonarrSeriesId: seriesId } = payload;
const tasks = items
.filter((v) => v.payload.instance !== undefined)
.map((v) => {
const { hi, forced, payload, language } = v;
const { code2 } = language!;
- const { sonarrEpisodeId: episodeid } = payload.instance!;
+ const { sonarrEpisodeId: episodeId } = payload.instance!;
const form: FormType.UploadSubtitle = {
file: v.file,
@@ -101,19 +109,16 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
forced: forced,
};
- return createTask(
- v.file.name,
- episodeid,
- EpisodesApi.uploadSubtitles.bind(EpisodesApi),
- seriesid,
- episodeid,
- form
- );
+ return createTask(v.file.name, episodeId, mutateAsync, {
+ seriesId,
+ episodeId,
+ form,
+ });
});
dispatchTask(TaskGroupName, tasks, "Uploading subtitles...");
},
- [payload]
+ [mutateAsync, payload]
);
const columns = useMemo<Column<PendingSubtitle<Payload>>[]>(
diff --git a/frontend/src/components/modals/SubtitleToolModal.tsx b/frontend/src/components/modals/SubtitleToolModal.tsx
index f22eb9f38..f8891ecff 100644
--- a/frontend/src/components/modals/SubtitleToolModal.tsx
+++ b/frontend/src/components/modals/SubtitleToolModal.tsx
@@ -14,6 +14,9 @@ import {
faTextHeight,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { dispatchTask } from "@modules/task";
+import { createTask } from "@modules/task/utilities";
+import { useSubtitleAction } from "apis/hooks";
import React, {
FunctionComponent,
useCallback,
@@ -29,6 +32,9 @@ import {
InputGroup,
} from "react-bootstrap";
import { Column, useRowSelect } from "react-table";
+import { isMovie, submodProcessColor } from "utilities";
+import { useEnabledLanguages } from "utilities/languages";
+import { log } from "utilities/logger";
import {
ActionButton,
ActionButtonItem,
@@ -39,12 +45,6 @@ import {
useModalPayload,
useShowModal,
} from "..";
-import { dispatchTask } from "../../@modules/task";
-import { createTask } from "../../@modules/task/utilities";
-import { useEnabledLanguages } from "../../@redux/hooks";
-import { SubtitlesApi } from "../../apis";
-import { isMovie, submodProcessColor } from "../../utilities";
-import { log } from "../../utilities/logger";
import { useCustomSelection } from "../tables/plugins";
import BaseModal, { BaseModalProps } from "./BaseModal";
import { useCloseModal } from "./hooks";
@@ -255,7 +255,7 @@ const TranslateModal: FunctionComponent<BaseModalProps & ToolModalProps> = ({
process,
...modal
}) => {
- const languages = useEnabledLanguages();
+ const { data: languages } = useEnabledLanguages();
const available = useMemo(
() => languages.filter((v) => v.code2 in availableTranslation),
@@ -305,6 +305,8 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
const closeModal = useCloseModal();
+ const { mutateAsync } = useSubtitleAction();
+
const process = useCallback(
(action: string, override?: Partial<FormType.ModifySubtitle>) => {
log("info", "executing action", action);
@@ -318,18 +320,12 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => {
path: s.path,
...override,
};
- return createTask(
- s.path,
- s.id,
- SubtitlesApi.modify.bind(SubtitlesApi),
- action,
- form
- );
+ return createTask(s.path, s.id, mutateAsync, { action, form });
});
dispatchTask(TaskGroupName, tasks, "Modifying subtitles...");
},
- [closeModal, selections, props.modalKey]
+ [closeModal, props.modalKey, selections, mutateAsync]
);
const showModal = useShowModal();
diff --git a/frontend/src/components/modals/SubtitleUploadModal.tsx b/frontend/src/components/modals/SubtitleUploadModal.tsx
index eba982ed5..b5cb11b9d 100644
--- a/frontend/src/components/modals/SubtitleUploadModal.tsx
+++ b/frontend/src/components/modals/SubtitleUploadModal.tsx
@@ -9,8 +9,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Container, Form } from "react-bootstrap";
import { Column, TableUpdater } from "react-table";
+import { BuildKey } from "utilities";
import { LanguageSelector, MessageIcon } from "..";
-import { BuildKey } from "../../utilities";
import { FileForm } from "../inputs";
import { SimpleTable } from "../tables";
import BaseModal, { BaseModalProps } from "./BaseModal";
diff --git a/frontend/src/components/modals/hooks.tsx b/frontend/src/components/modals/hooks.tsx
index 485261376..2b9b4c136 100644
--- a/frontend/src/components/modals/hooks.tsx
+++ b/frontend/src/components/modals/hooks.tsx
@@ -1,6 +1,6 @@
import { useCallback, useContext, useMemo } from "react";
import { useDidUpdate } from "rooks";
-import { log } from "../../utilities/logger";
+import { log } from "utilities/logger";
import { ModalContext } from "./provider";
interface ModalInformation<T> {
diff --git a/frontend/src/components/tables/AsyncPageTable.tsx b/frontend/src/components/tables/AsyncPageTable.tsx
deleted file mode 100644
index 00e7748b8..000000000
--- a/frontend/src/components/tables/AsyncPageTable.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import React, { useCallback, useEffect, useState } from "react";
-import { PluginHook, TableOptions, useTable } from "react-table";
-import { LoadingIndicator } from "..";
-import { usePageSize } from "../../@storage/local";
-import {
- ScrollToTop,
- useEntityByRange,
- useIsEntityLoaded,
-} from "../../utilities";
-import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
-import PageControl from "./PageControl";
-import { useDefaultSettings } from "./plugins";
-
-function useEntityPagination<T>(
- entity: Async.Entity<T>,
- loader: (range: Parameter.Range) => void,
- start: number,
- end: number
-): T[] {
- const { state, content } = entity;
-
- const needInit = state === "uninitialized";
- const hasEmpty = useIsEntityLoaded(content, start, end) === false;
-
- useEffect(() => {
- if (needInit || hasEmpty) {
- const length = end - start;
- loader({ start, length });
- }
- });
-
- return useEntityByRange(content, start, end);
-}
-
-type Props<T extends object> = TableOptions<T> &
- TableStyleProps<T> & {
- plugins?: PluginHook<T>[];
- entity: Async.Entity<T>;
- loader: (params: Parameter.Range) => void;
- };
-
-export default function AsyncPageTable<T extends object>(props: Props<T>) {
- const { entity, plugins, loader, ...remain } = props;
- const { style, options } = useStyleAndOptions(remain);
-
- const {
- state,
- content: { ids },
- } = entity;
-
- // Impl a new pagination system instead of hacking into existing one
- const [pageIndex, setIndex] = useState(0);
- const [pageSize] = usePageSize();
- const totalRows = ids.length;
- const pageCount = Math.ceil(totalRows / pageSize);
-
- const pageStart = pageIndex * pageSize;
- const pageEnd = pageStart + pageSize;
-
- const data = useEntityPagination(entity, loader, pageStart, pageEnd);
-
- const instance = useTable(
- {
- ...options,
- data,
- },
- useDefaultSettings,
- ...(plugins ?? [])
- );
-
- const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
- instance;
-
- const previous = useCallback(() => {
- setIndex((idx) => idx - 1);
- }, []);
-
- const next = useCallback(() => {
- setIndex((idx) => idx + 1);
- }, []);
-
- const goto = useCallback((idx: number) => {
- setIndex(idx);
- }, []);
-
- useEffect(() => {
- ScrollToTop();
- }, [pageIndex]);
-
- // Reset page index if we out of bound
- useEffect(() => {
- if (pageCount === 0) return;
-
- if (pageIndex >= pageCount) {
- setIndex(pageCount - 1);
- } else if (pageIndex < 0) {
- setIndex(0);
- }
- }, [pageIndex, pageCount]);
-
- if ((state === "loading" && data.length === 0) || state === "uninitialized") {
- return <LoadingIndicator></LoadingIndicator>;
- }
-
- return (
- <React.Fragment>
- <BaseTable
- {...style}
- headers={headerGroups}
- rows={rows}
- prepareRow={prepareRow}
- tableProps={getTableProps()}
- tableBodyProps={getTableBodyProps()}
- ></BaseTable>
- <PageControl
- count={pageCount}
- index={pageIndex}
- size={pageSize}
- total={totalRows}
- canPrevious={pageIndex > 0}
- canNext={pageIndex < pageCount - 1}
- previous={previous}
- next={next}
- goto={goto}
- ></PageControl>
- </React.Fragment>
- );
-}
diff --git a/frontend/src/components/tables/PageTable.tsx b/frontend/src/components/tables/PageTable.tsx
index 6dc839bb5..f7dfd018c 100644
--- a/frontend/src/components/tables/PageTable.tsx
+++ b/frontend/src/components/tables/PageTable.tsx
@@ -6,7 +6,7 @@ import {
useRowSelect,
useTable,
} from "react-table";
-import { ScrollToTop } from "../../utilities";
+import { ScrollToTop } from "utilities";
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
import PageControl from "./PageControl";
import { useCustomSelection, useDefaultSettings } from "./plugins";
diff --git a/frontend/src/components/tables/QueryPageTable.tsx b/frontend/src/components/tables/QueryPageTable.tsx
new file mode 100644
index 000000000..444e4d40f
--- /dev/null
+++ b/frontend/src/components/tables/QueryPageTable.tsx
@@ -0,0 +1,77 @@
+import { UsePaginationQueryResult } from "apis/queries/hooks";
+import React, { useEffect } from "react";
+import { PluginHook, TableOptions, useTable } from "react-table";
+import { ScrollToTop } from "utilities";
+import { LoadingIndicator } from "..";
+import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
+import PageControl from "./PageControl";
+import { useDefaultSettings } from "./plugins";
+
+type Props<T extends object> = TableOptions<T> &
+ TableStyleProps<T> & {
+ plugins?: PluginHook<T>[];
+ query: UsePaginationQueryResult<T>;
+ };
+
+export default function QueryPageTable<T extends object>(props: Props<T>) {
+ const { plugins, query, ...remain } = props;
+ const { style, options } = useStyleAndOptions(remain);
+
+ const {
+ data,
+ isLoading,
+ paginationStatus: {
+ page,
+ pageCount,
+ totalCount,
+ canPrevious,
+ canNext,
+ pageSize,
+ },
+ controls: { previousPage, nextPage, gotoPage },
+ } = query;
+
+ const instance = useTable(
+ {
+ ...options,
+ data: data?.data ?? [],
+ },
+ useDefaultSettings,
+ ...(plugins ?? [])
+ );
+
+ const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
+ instance;
+
+ useEffect(() => {
+ ScrollToTop();
+ }, [page]);
+
+ if (isLoading) {
+ return <LoadingIndicator></LoadingIndicator>;
+ }
+
+ return (
+ <React.Fragment>
+ <BaseTable
+ {...style}
+ headers={headerGroups}
+ rows={rows}
+ prepareRow={prepareRow}
+ tableProps={getTableProps()}
+ tableBodyProps={getTableBodyProps()}
+ ></BaseTable>
+ <PageControl
+ count={pageCount}
+ index={page}
+ size={pageSize}
+ total={totalCount}
+ canPrevious={canPrevious}
+ canNext={canNext}
+ previous={previousPage}
+ next={nextPage}
+ goto={gotoPage}
+ ></PageControl>
+ </React.Fragment>
+ );
+}
diff --git a/frontend/src/components/tables/index.tsx b/frontend/src/components/tables/index.tsx
index 9db3466f8..2e7cb618d 100644
--- a/frontend/src/components/tables/index.tsx
+++ b/frontend/src/components/tables/index.tsx
@@ -1,4 +1,4 @@
-export { default as AsyncPageTable } from "./AsyncPageTable";
export { default as GroupTable } from "./GroupTable";
export { default as PageTable } from "./PageTable";
+export { default as QueryPageTable } from "./QueryPageTable";
export { default as SimpleTable } from "./SimpleTable";
diff --git a/frontend/src/components/tables/plugins/useDefaultSettings.tsx b/frontend/src/components/tables/plugins/useDefaultSettings.tsx
index 72103bff5..444ee2616 100644
--- a/frontend/src/components/tables/plugins/useDefaultSettings.tsx
+++ b/frontend/src/components/tables/plugins/useDefaultSettings.tsx
@@ -1,5 +1,5 @@
import { Hooks, TableOptions } from "react-table";
-import { usePageSize } from "../../../@storage/local";
+import { usePageSize } from "utilities/storage";
const pluginName = "useLocalSettings";
diff --git a/frontend/src/components/views/HistoryView.tsx b/frontend/src/components/views/HistoryView.tsx
new file mode 100644
index 000000000..fb900218e
--- /dev/null
+++ b/frontend/src/components/views/HistoryView.tsx
@@ -0,0 +1,36 @@
+import { UsePaginationQueryResult } from "apis/queries/hooks";
+import React from "react";
+import { Container, Row } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import { Column } from "react-table";
+import { QueryPageTable } from "..";
+
+interface Props<T extends History.Base> {
+ name: string;
+ query: UsePaginationQueryResult<T>;
+ columns: Column<T>[];
+}
+
+function HistoryView<T extends History.Base = History.Base>({
+ columns,
+ name,
+ query,
+}: Props<T>) {
+ return (
+ <Container fluid>
+ <Helmet>
+ <title>{name} History - Bazarr</title>
+ </Helmet>
+ <Row>
+ <QueryPageTable
+ emptyText={`Nothing Found in ${name} History`}
+ columns={columns}
+ query={query}
+ data={[]}
+ ></QueryPageTable>
+ </Row>
+ </Container>
+ );
+}
+
+export default HistoryView;
diff --git a/frontend/src/components/views/ItemView.tsx b/frontend/src/components/views/ItemView.tsx
new file mode 100644
index 000000000..22cd56ea8
--- /dev/null
+++ b/frontend/src/components/views/ItemView.tsx
@@ -0,0 +1,213 @@
+import { faCheck, faList, faUndo } from "@fortawesome/free-solid-svg-icons";
+import { useIsAnyMutationRunning, useLanguageProfiles } from "apis/hooks";
+import { UsePaginationQueryResult } from "apis/queries/hooks";
+import { TableStyleProps } from "components/tables/BaseTable";
+import { useCustomSelection } from "components/tables/plugins";
+import { uniqBy } from "lodash";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { Container, Dropdown, Row } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import { UseMutationResult, UseQueryResult } from "react-query";
+import { Column, TableOptions, TableUpdater, useRowSelect } from "react-table";
+import { GetItemId } from "utilities";
+import {
+ ContentHeader,
+ ItemEditorModal,
+ LoadingIndicator,
+ QueryPageTable,
+ SimpleTable,
+ useShowModal,
+} from "..";
+
+interface Props<T extends Item.Base = Item.Base> {
+ name: string;
+ fullQuery: UseQueryResult<T[]>;
+ query: UsePaginationQueryResult<T>;
+ columns: Column<T>[];
+ mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
+}
+
+function ItemView<T extends Item.Base>({
+ name,
+ fullQuery,
+ query,
+ columns,
+ mutation,
+}: Props<T>) {
+ const [editMode, setEditMode] = useState(false);
+
+ const { mutateAsync } = mutation;
+
+ const showModal = useShowModal();
+
+ const updateRow = useCallback<TableUpdater<T>>(
+ ({ original }, modalKey: string) => {
+ showModal(modalKey, original);
+ },
+ [showModal]
+ );
+
+ const options: Partial<TableOptions<T> & TableStyleProps<T>> = {
+ emptyText: `No ${name} Found`,
+ update: updateRow,
+ };
+
+ const content = editMode ? (
+ <ItemMassEditor
+ query={fullQuery}
+ columns={columns}
+ mutation={mutation}
+ onEnded={() => setEditMode(false)}
+ ></ItemMassEditor>
+ ) : (
+ <>
+ <ContentHeader scroll={false}>
+ <ContentHeader.Button
+ disabled={query.paginationStatus.totalCount === 0}
+ icon={faList}
+ onClick={() => setEditMode(true)}
+ >
+ Mass Edit
+ </ContentHeader.Button>
+ </ContentHeader>
+ <Row>
+ <QueryPageTable
+ {...options}
+ columns={columns}
+ query={query}
+ data={[]}
+ ></QueryPageTable>
+ <ItemEditorModal modalKey="edit" submit={mutateAsync}></ItemEditorModal>
+ </Row>
+ </>
+ );
+
+ return (
+ <Container fluid>
+ <Helmet>
+ <title>{name} - Bazarr</title>
+ </Helmet>
+ {content}
+ </Container>
+ );
+}
+
+interface ItemMassEditorProps<T extends Item.Base> {
+ columns: Column<T>[];
+ query: UseQueryResult<T[]>;
+ mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
+ onEnded: () => void;
+}
+
+function ItemMassEditor<T extends Item.Base = Item.Base>(
+ props: ItemMassEditorProps<T>
+) {
+ const { columns, mutation, query, onEnded } = props;
+ const [selections, setSelections] = useState<T[]>([]);
+ const [dirties, setDirties] = useState<T[]>([]);
+ const hasTask = useIsAnyMutationRunning();
+ const { data: profiles } = useLanguageProfiles();
+
+ const { refetch } = query;
+
+ useEffect(() => {
+ refetch();
+ }, [refetch]);
+
+ const data = useMemo(
+ () => uniqBy([...dirties, ...(query?.data ?? [])], GetItemId),
+ [dirties, query?.data]
+ );
+
+ const profileOptions = useMemo<JSX.Element[]>(() => {
+ const items: JSX.Element[] = [];
+ if (profiles) {
+ items.push(
+ <Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
+ );
+ items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
+ items.push(
+ ...profiles.map((v) => (
+ <Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
+ {v.name}
+ </Dropdown.Item>
+ ))
+ );
+ }
+
+ return items;
+ }, [profiles]);
+
+ const { mutateAsync } = mutation;
+
+ const save = useCallback(() => {
+ const form: FormType.ModifyItem = {
+ id: [],
+ profileid: [],
+ };
+ dirties.forEach((v) => {
+ const id = GetItemId(v);
+ if (id) {
+ form.id.push(id);
+ form.profileid.push(v.profileId);
+ }
+ });
+ return mutateAsync(form);
+ }, [dirties, mutateAsync]);
+
+ const setProfiles = useCallback(
+ (key: Nullable<string>) => {
+ const id = key ? parseInt(key) : null;
+
+ const newItems = selections.map((v) => ({ ...v, profileId: id }));
+
+ setDirties((dirty) => {
+ return uniqBy([...newItems, ...dirty], GetItemId);
+ });
+ },
+ [selections]
+ );
+
+ return (
+ <>
+ <ContentHeader scroll={false}>
+ <ContentHeader.Group pos="start">
+ <Dropdown onSelect={setProfiles}>
+ <Dropdown.Toggle disabled={selections.length === 0} variant="light">
+ Change Profile
+ </Dropdown.Toggle>
+ <Dropdown.Menu>{profileOptions}</Dropdown.Menu>
+ </Dropdown>
+ </ContentHeader.Group>
+ <ContentHeader.Group pos="end">
+ <ContentHeader.Button icon={faUndo} onClick={onEnded}>
+ Cancel
+ </ContentHeader.Button>
+ <ContentHeader.AsyncButton
+ icon={faCheck}
+ disabled={dirties.length === 0 || hasTask}
+ promise={save}
+ onSuccess={onEnded}
+ >
+ Save
+ </ContentHeader.AsyncButton>
+ </ContentHeader.Group>
+ </ContentHeader>
+ <Row>
+ {query.data === undefined ? (
+ <LoadingIndicator></LoadingIndicator>
+ ) : (
+ <SimpleTable
+ columns={columns}
+ data={data}
+ onSelect={setSelections}
+ isSelecting
+ plugins={[useRowSelect, useCustomSelection]}
+ ></SimpleTable>
+ )}
+ </Row>
+ </>
+ );
+}
+
+export default ItemView;
diff --git a/frontend/src/components/views/WantedView.tsx b/frontend/src/components/views/WantedView.tsx
new file mode 100644
index 000000000..ef0895066
--- /dev/null
+++ b/frontend/src/components/views/WantedView.tsx
@@ -0,0 +1,60 @@
+import { faSearch } from "@fortawesome/free-solid-svg-icons";
+import { dispatchTask } from "@modules/task";
+import { createTask } from "@modules/task/utilities";
+import { useIsAnyActionRunning } from "apis/hooks";
+import { UsePaginationQueryResult } from "apis/queries/hooks";
+import React from "react";
+import { Container, Row } from "react-bootstrap";
+import { Helmet } from "react-helmet";
+import { Column } from "react-table";
+import { ContentHeader, QueryPageTable } from "..";
+
+interface Props<T extends Wanted.Base> {
+ name: string;
+ columns: Column<T>[];
+ query: UsePaginationQueryResult<T>;
+ searchAll: () => Promise<void>;
+}
+
+const TaskGroupName = "Searching wanted subtitles...";
+
+function WantedView<T extends Wanted.Base>({
+ name,
+ columns,
+ query,
+ searchAll,
+}: Props<T>) {
+ // TODO
+ const dataCount = query.paginationStatus.totalCount;
+ const hasTask = useIsAnyActionRunning();
+
+ return (
+ <Container fluid>
+ <Helmet>
+ <title>Wanted {name} - Bazarr</title>
+ </Helmet>
+ <ContentHeader>
+ <ContentHeader.Button
+ disabled={hasTask || dataCount === 0}
+ onClick={() => {
+ const task = createTask(name, undefined, searchAll);
+ dispatchTask(TaskGroupName, [task], "Searching...");
+ }}
+ icon={faSearch}
+ >
+ Search All
+ </ContentHeader.Button>
+ </ContentHeader>
+ <Row>
+ <QueryPageTable
+ emptyText={`No Missing ${name} Subtitles`}
+ query={query}
+ columns={columns}
+ data={[]}
+ ></QueryPageTable>
+ </Row>
+ </Container>
+ );
+}
+
+export default WantedView;