diff options
Diffstat (limited to 'frontend/src/components')
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; |