diff options
Diffstat (limited to 'frontend/src/components')
48 files changed, 613 insertions, 897 deletions
diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index e419d6da5..b7b7494fb 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,12 +1,12 @@ -import UIError from "pages/UIError"; -import React from "react"; +import UIError from "@/pages/UIError"; +import { Component } from "react"; interface State { error: Error | null; } -class ErrorBoundary extends React.Component<{}, State> { - constructor(props: {}) { +class ErrorBoundary extends Component<object, State> { + constructor(props: object) { super(props); this.state = { error: null }; } diff --git a/frontend/src/components/ItemOverview.tsx b/frontend/src/components/ItemOverview.tsx index 7915a2ed4..565430f2e 100644 --- a/frontend/src/components/ItemOverview.tsx +++ b/frontend/src/components/ItemOverview.tsx @@ -1,3 +1,8 @@ +import { BuildKey, isMovie } from "@/utilities"; +import { + useLanguageProfileBy, + useProfileItemsToLanguages, +} from "@/utilities/languages"; import { faBookmark as farBookmark, faClone as fasClone, @@ -12,7 +17,7 @@ import { IconDefinition, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React, { FunctionComponent, useMemo } from "react"; +import { FunctionComponent, useMemo } from "react"; import { Badge, Col, @@ -22,12 +27,7 @@ import { Popover, Row, } from "react-bootstrap"; -import { BuildKey, isMovie } from "utilities"; -import { - useLanguageProfileBy, - useProfileItemsToLanguages, -} from "utilities/languages"; -import { LanguageText } from "."; +import Language from "./bazarr/Language"; interface Props { item: Item.Base; @@ -102,7 +102,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => { icon={faLanguage} desc="Language" > - <LanguageText long text={v}></LanguageText> + <Language.Text long value={v}></Language.Text> </DetailBadge> )) ); diff --git a/frontend/src/components/LanguageSelector.tsx b/frontend/src/components/LanguageSelector.tsx index 1d6271a64..648ccf167 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, SelectorOption, SelectorProps } from "@/components"; +import { useMemo } from "react"; interface Props { options: readonly Language.Info[]; diff --git a/frontend/src/components/Lazy.tsx b/frontend/src/components/Lazy.tsx new file mode 100644 index 000000000..a22dfd15a --- /dev/null +++ b/frontend/src/components/Lazy.tsx @@ -0,0 +1,8 @@ +import { FunctionComponent, Suspense } from "react"; +import { LoadingIndicator } from "."; + +const Lazy: FunctionComponent = ({ children }) => { + return <Suspense fallback={<LoadingIndicator />}>{children}</Suspense>; +}; + +export default Lazy; diff --git a/frontend/src/components/MassEditor.tsx b/frontend/src/components/MassEditor.tsx new file mode 100644 index 000000000..9a6fdb0e3 --- /dev/null +++ b/frontend/src/components/MassEditor.tsx @@ -0,0 +1,121 @@ +import { useIsAnyMutationRunning, useLanguageProfiles } from "@/apis/hooks"; +import { GetItemId } from "@/utilities"; +import { faCheck, faUndo } from "@fortawesome/free-solid-svg-icons"; +import { uniqBy } from "lodash"; +import { useCallback, useMemo, useState } from "react"; +import { Container, Dropdown, Row } from "react-bootstrap"; +import { UseMutationResult } from "react-query"; +import { useNavigate } from "react-router-dom"; +import { Column, useRowSelect } from "react-table"; +import { ContentHeader, SimpleTable } from "."; +import { useCustomSelection } from "./tables/plugins"; + +interface MassEditorProps<T extends Item.Base = Item.Base> { + columns: Column<T>[]; + data: T[]; + mutation: UseMutationResult<void, unknown, FormType.ModifyItem>; +} + +function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) { + const { columns, data: raw, mutation } = props; + + const [selections, setSelections] = useState<T[]>([]); + const [dirties, setDirties] = useState<T[]>([]); + const hasTask = useIsAnyMutationRunning(); + const { data: profiles } = useLanguageProfiles(); + + const navigate = useNavigate(); + + const onEnded = useCallback(() => navigate(".."), [navigate]); + + const data = useMemo( + () => uniqBy([...dirties, ...(raw ?? [])], GetItemId), + [dirties, raw] + ); + + const profileOptions = useMemo(() => { + 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 ( + <Container fluid> + <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> + <SimpleTable + columns={columns} + data={data} + onSelect={setSelections} + plugins={[useRowSelect, useCustomSelection]} + ></SimpleTable> + </Row> + </Container> + ); +} + +export default MassEditor; diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index f8de27ec4..acdfd56a9 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -1,6 +1,6 @@ -import { useServerSearch } from "apis/hooks"; +import { useServerSearch } from "@/apis/hooks"; import { uniqueId } from "lodash"; -import React, { +import { FunctionComponent, useCallback, useEffect, @@ -8,7 +8,7 @@ import React, { useState, } from "react"; import { Dropdown, Form } from "react-bootstrap"; -import { useHistory } from "react-router"; +import { useNavigate } from "react-router-dom"; import { useThrottle } from "rooks"; function useSearch(query: string) { @@ -66,7 +66,7 @@ export const SearchBar: FunctionComponent<Props> = ({ const results = useSearch(query); - const history = useHistory(); + const navigate = useNavigate(); const clear = useCallback(() => { setDisplay(""); @@ -100,7 +100,7 @@ export const SearchBar: FunctionComponent<Props> = ({ onSelect={(link) => { if (link) { clear(); - history.push(link); + navigate(link); } }} > diff --git a/frontend/src/components/async.tsx b/frontend/src/components/async.tsx index 105cd567e..7c781707e 100644 --- a/frontend/src/components/async.tsx +++ b/frontend/src/components/async.tsx @@ -4,9 +4,10 @@ import { faTimes, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React, { +import { FunctionComponent, PropsWithChildren, + ReactElement, useCallback, useEffect, useState, @@ -18,7 +19,7 @@ import { LoadingIndicator } from "."; interface QueryOverlayProps { result: UseQueryResult<unknown, unknown>; - children: React.ReactElement; + children: ReactElement; } export const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({ @@ -43,9 +44,7 @@ export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) { const [item, setItem] = useState<T | null>(null); useEffect(() => { - promise() - .then(setItem) - .catch(() => {}); + promise().then(setItem); }, [promise]); if (item === null) { diff --git a/frontend/src/components/bazarr/Language.tsx b/frontend/src/components/bazarr/Language.tsx new file mode 100644 index 000000000..ad60a7b65 --- /dev/null +++ b/frontend/src/components/bazarr/Language.tsx @@ -0,0 +1,88 @@ +import { useLanguages } from "@/apis/hooks"; +import { Selector, SelectorOption, SelectorProps } from "@/components"; +import { FunctionComponent, useMemo } from "react"; + +interface TextProps { + value: Language.Info; + className?: string; + long?: boolean; +} + +declare type LanguageComponent = { + Text: typeof LanguageText; + Selector: typeof LanguageSelector; +}; + +const LanguageText: FunctionComponent<TextProps> = ({ + value, + className, + long, +}) => { + const result = useMemo(() => { + let lang = value.code2; + let hi = ":HI"; + let forced = ":Forced"; + if (long) { + lang = value.name; + hi = " HI"; + forced = " Forced"; + } + + let res = lang; + if (value.hi) { + res += hi; + } else if (value.forced) { + res += forced; + } + return res; + }, [value, long]); + + return ( + <span title={value.name} className={className}> + {result} + </span> + ); +}; + +type LanguageSelectorProps<M extends boolean> = Omit< + SelectorProps<Language.Info, M>, + "label" | "options" +> & { + history?: boolean; +}; + +function getLabel(lang: Language.Info) { + return lang.name; +} + +export function LanguageSelector<M extends boolean = false>( + props: LanguageSelectorProps<M> +) { + const { history, ...rest } = props; + const { data: options } = useLanguages(history); + + const items = useMemo<SelectorOption<Language.Info>[]>( + () => + options?.map((v) => ({ + label: v.name, + value: v, + })) ?? [], + [options] + ); + + return ( + <Selector + placeholder="Language..." + options={items} + label={getLabel} + {...rest} + ></Selector> + ); +} + +const Components: LanguageComponent = { + Text: LanguageText, + Selector: LanguageSelector, +}; + +export default Components; diff --git a/frontend/src/components/bazarr/LanguageProfile.tsx b/frontend/src/components/bazarr/LanguageProfile.tsx new file mode 100644 index 000000000..c3724dc89 --- /dev/null +++ b/frontend/src/components/bazarr/LanguageProfile.tsx @@ -0,0 +1,25 @@ +import { useLanguageProfiles } from "@/apis/hooks"; +import { FunctionComponent, useMemo } from "react"; + +interface Props { + index: number | null; + className?: string; + empty?: string; +} + +const LanguageProfile: FunctionComponent<Props> = ({ + index, + className, + empty = "Unknown Profile", +}) => { + const { data } = useLanguageProfiles(); + + const name = useMemo( + () => data?.find((v) => v.profileId === index)?.name ?? empty, + [data, empty, index] + ); + + return <span className={className}>{name}</span>; +}; + +export default LanguageProfile; diff --git a/frontend/src/components/buttons.tsx b/frontend/src/components/buttons.tsx index c472c1256..db8b9836a 100644 --- a/frontend/src/components/buttons.tsx +++ b/frontend/src/components/buttons.tsx @@ -1,7 +1,7 @@ import { IconDefinition } from "@fortawesome/fontawesome-common-types"; import { faCircleNotch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React, { FunctionComponent, MouseEvent } from "react"; +import { FunctionComponent, MouseEvent } from "react"; import { Badge, Button, ButtonProps } from "react-bootstrap"; export const ActionBadge: FunctionComponent<{ @@ -66,7 +66,7 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({ }) => { const showText = alwaysShowText === true || loading !== true; return ( - <React.Fragment> + <> <FontAwesomeIcon style={{ width: "1rem" }} icon={loading ? faCircleNotch : icon} @@ -75,6 +75,6 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({ {children && showText ? ( <span className="ml-2 font-weight-bold">{children}</span> ) : null} - </React.Fragment> + </> ); }; diff --git a/frontend/src/components/header/Button.tsx b/frontend/src/components/header/Button.tsx index fa0480689..7cd952876 100644 --- a/frontend/src/components/header/Button.tsx +++ b/frontend/src/components/header/Button.tsx @@ -1,7 +1,7 @@ import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { faSpinner } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React, { +import { FunctionComponent, MouseEvent, PropsWithChildren, @@ -46,13 +46,13 @@ const ContentHeaderButton: FunctionComponent<CHButtonProps> = (props) => { ); }; -type CHAsyncButtonProps<T extends () => Promise<any>> = { +type CHAsyncButtonProps<R, T extends () => Promise<R>> = { promise: T; - onSuccess?: (item: PromiseType<ReturnType<T>>) => void; + onSuccess?: (item: R) => void; } & Omit<CHButtonProps, "updating" | "updatingIcon" | "onClick">; -export function ContentHeaderAsyncButton<T extends () => Promise<any>>( - props: PropsWithChildren<CHAsyncButtonProps<T>> +export function ContentHeaderAsyncButton<R, T extends () => Promise<R>>( + props: PropsWithChildren<CHAsyncButtonProps<R, T>> ): JSX.Element { const { promise, onSuccess, ...button } = props; diff --git a/frontend/src/components/header/Group.tsx b/frontend/src/components/header/Group.tsx index b7bd2d4ee..085065631 100644 --- a/frontend/src/components/header/Group.tsx +++ b/frontend/src/components/header/Group.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent } from "react"; +import { FunctionComponent } from "react"; type GroupPosition = "start" | "end"; interface GroupProps { diff --git a/frontend/src/components/header/index.tsx b/frontend/src/components/header/index.tsx index a94fdb7f5..200f9e343 100644 --- a/frontend/src/components/header/index.tsx +++ b/frontend/src/components/header/index.tsx @@ -1,8 +1,7 @@ -import React, { FunctionComponent, useMemo } from "react"; +import { FunctionComponent, ReactNode, useMemo } from "react"; import { Row } from "react-bootstrap"; import ContentHeaderButton, { ContentHeaderAsyncButton } from "./Button"; import ContentHeaderGroup from "./Group"; -import "./style.scss"; interface Props { scroll?: boolean; @@ -29,7 +28,7 @@ export const ContentHeader: Header = ({ children, scroll, className }) => { return rowCls.join(" "); }, [scroll, className]); - let childItem: React.ReactNode; + let childItem: ReactNode; if (scroll !== false) { childItem = ( diff --git a/frontend/src/components/header/style.scss b/frontend/src/components/header/style.scss deleted file mode 100644 index 7ce71bb36..000000000 --- a/frontend/src/components/header/style.scss +++ /dev/null @@ -1,9 +0,0 @@ -.content-header { - position: sticky; - top: 0; - z-index: 99; - - &.scroll { - overflow-x: auto; - } -} diff --git a/frontend/src/components/index.tsx b/frontend/src/components/index.tsx index dc0e0999c..acb6b1fa1 100644 --- a/frontend/src/components/index.tsx +++ b/frontend/src/components/index.tsx @@ -11,7 +11,7 @@ import { FontAwesomeIconProps, } from "@fortawesome/react-fontawesome"; import { isNull, isUndefined } from "lodash"; -import React, { FunctionComponent, useMemo } from "react"; +import { FunctionComponent, ReactElement } from "react"; import { OverlayTrigger, OverlayTriggerProps, @@ -97,44 +97,8 @@ export const LoadingIndicator: FunctionComponent<{ ); }; -interface LanguageTextProps { - text: Language.Info; - className?: string; - long?: boolean; -} - -export const LanguageText: FunctionComponent<LanguageTextProps> = ({ - text, - className, - long, -}) => { - const result = useMemo(() => { - let lang = text.code2; - let hi = ":HI"; - let forced = ":Forced"; - if (long) { - lang = text.name; - hi = " HI"; - forced = " Forced"; - } - - let res = lang; - if (text.hi) { - res += hi; - } else if (text.forced) { - res += forced; - } - return res; - }, [text, long]); - return ( - <span title={text.name} className={className}> - {result} - </span> - ); -}; - interface TextPopoverProps { - children: React.ReactElement<any, any>; + children: ReactElement; text: string | undefined | null; placement?: OverlayTriggerProps["placement"]; delay?: number; diff --git a/frontend/src/components/inputs/Chips.tsx b/frontend/src/components/inputs/Chips.tsx index 1be0050a0..e52f751d7 100644 --- a/frontend/src/components/inputs/Chips.tsx +++ b/frontend/src/components/inputs/Chips.tsx @@ -1,4 +1,4 @@ -import React, { +import { FocusEvent, FunctionComponent, KeyboardEvent, @@ -8,7 +8,6 @@ import React, { useRef, useState, } from "react"; -import "./chip.scss"; const SplitKeys = ["Tab", "Enter", " ", ",", ";"]; diff --git a/frontend/src/components/inputs/FileBrowser.tsx b/frontend/src/components/inputs/FileBrowser.tsx index f3bc1d37d..5bf289de7 100644 --- a/frontend/src/components/inputs/FileBrowser.tsx +++ b/frontend/src/components/inputs/FileBrowser.tsx @@ -1,8 +1,9 @@ +import { useFileSystem } from "@/apis/hooks"; 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, { +import { + ChangeEvent, FunctionComponent, useEffect, useMemo, @@ -147,7 +148,7 @@ export const FileBrowser: FunctionComponent<FileBrowserProps> = ({ placeholder="Click to start" type="text" value={text} - onChange={(e: React.ChangeEvent<HTMLInputElement>) => { + onChange={(e: ChangeEvent<HTMLInputElement>) => { setText(e.currentTarget.value); }} ref={input} diff --git a/frontend/src/components/inputs/FileForm.tsx b/frontend/src/components/inputs/FileForm.tsx index bfff960d6..af7a6a238 100644 --- a/frontend/src/components/inputs/FileForm.tsx +++ b/frontend/src/components/inputs/FileForm.tsx @@ -1,4 +1,4 @@ -import React, { +import { ChangeEvent, FunctionComponent, useEffect, diff --git a/frontend/src/components/inputs/Selector.tsx b/frontend/src/components/inputs/Selector.tsx index f3df67459..98ea9eeb3 100644 --- a/frontend/src/components/inputs/Selector.tsx +++ b/frontend/src/components/inputs/Selector.tsx @@ -1,8 +1,22 @@ -import { isArray } from "lodash"; -import React, { useCallback, useMemo } from "react"; -import Select from "react-select"; +import clsx from "clsx"; +import { FocusEvent, useCallback, useMemo, useRef } from "react"; +import Select, { GroupBase, OnChangeValue } from "react-select"; import { SelectComponents } from "react-select/dist/declarations/src/components"; -import "./selector.scss"; + +export type SelectorOption<T> = { + label: string; + value: T; +}; + +export type SelectorComponents<T, M extends boolean> = SelectComponents< + SelectorOption<T>, + M, + GroupBase<SelectorOption<T>> +>; + +export type SelectorValueType<T, M extends boolean> = M extends true + ? ReadonlyArray<T> + : Nullable<T>; export interface SelectorProps<T, M extends boolean> { className?: string; @@ -13,11 +27,13 @@ export interface SelectorProps<T, M extends boolean> { loading?: boolean; multiple?: M; onChange?: (k: SelectorValueType<T, M>) => void; - onFocus?: (e: React.FocusEvent<HTMLElement>) => void; + onFocus?: (e: FocusEvent<HTMLElement>) => void; label?: (item: T) => string; defaultValue?: SelectorValueType<T, M>; value?: SelectorValueType<T, M>; - components?: Partial<SelectComponents<T, M, any>>; + components?: Partial< + SelectComponents<SelectorOption<T>, M, GroupBase<SelectorOption<T>>> + >; } export function Selector<T = string, M extends boolean = false>( @@ -39,34 +55,45 @@ export function Selector<T = string, M extends boolean = false>( value, } = props; - const nameFromItems = useCallback( + const labelRef = useRef(label); + + const getName = useCallback( (item: T) => { - return options.find((v) => v.value === item)?.label; + if (labelRef.current) { + return labelRef.current(item); + } + + return options.find((v) => v.value === item)?.label ?? "Unknown"; }, [options] ); - // TODO: Force as any const wrapper = useCallback( - (value: SelectorValueType<T, M> | undefined | null): any => { - if (value !== null && value !== undefined) { - if (multiple) { + ( + value: SelectorValueType<T, M> | undefined | null + ): + | SelectorOption<T> + | ReadonlyArray<SelectorOption<T>> + | null + | undefined => { + if (value === null || value === undefined) { + return value as null | undefined; + } else { + if (multiple === true) { return (value as SelectorValueType<T, true>).map((v) => ({ - label: label ? label(v) : nameFromItems(v) ?? "Unknown", + label: getName(v), value: v, })); } else { const v = value as T; return { - label: label ? label(v) : nameFromItems(v) ?? "Unknown", + label: getName(v), value: v, }; } } - - return value; }, - [label, multiple, nameFromItems] + [multiple, getName] ); const defaultWrapper = useMemo( @@ -89,21 +116,23 @@ export function Selector<T = string, M extends boolean = false>( isDisabled={disabled} options={options} components={components} - className={`custom-selector w-100 ${className ?? ""}`} + className={clsx("custom-selector w-100", className)} classNamePrefix="selector" onFocus={onFocus} - onChange={(v: SelectorOption<T>[]) => { + onChange={(newValue) => { if (onChange) { - let res: T | T[] | null = null; - if (isArray(v)) { - res = (v as ReadonlyArray<SelectorOption<T>>).map( - (val) => val.value - ); + if (multiple === true) { + const values = ( + newValue as OnChangeValue<SelectorOption<T>, true> + ).map((v) => v.value) as ReadonlyArray<T>; + + onChange(values as SelectorValueType<T, M>); } else { - res = (v as SelectorOption<T>)?.value ?? null; + const value = (newValue as OnChangeValue<SelectorOption<T>, false>) + ?.value; + + onChange(value as SelectorValueType<T, M>); } - // TODO: Force as any - onChange(res as any); } }} ></Select> diff --git a/frontend/src/components/inputs/Slider.tsx b/frontend/src/components/inputs/Slider.tsx index e8a8c3c35..ab3fb0996 100644 --- a/frontend/src/components/inputs/Slider.tsx +++ b/frontend/src/components/inputs/Slider.tsx @@ -1,7 +1,6 @@ import RcSlider from "rc-slider"; import "rc-slider/assets/index.css"; -import React, { FunctionComponent, useMemo, useState } from "react"; -import "./slider.scss"; +import { FunctionComponent, useMemo, useState } from "react"; type TooltipsOptions = boolean | "Always"; diff --git a/frontend/src/components/inputs/blacklist.tsx b/frontend/src/components/inputs/blacklist.tsx index fe079a925..d55e843ec 100644 --- a/frontend/src/components/inputs/blacklist.tsx +++ b/frontend/src/components/inputs/blacklist.tsx @@ -1,6 +1,6 @@ import { faFileExcel } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React, { FunctionComponent } from "react"; +import { FunctionComponent } from "react"; import { AsyncButton } from ".."; interface Props { diff --git a/frontend/src/components/inputs/chip.scss b/frontend/src/components/inputs/chip.scss deleted file mode 100644 index 92e172541..000000000 --- a/frontend/src/components/inputs/chip.scss +++ /dev/null @@ -1,43 +0,0 @@ -.custom-chip-input { - overflow: hidden; - &:focus-within { - border-color: var(--primary); - } - .main-input { - border: hidden; - outline: none; - width: 100%; - flex-grow: 2; - } - - .chip-container { - display: flex; - flex-wrap: nowrap; - flex-grow: 1; - } - - .custom-chip { - padding: 0 0.5rem; - margin-right: 0.25rem; - background-color: var(--light); - border-radius: 0.25rem; - - max-width: 10rem; - - overflow-x: hidden; - text-overflow: ellipsis; - - transition: { - duration: 0.1s; - timing-function: ease-in-out; - } - - &.active { - &:hover { - cursor: pointer; - background-color: var(--danger); - color: white; - } - } - } -} diff --git a/frontend/src/components/inputs/selector.scss b/frontend/src/components/inputs/selector.scss deleted file mode 100644 index 5f4b139c5..000000000 --- a/frontend/src/components/inputs/selector.scss +++ /dev/null @@ -1,30 +0,0 @@ -@import "../../@scss/variable.scss"; - -.custom-selector { - .selector__control { - outline: none !important; - box-shadow: none !important; - border-radius: 0.25rem; - } - .selector__control--is-focused { - border-color: var(--primary) !important; - } - .selector__menu { - z-index: 1000; - } - - .selector__option--is-focused { - background-color: $theme-color-transparent; - &:focus, - &:active { - background-color: $theme-color-less-transparent; - } - } - - .selector__option--is-selected { - background-color: var(--primary); - &:active { - background-color: $theme-color-darked; - } - } -} diff --git a/frontend/src/components/inputs/slider.scss b/frontend/src/components/inputs/slider.scss deleted file mode 100644 index f0fe518dd..000000000 --- a/frontend/src/components/inputs/slider.scss +++ /dev/null @@ -1,49 +0,0 @@ -.custom-rc-slider { - .rc-slider-track { - background-color: var(--primary); - } - .rc-slider-step { - cursor: pointer; - } - .rc-slider-handle { - border: 3px solid var(--primary); - margin-top: 0; - top: 50%; - transform: translate(-50%, -50%) !important; - width: 18px; - height: 18px; - cursor: pointer; - - .rc-slider-handle-tips-always { - display: block !important; - } - - .rc-slider-handle-tips-hidden { - display: none !important; - } - - .rc-slider-handle-tips { - font-size: medium; - display: none; - position: absolute; - top: -1.1rem; - left: 50%; - transform: translate(-50%, -50%); - } - - &:hover { - border-color: var(--primary); - .rc-slider-handle-tips { - display: block; - } - } - - &:active { - border-color: var(--primary); - box-shadow: none; - .rc-slider-handle-tips { - display: block; - } - } - } -} diff --git a/frontend/src/components/modals/BaseModal.tsx b/frontend/src/components/modals/BaseModal.tsx index 89021effb..f9488ef6b 100644 --- a/frontend/src/components/modals/BaseModal.tsx +++ b/frontend/src/components/modals/BaseModal.tsx @@ -1,6 +1,7 @@ -import React, { FunctionComponent, useCallback, useState } from "react"; +import { useIsShowed, useModalControl } from "@/modules/redux/hooks/modal"; +import clsx from "clsx"; +import { FunctionComponent, useCallback, useState } from "react"; import { Modal } from "react-bootstrap"; -import { useModalInformation } from "./hooks"; export interface BaseModalProps { modalKey: string; @@ -11,32 +12,34 @@ export interface BaseModalProps { } export const BaseModal: FunctionComponent<BaseModalProps> = (props) => { - const { size, modalKey, title, children, footer } = props; + const { size, modalKey, title, children, footer, closeable = true } = props; const [needExit, setExit] = useState(false); - const { isShow, closeModal } = useModalInformation(modalKey); - - const closeable = props.closeable !== false; + const { hide: hideModal } = useModalControl(); + const showIndex = useIsShowed(modalKey); + const isShowed = showIndex !== -1; const hide = useCallback(() => { setExit(true); }, []); const exit = useCallback(() => { - if (isShow) { - closeModal(modalKey); + if (isShowed) { + hideModal(modalKey); } setExit(false); - }, [closeModal, modalKey, isShow]); + }, [isShowed, hideModal, modalKey]); return ( <Modal centered size={size} - show={isShow && !needExit} + show={isShowed && !needExit} onHide={hide} onExited={exit} backdrop={closeable ? undefined : "static"} + className={clsx(`index-${showIndex}`)} + backdropClassName={clsx(`index-${showIndex}`)} > <Modal.Header closeButton={closeable}>{title}</Modal.Header> <Modal.Body>{children}</Modal.Body> diff --git a/frontend/src/components/modals/HistoryModal.tsx b/frontend/src/components/modals/HistoryModal.tsx index 7fe8a40f6..dd4adf2bd 100644 --- a/frontend/src/components/modals/HistoryModal.tsx +++ b/frontend/src/components/modals/HistoryModal.tsx @@ -3,24 +3,19 @@ import { useEpisodeHistory, useMovieAddBlacklist, useMovieHistory, -} from "apis/hooks"; -import React, { FunctionComponent, useMemo } from "react"; +} from "@/apis/hooks"; +import { usePayload } from "@/modules/redux/hooks/modal"; +import { FunctionComponent, useMemo } from "react"; import { Column } from "react-table"; -import { - HistoryIcon, - LanguageText, - PageTable, - QueryOverlay, - TextPopover, -} from ".."; +import { HistoryIcon, PageTable, QueryOverlay, TextPopover } from ".."; +import Language from "../bazarr/Language"; import { BlacklistButton } from "../inputs/blacklist"; import BaseModal, { BaseModalProps } from "./BaseModal"; -import { useModalPayload } from "./hooks"; export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => { const { ...modal } = props; - const movie = useModalPayload<Item.Movie>(modal.modalKey); + const movie = usePayload<Item.Movie>(modal.modalKey); const history = useMovieHistory(movie?.radarrId); @@ -40,7 +35,7 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => { accessor: "language", Cell: ({ value }) => { if (value) { - return <LanguageText text={value} long></LanguageText>; + return <Language.Text value={value} long></Language.Text>; } else { return null; } @@ -101,12 +96,10 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => { ); }; -interface EpisodeHistoryProps {} - -export const EpisodeHistoryModal: FunctionComponent< - BaseModalProps & EpisodeHistoryProps -> = (props) => { - const episode = useModalPayload<Item.Episode>(props.modalKey); +export const EpisodeHistoryModal: FunctionComponent<BaseModalProps> = ( + props +) => { + const episode = usePayload<Item.Episode>(props.modalKey); const history = useEpisodeHistory(episode?.sonarrEpisodeId); @@ -126,7 +119,7 @@ export const EpisodeHistoryModal: FunctionComponent< accessor: "language", Cell: ({ value }) => { if (value) { - return <LanguageText text={value} long></LanguageText>; + return <Language.Text value={value} long></Language.Text>; } else { return null; } diff --git a/frontend/src/components/modals/ItemEditorModal.tsx b/frontend/src/components/modals/ItemEditorModal.tsx index 77bbf66f1..ad714598d 100644 --- a/frontend/src/components/modals/ItemEditorModal.tsx +++ b/frontend/src/components/modals/ItemEditorModal.tsx @@ -1,11 +1,11 @@ -import { useIsAnyActionRunning, useLanguageProfiles } from "apis/hooks"; -import React, { FunctionComponent, useEffect, useMemo, useState } from "react"; +import { useIsAnyActionRunning, useLanguageProfiles } from "@/apis/hooks"; +import { useModalControl, usePayload } from "@/modules/redux/hooks/modal"; +import { GetItemId } from "@/utilities"; +import { FunctionComponent, useEffect, useMemo, useState } from "react"; import { Container, Form } from "react-bootstrap"; import { UseMutationResult } from "react-query"; -import { GetItemId } from "utilities"; -import { AsyncButton, Selector } from "../"; +import { AsyncButton, Selector, SelectorOption } from ".."; import BaseModal, { BaseModalProps } from "./BaseModal"; -import { useModalInformation } from "./hooks"; interface Props { mutation: UseMutationResult<void, unknown, FormType.ModifyItem, unknown>; @@ -16,9 +16,8 @@ const Editor: FunctionComponent<Props & BaseModalProps> = (props) => { const { data: profiles } = useLanguageProfiles(); - const { payload, closeModal } = useModalInformation<Item.Base>( - modal.modalKey - ); + const payload = usePayload<Item.Base>(modal.modalKey); + const { hide } = useModalControl(); const { mutateAsync, isLoading } = mutation; @@ -57,7 +56,9 @@ const Editor: FunctionComponent<Props & BaseModalProps> = (props) => { return null; } }} - onSuccess={() => closeModal()} + onSuccess={() => { + hide(); + }} > Save </AsyncButton> diff --git a/frontend/src/components/modals/ManualSearchModal.tsx b/frontend/src/components/modals/ManualSearchModal.tsx index 853d93a49..49c95ea5c 100644 --- a/frontend/src/components/modals/ManualSearchModal.tsx +++ b/frontend/src/components/modals/ManualSearchModal.tsx @@ -1,3 +1,7 @@ +import { useEpisodesProvider, useMoviesProvider } from "@/apis/hooks"; +import { usePayload } from "@/modules/redux/hooks/modal"; +import { createAndDispatchTask } from "@/modules/task/utilities"; +import { isMovie } from "@/utilities"; import { faCaretDown, faCheck, @@ -6,15 +10,7 @@ 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, - useMemo, - useState, -} from "react"; +import { FunctionComponent, useCallback, useMemo, useState } from "react"; import { Badge, Button, @@ -26,16 +22,8 @@ import { Row, } from "react-bootstrap"; import { Column } from "react-table"; -import { GetItemId, isMovie } from "utilities"; -import { - BaseModal, - BaseModalProps, - LanguageText, - LoadingIndicator, - PageTable, - useModalPayload, -} from ".."; -import "./msmStyle.scss"; +import { BaseModal, BaseModalProps, LoadingIndicator, PageTable } from ".."; +import Language from "../bazarr/Language"; type SupportType = Item.Movie | Item.Episode; @@ -48,7 +36,7 @@ export function ManualSearchModal<T extends SupportType>( ) { const { download, ...modal } = props; - const item = useModalPayload<T>(modal.modalKey); + const item = usePayload<T>(modal.modalKey); const [episodeId, setEpisodeId] = useState<number | undefined>(undefined); const [radarrId, setRadarrId] = useState<number | undefined>(undefined); @@ -95,7 +83,7 @@ export function ManualSearchModal<T extends SupportType>( }; return ( <Badge variant="secondary"> - <LanguageText text={lang}></LanguageText> + <Language.Text value={lang}></Language.Text> </Badge> ); }, @@ -194,12 +182,12 @@ export function ManualSearchModal<T extends SupportType>( onClick={() => { if (!item) return; - const id = GetItemId(item); - const task = createTask(item.title, id, download, item, result); - dispatchTask( - "Downloading subtitles...", - [task], - "Downloading..." + createAndDispatchTask( + item.title, + "download-subtitles", + download, + item, + result ); }} > @@ -226,14 +214,14 @@ export function ManualSearchModal<T extends SupportType>( return <LoadingIndicator animation="grow"></LoadingIndicator>; } else { return ( - <React.Fragment> + <> <p className="mb-3 small">{item?.path ?? ""}</p> <PageTable emptyText="No Result" columns={columns} data={results} ></PageTable> - </React.Fragment> + </> ); } }; diff --git a/frontend/src/components/modals/MovieUploadModal.tsx b/frontend/src/components/modals/MovieUploadModal.tsx index a5e2705b1..3b3730668 100644 --- a/frontend/src/components/modals/MovieUploadModal.tsx +++ b/frontend/src/components/modals/MovieUploadModal.tsx @@ -1,32 +1,27 @@ -import { dispatchTask } from "@modules/task"; -import { createTask } from "@modules/task/utilities"; -import { useMovieSubtitleModification } from "apis/hooks"; -import React, { FunctionComponent, useCallback } from "react"; +import { useMovieSubtitleModification } from "@/apis/hooks"; +import { usePayload } from "@/modules/redux/hooks/modal"; +import { createTask, dispatchTask } from "@/modules/task/utilities"; import { useLanguageProfileBy, useProfileItemsToLanguages, -} from "utilities/languages"; +} from "@/utilities/languages"; +import { FunctionComponent, useCallback } from "react"; import { BaseModalProps } from "./BaseModal"; -import { useModalInformation } from "./hooks"; import SubtitleUploadModal, { PendingSubtitle, Validator, } from "./SubtitleUploadModal"; -interface Payload {} - -export const TaskGroupName = "Uploading Subtitles..."; - const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => { const modal = props; - const { payload } = useModalInformation<Item.Movie>(modal.modalKey); + const payload = usePayload<Item.Movie>(modal.modalKey); const profile = useLanguageProfileBy(payload?.profileId); const availableLanguages = useProfileItemsToLanguages(profile); - const update = useCallback(async (list: PendingSubtitle<Payload>[]) => { + const update = useCallback(async (list: PendingSubtitle<unknown>[]) => { return list; }, []); @@ -34,7 +29,7 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => { upload: { mutateAsync }, } = useMovieSubtitleModification(); - const validate = useCallback<Validator<Payload>>( + const validate = useCallback<Validator<unknown>>( (item) => { if (item.language === null) { return { @@ -59,7 +54,7 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => { ); const upload = useCallback( - (items: PendingSubtitle<Payload>[]) => { + (items: PendingSubtitle<unknown>[]) => { if (payload === null) { return; } @@ -71,18 +66,22 @@ const MovieUploadModal: FunctionComponent<BaseModalProps> = (props) => { .map((v) => { const { file, language, forced, hi } = v; - return createTask(file.name, radarrId, mutateAsync, { + if (language === null) { + throw new Error("Language is not selected"); + } + + return createTask(file.name, mutateAsync, { radarrId, form: { file, forced, hi, - language: language!.code2, + language: language.code2, }, }); }); - dispatchTask(TaskGroupName, tasks, "Uploading..."); + dispatchTask(tasks, "upload-subtitles"); }, [mutateAsync, payload] ); diff --git a/frontend/src/components/modals/SeriesUploadModal.tsx b/frontend/src/components/modals/SeriesUploadModal.tsx index d7c6d359c..23c3101f3 100644 --- a/frontend/src/components/modals/SeriesUploadModal.tsx +++ b/frontend/src/components/modals/SeriesUploadModal.tsx @@ -1,18 +1,18 @@ -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 { useEpisodeSubtitleModification } from "@/apis/hooks"; +import api from "@/apis/raw"; +import { usePayload } from "@/modules/redux/hooks/modal"; +import { createTask, dispatchTask } from "@/modules/task/utilities"; import { useLanguageProfileBy, useProfileItemsToLanguages, -} from "utilities/languages"; -import { Selector } from "../inputs"; +} from "@/utilities/languages"; +import { FunctionComponent, useCallback, useMemo } from "react"; +import { Column } from "react-table"; +import { Selector, SelectorOption } from "../inputs"; import { BaseModalProps } from "./BaseModal"; -import { useModalInformation } from "./hooks"; import SubtitleUploadModal, { PendingSubtitle, + useRowMutation, Validator, } from "./SubtitleUploadModal"; @@ -24,13 +24,11 @@ interface SeriesProps { episodes: readonly Item.Episode[]; } -export const TaskGroupName = "Uploading Subtitles..."; - const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({ episodes, ...modal }) => { - const { payload } = useModalInformation<Item.Series>(modal.modalKey); + const payload = usePayload<Item.Series>(modal.modalKey); const profile = useLanguageProfileBy(payload?.profileId); @@ -98,9 +96,19 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({ 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 { + hi, + forced, + payload: { instance }, + language, + } = v; + + if (language === null || instance === null) { + throw new Error("Invalid state"); + } + + const { code2 } = language; + const { sonarrEpisodeId: episodeId } = instance; const form: FormType.UploadSubtitle = { file: v.file, @@ -109,14 +117,14 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({ forced: forced, }; - return createTask(v.file.name, episodeId, mutateAsync, { + return createTask(v.file.name, mutateAsync, { seriesId, episodeId, form, }); }); - dispatchTask(TaskGroupName, tasks, "Uploading subtitles..."); + dispatchTask(tasks, "upload-subtitles"); }, [mutateAsync, payload] ); @@ -128,29 +136,26 @@ const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({ Header: "Episode", accessor: "payload", className: "vw-1", - Cell: ({ value, row, update }) => { + Cell: ({ value, row }) => { const options = episodes.map<SelectorOption<Item.Episode>>((ep) => ({ label: `(${ep.season}x${ep.episode}) ${ep.title}`, value: ep, })); - const change = useCallback( - (ep: Nullable<Item.Episode>) => { - if (ep) { - const newInfo = { ...row.original }; - newInfo.payload.instance = ep; - update && update(row, newInfo); - } - }, - [row, update] - ); + const mutate = useRowMutation(); return ( <Selector disabled={row.original.state === "fetching"} options={options} value={value.instance} - onChange={change} + onChange={(ep: Nullable<Item.Episode>) => { + if (ep) { + const newInfo = { ...row.original }; + newInfo.payload.instance = ep; + mutate(row.index, newInfo); + } + }} ></Selector> ); }, diff --git a/frontend/src/components/modals/SubtitleToolModal.tsx b/frontend/src/components/modals/SubtitleToolModal.tsx index f8891ecff..b15444879 100644 --- a/frontend/src/components/modals/SubtitleToolModal.tsx +++ b/frontend/src/components/modals/SubtitleToolModal.tsx @@ -1,3 +1,9 @@ +import { useSubtitleAction } from "@/apis/hooks"; +import { useModalControl, usePayload } from "@/modules/redux/hooks/modal"; +import { createTask, dispatchTask } from "@/modules/task/utilities"; +import { isMovie, submodProcessColor } from "@/utilities"; +import { LOG } from "@/utilities/console"; +import { useEnabledLanguages } from "@/utilities/languages"; import { faClock, faCode, @@ -14,10 +20,8 @@ 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, { +import { + ChangeEventHandler, FunctionComponent, useCallback, useMemo, @@ -32,22 +36,16 @@ 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, LanguageSelector, - LanguageText, Selector, SimpleTable, - useModalPayload, - useShowModal, } from ".."; +import Language from "../bazarr/Language"; import { useCustomSelection } from "../tables/plugins"; import BaseModal, { BaseModalProps } from "./BaseModal"; -import { useCloseModal } from "./hooks"; import { availableTranslation, colorOptions } from "./toolOptions"; type SupportType = Item.Episode | Item.Movie; @@ -119,18 +117,15 @@ const FrameRateModal: FunctionComponent<BaseModalProps & ToolModalProps> = ( const submit = useCallback(() => { if (canSave) { - const action = submodProcessFrameRate(from!, to!); + const action = submodProcessFrameRate(from, to); process(action); } }, [canSave, from, to, process]); - const footer = useMemo( - () => ( - <Button disabled={!canSave} onClick={submit}> - Save - </Button> - ), - [submit, canSave] + const footer = ( + <Button disabled={!canSave} onClick={submit}> + Save + </Button> ); return ( @@ -176,8 +171,8 @@ const AdjustTimesModal: FunctionComponent<BaseModalProps & ToolModalProps> = ( ]); const updateOffset = useCallback( - (idx: number) => { - return (e: any) => { + (idx: number): ChangeEventHandler<HTMLInputElement> => { + return (e) => { let value = parseFloat(e.currentTarget.value); if (isNaN(value)) { value = 0; @@ -293,24 +288,22 @@ const TranslateModal: FunctionComponent<BaseModalProps & ToolModalProps> = ({ ); }; -const TaskGroupName = "Modifying Subtitles"; - const CanSelectSubtitle = (item: TableColumnType) => { return item.path.endsWith(".srt"); }; const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => { - const payload = useModalPayload<SupportType[]>(props.modalKey); + const payload = usePayload<SupportType[]>(props.modalKey); const [selections, setSelections] = useState<TableColumnType[]>([]); - const closeModal = useCloseModal(); + const { hide } = useModalControl(); const { mutateAsync } = useSubtitleAction(); const process = useCallback( (action: string, override?: Partial<FormType.ModifySubtitle>) => { - log("info", "executing action", action); - closeModal(props.modalKey); + LOG("info", "executing action", action); + hide(props.modalKey); const tasks = selections.map((s) => { const form: FormType.ModifySubtitle = { @@ -320,15 +313,15 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => { path: s.path, ...override, }; - return createTask(s.path, s.id, mutateAsync, { action, form }); + return createTask(s.path, mutateAsync, { action, form }); }); - dispatchTask(TaskGroupName, tasks, "Modifying subtitles..."); + dispatchTask(tasks, "modify-subtitles"); }, - [closeModal, props.modalKey, selections, mutateAsync] + [hide, props.modalKey, selections, mutateAsync] ); - const showModal = useShowModal(); + const { show } = useModalControl(); const columns: Column<TableColumnType>[] = useMemo<Column<TableColumnType>[]>( () => [ @@ -337,7 +330,7 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => { accessor: "_language", Cell: ({ value }) => ( <Badge variant="secondary"> - <LanguageText text={value} long></LanguageText> + <Language.Text value={value} long></Language.Text> </Badge> ), }, @@ -345,8 +338,8 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => { id: "file", Header: "File", accessor: "path", - Cell: (row) => { - const path = row.value!; + Cell: ({ value }) => { + const path = value; let idx = path.lastIndexOf("/"); @@ -431,29 +424,28 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => { Reverse RTL </ActionButtonItem> </Dropdown.Item> - <Dropdown.Item onSelect={() => showModal("add-color")}> + <Dropdown.Item onSelect={() => show("add-color")}> <ActionButtonItem icon={faPaintBrush}>Add Color</ActionButtonItem> </Dropdown.Item> - <Dropdown.Item onSelect={() => showModal("change-frame-rate")}> + <Dropdown.Item onSelect={() => show("change-frame-rate")}> <ActionButtonItem icon={faFilm}>Change Frame Rate</ActionButtonItem> </Dropdown.Item> - <Dropdown.Item onSelect={() => showModal("adjust-times")}> + <Dropdown.Item onSelect={() => show("adjust-times")}> <ActionButtonItem icon={faClock}>Adjust Times</ActionButtonItem> </Dropdown.Item> - <Dropdown.Item onSelect={() => showModal("translate-sub")}> + <Dropdown.Item onSelect={() => show("translate-sub")}> <ActionButtonItem icon={faLanguage}>Translate</ActionButtonItem> </Dropdown.Item> </Dropdown.Menu> </Dropdown> ), - [showModal, selections.length, process] + [selections.length, process, show] ); return ( - <React.Fragment> + <> <BaseModal title={"Subtitle Tools"} footer={footer} {...props}> <SimpleTable - isSelecting={data.length !== 0} emptyText="No External Subtitles Found" plugins={plugins} columns={columns} @@ -475,7 +467,7 @@ const STM: FunctionComponent<BaseModalProps> = ({ ...props }) => { process={process} modalKey="translate-sub" ></TranslateModal> - </React.Fragment> + </> ); }; diff --git a/frontend/src/components/modals/SubtitleUploadModal.tsx b/frontend/src/components/modals/SubtitleUploadModal.tsx index b5cb11b9d..c77bd980b 100644 --- a/frontend/src/components/modals/SubtitleUploadModal.tsx +++ b/frontend/src/components/modals/SubtitleUploadModal.tsx @@ -1,3 +1,6 @@ +import { useModalControl } from "@/modules/redux/hooks/modal"; +import { BuildKey } from "@/utilities"; +import { LOG } from "@/utilities/console"; import { faCheck, faCircleNotch, @@ -6,15 +9,31 @@ import { faTrash, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Button, Container, Form } from "react-bootstrap"; -import { Column, TableUpdater } from "react-table"; -import { BuildKey } from "utilities"; +import { Column } from "react-table"; import { LanguageSelector, MessageIcon } from ".."; import { FileForm } from "../inputs"; import { SimpleTable } from "../tables"; import BaseModal, { BaseModalProps } from "./BaseModal"; -import { useCloseModal } from "./hooks"; + +type ModifyFn<T> = (index: number, info?: PendingSubtitle<T>) => void; + +const RowContext = createContext<ModifyFn<unknown>>(() => { + LOG("error", "RowContext not initialized"); +}); + +export function useRowMutation() { + return useContext(RowContext); +} export interface PendingSubtitle<P> { file: File; @@ -30,7 +49,7 @@ export type Validator<T> = ( item: PendingSubtitle<T> ) => Pick<PendingSubtitle<T>, "state" | "messages">; -interface Props<T> { +interface Props<T = unknown> { initial: T; availableLanguages: Language.Info[]; upload: (items: PendingSubtitle<T>[]) => void; @@ -40,9 +59,10 @@ interface Props<T> { hideAllLanguages?: boolean; } -export default function SubtitleUploadModal<T>( - props: Props<T> & Omit<BaseModalProps, "footer" | "title" | "size"> -) { +type ComponentProps<T> = Props<T> & + Omit<BaseModalProps, "footer" | "title" | "size">; + +function SubtitleUploadModal<T>(props: ComponentProps<T>) { const { initial, columns, @@ -53,7 +73,7 @@ export default function SubtitleUploadModal<T>( hideAllLanguages, } = props; - const closeModal = useCloseModal(); + const { hide } = useModalControl(); const [pending, setPending] = useState<PendingSubtitle<T>[]>([]); @@ -72,7 +92,7 @@ export default function SubtitleUploadModal<T>( language: initialLanguage, forced: false, hi: false, - payload: { ...initialRef.current }, + payload: initialRef.current, })); if (update) { @@ -95,15 +115,15 @@ export default function SubtitleUploadModal<T>( [update, validate, availableLanguages] ); - const modify = useCallback<TableUpdater<PendingSubtitle<T>>>( - (row, info?: PendingSubtitle<T>) => { + const modify = useCallback( + (index: number, info?: PendingSubtitle<T>) => { setPending((pd) => { const newPending = [...pd]; if (info) { info = { ...info, ...validate(info) }; - newPending[row.index] = info; + newPending[index] = info; } else { - newPending.splice(row.index, 1); + newPending.splice(index, 1); } return newPending; }); @@ -174,8 +194,9 @@ export default function SubtitleUploadModal<T>( id: "hi", Header: "HI", accessor: "hi", - Cell: ({ row, value, update }) => { + Cell: ({ row, value }) => { const { original, index } = row; + const mutate = useRowMutation(); return ( <Form.Check custom @@ -185,7 +206,7 @@ export default function SubtitleUploadModal<T>( onChange={(v) => { const newInfo = { ...row.original }; newInfo.hi = v.target.checked; - update && update(row, newInfo); + mutate(row.index, newInfo); }} ></Form.Check> ); @@ -195,8 +216,9 @@ export default function SubtitleUploadModal<T>( id: "forced", Header: "Forced", accessor: "forced", - Cell: ({ row, value, update }) => { + Cell: ({ row, value }) => { const { original, index } = row; + const mutate = useRowMutation(); return ( <Form.Check custom @@ -206,7 +228,7 @@ export default function SubtitleUploadModal<T>( onChange={(v) => { const newInfo = { ...row.original }; newInfo.forced = v.target.checked; - update && update(row, newInfo); + mutate(row.index, newInfo); }} ></Form.Check> ); @@ -217,17 +239,18 @@ export default function SubtitleUploadModal<T>( Header: "Language", accessor: "language", className: "w-25", - Cell: ({ row, update, value }) => { + Cell: ({ row, value }) => { + const mutate = useRowMutation(); return ( <LanguageSelector disabled={row.original.state === "fetching"} options={availableLanguages} value={value} onChange={(lang) => { - if (lang && update) { + if (lang) { const newInfo = { ...row.original }; newInfo.language = lang; - update(row, newInfo); + mutate(row.index, newInfo); } }} ></LanguageSelector> @@ -238,18 +261,21 @@ export default function SubtitleUploadModal<T>( { id: "action", accessor: "file", - Cell: ({ row, update }) => ( - <Button - size="sm" - variant="light" - disabled={row.original.state === "fetching"} - onClick={() => { - update && update(row); - }} - > - <FontAwesomeIcon icon={faTrash}></FontAwesomeIcon> - </Button> - ), + Cell: ({ row }) => { + const mutate = useRowMutation(); + return ( + <Button + size="sm" + variant="light" + disabled={row.original.state === "fetching"} + onClick={() => { + mutate(row.index); + }} + > + <FontAwesomeIcon icon={faTrash}></FontAwesomeIcon> + </Button> + ); + }, }, ], [columns, availableLanguages] @@ -280,7 +306,7 @@ export default function SubtitleUploadModal<T>( onClick={() => { upload(pending); setFiles([]); - closeModal(); + hide(); }} > Upload @@ -325,14 +351,17 @@ export default function SubtitleUploadModal<T>( </Form.Group> </Form> <div hidden={!showTable}> - <SimpleTable - columns={columnsWithAction} - data={pending} - responsive={false} - update={modify} - ></SimpleTable> + <RowContext.Provider value={modify as ModifyFn<unknown>}> + <SimpleTable + columns={columnsWithAction} + data={pending} + responsive={false} + ></SimpleTable> + </RowContext.Provider> </div> </Container> </BaseModal> ); } + +export default SubtitleUploadModal; diff --git a/frontend/src/components/modals/hooks.tsx b/frontend/src/components/modals/hooks.tsx deleted file mode 100644 index 2b9b4c136..000000000 --- a/frontend/src/components/modals/hooks.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useCallback, useContext, useMemo } from "react"; -import { useDidUpdate } from "rooks"; -import { log } from "utilities/logger"; -import { ModalContext } from "./provider"; - -interface ModalInformation<T> { - isShow: boolean; - payload: T | null; - closeModal: ReturnType<typeof useCloseModal>; -} - -export function useModalInformation<T>(key: string): ModalInformation<T> { - const isShow = useIsModalShow(key); - const payload = useModalPayload<T>(key); - const closeModal = useCloseModal(); - - return useMemo( - () => ({ - isShow, - payload, - closeModal, - }), - [isShow, payload, closeModal] - ); -} - -export function useShowModal() { - const { - control: { push }, - } = useContext(ModalContext); - - return useCallback( - <T,>(key: string, payload?: T) => { - log("info", `modal ${key} sending payload`, payload); - - push({ key, payload }); - }, - [push] - ); -} - -export function useCloseModal() { - const { - control: { pop }, - } = useContext(ModalContext); - return useCallback( - (key?: string) => { - pop(key); - }, - [pop] - ); -} - -export function useIsModalShow(key: string) { - const { - control: { peek }, - } = useContext(ModalContext); - const modal = peek(); - return key === modal?.key; -} - -export function useOnModalShow<T>( - callback: (payload: T | null) => void, - key: string -) { - const { - modals, - control: { peek }, - } = useContext(ModalContext); - useDidUpdate(() => { - const modal = peek(); - if (modal && modal.key === key) { - callback(modal.payload ?? null); - } - }, [modals.length, key]); -} - -export function useModalPayload<T>(key: string): T | null { - const { - control: { peek }, - } = useContext(ModalContext); - return useMemo(() => { - const modal = peek(); - if (modal && modal.key === key) { - return (modal.payload as T) ?? null; - } else { - return null; - } - }, [key, peek]); -} diff --git a/frontend/src/components/modals/index.ts b/frontend/src/components/modals/index.ts index 3662bfe5a..5f02dc94e 100644 --- a/frontend/src/components/modals/index.ts +++ b/frontend/src/components/modals/index.ts @@ -1,8 +1,6 @@ export * from "./BaseModal"; export * from "./HistoryModal"; -export * from "./hooks"; export { default as ItemEditorModal } from "./ItemEditorModal"; export { default as MovieUploadModal } from "./MovieUploadModal"; -export { default as ModalProvider } from "./provider"; export { default as SeriesUploadModal } from "./SeriesUploadModal"; export { default as SubtitleToolModal } from "./SubtitleToolModal"; diff --git a/frontend/src/components/modals/msmStyle.scss b/frontend/src/components/modals/msmStyle.scss deleted file mode 100644 index 91c35b3d7..000000000 --- a/frontend/src/components/modals/msmStyle.scss +++ /dev/null @@ -1,20 +0,0 @@ -.release-container { - flex-wrap: nowrap; - overflow: hidden; - .text-container { - max-width: 500px; - .release-text { - text-overflow: ellipsis; - overflow-wrap: break-word; - word-wrap: break-word; - white-space: pre-wrap; - - &.hidden-item { - color: gray; - } - } - } - &.release-multi { - cursor: zoom-in; - } -} diff --git a/frontend/src/components/modals/provider.tsx b/frontend/src/components/modals/provider.tsx deleted file mode 100644 index 0681537da..000000000 --- a/frontend/src/components/modals/provider.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { - FunctionComponent, - useCallback, - useMemo, - useState, -} from "react"; - -interface Modal { - key: string; - payload: any; -} - -interface ModalControl { - push: (modal: Modal) => void; - peek: () => Modal | undefined; - pop: (key: string | undefined) => void; -} - -interface ModalContextType { - modals: Modal[]; - control: ModalControl; -} - -export const ModalContext = React.createContext<ModalContextType>({ - modals: [], - control: { - push: () => { - throw new Error("Unimplemented"); - }, - pop: () => { - throw new Error("Unimplemented"); - }, - peek: () => { - throw new Error("Unimplemented"); - }, - }, -}); - -const ModalProvider: FunctionComponent = ({ children }) => { - const [stack, setStack] = useState<Modal[]>([]); - - const push = useCallback<ModalControl["push"]>((model) => { - setStack((old) => { - return [...old, model]; - }); - }, []); - - const pop = useCallback<ModalControl["pop"]>((key) => { - setStack((old) => { - if (old.length === 0) { - return []; - } - - if (key === undefined) { - const newOld = old; - newOld.pop(); - return newOld; - } - - // find key - const index = old.findIndex((v) => v.key === key); - if (index !== -1) { - return old.slice(0, index); - } else { - return old; - } - }); - }, []); - - const peek = useCallback<ModalControl["peek"]>(() => { - return stack.length > 0 ? stack[stack.length - 1] : undefined; - }, [stack]); - - const context = useMemo<ModalContextType>( - () => ({ modals: stack, control: { push, pop, peek } }), - [stack, push, pop, peek] - ); - - return ( - <ModalContext.Provider value={context}>{children}</ModalContext.Provider> - ); -}; - -export default ModalProvider; diff --git a/frontend/src/components/modals/toolOptions.ts b/frontend/src/components/modals/toolOptions.ts index 5639cd4d0..6acee1a6a 100644 --- a/frontend/src/components/modals/toolOptions.ts +++ b/frontend/src/components/modals/toolOptions.ts @@ -1,3 +1,5 @@ +import { SelectorOption } from ".."; + export const availableTranslation = { af: "afrikaans", sq: "albanian", diff --git a/frontend/src/components/tables/BaseTable.tsx b/frontend/src/components/tables/BaseTable.tsx index b01109114..e2203128a 100644 --- a/frontend/src/components/tables/BaseTable.tsx +++ b/frontend/src/components/tables/BaseTable.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import { useMemo } from "react"; import { Table } from "react-bootstrap"; import { HeaderGroup, diff --git a/frontend/src/components/tables/GroupTable.tsx b/frontend/src/components/tables/GroupTable.tsx index 62d640252..6940b95a1 100644 --- a/frontend/src/components/tables/GroupTable.tsx +++ b/frontend/src/components/tables/GroupTable.tsx @@ -1,6 +1,5 @@ import { faChevronCircleRight } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React from "react"; import { Cell, HeaderGroup, @@ -13,7 +12,7 @@ import { import { TableStyleProps } from "./BaseTable"; import SimpleTable from "./SimpleTable"; -function renderCell<T extends object = {}>(cell: Cell<T, any>, row: Row<T>) { +function renderCell<T extends object = object>(cell: Cell<T>, row: Row<T>) { if (cell.isGrouped) { return ( <span {...row.getToggleRowExpandedProps()}>{cell.render("Cell")}</span> @@ -79,7 +78,7 @@ function renderHeaders<T extends object>( type Props<T extends object> = TableOptions<T> & TableStyleProps<T>; -function GroupTable<T extends object = {}>(props: Props<T>) { +function GroupTable<T extends object = object>(props: Props<T>) { const plugins = [useGroupBy, useSortBy, useExpanded]; return ( <SimpleTable diff --git a/frontend/src/components/tables/PageControl.tsx b/frontend/src/components/tables/PageControl.tsx index 1680c5d39..3408341a2 100644 --- a/frontend/src/components/tables/PageControl.tsx +++ b/frontend/src/components/tables/PageControl.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, useMemo } from "react"; +import { FunctionComponent, useMemo } from "react"; import { Col, Container, Pagination, Row } from "react-bootstrap"; import { PageControlAction } from "./types"; interface Props { diff --git a/frontend/src/components/tables/PageTable.tsx b/frontend/src/components/tables/PageTable.tsx index f7dfd018c..30a18fff8 100644 --- a/frontend/src/components/tables/PageTable.tsx +++ b/frontend/src/components/tables/PageTable.tsx @@ -1,33 +1,22 @@ -import React, { useEffect } from "react"; -import { - PluginHook, - TableOptions, - usePagination, - useRowSelect, - useTable, -} from "react-table"; -import { ScrollToTop } from "utilities"; +import { ScrollToTop } from "@/utilities"; +import { useEffect } from "react"; +import { PluginHook, TableOptions, usePagination, useTable } from "react-table"; import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable"; import PageControl from "./PageControl"; -import { useCustomSelection, useDefaultSettings } from "./plugins"; +import { useDefaultSettings } from "./plugins"; type Props<T extends object> = TableOptions<T> & TableStyleProps<T> & { - canSelect?: boolean; autoScroll?: boolean; plugins?: PluginHook<T>[]; }; export default function PageTable<T extends object>(props: Props<T>) { - const { autoScroll, canSelect, plugins, ...remain } = props; + const { autoScroll, plugins, ...remain } = props; const { style, options } = useStyleAndOptions(remain); const allPlugins: PluginHook<T>[] = [useDefaultSettings, usePagination]; - if (canSelect) { - allPlugins.push(useRowSelect, useCustomSelection); - } - if (plugins) { allPlugins.push(...plugins); } @@ -60,7 +49,7 @@ export default function PageTable<T extends object>(props: Props<T>) { }, [pageIndex, autoScroll]); return ( - <React.Fragment> + <> <BaseTable {...style} headers={headerGroups} @@ -80,6 +69,6 @@ export default function PageTable<T extends object>(props: Props<T>) { next={nextPage} goto={gotoPage} ></PageControl> - </React.Fragment> + </> ); } diff --git a/frontend/src/components/tables/QueryPageTable.tsx b/frontend/src/components/tables/QueryPageTable.tsx index 444e4d40f..0ca26dd3b 100644 --- a/frontend/src/components/tables/QueryPageTable.tsx +++ b/frontend/src/components/tables/QueryPageTable.tsx @@ -1,7 +1,7 @@ -import { UsePaginationQueryResult } from "apis/queries/hooks"; -import React, { useEffect } from "react"; +import { UsePaginationQueryResult } from "@/apis/queries/hooks"; +import { ScrollToTop } from "@/utilities"; +import { 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"; @@ -52,7 +52,7 @@ export default function QueryPageTable<T extends object>(props: Props<T>) { } return ( - <React.Fragment> + <> <BaseTable {...style} headers={headerGroups} @@ -72,6 +72,6 @@ export default function QueryPageTable<T extends object>(props: Props<T>) { next={nextPage} goto={gotoPage} ></PageControl> - </React.Fragment> + </> ); } diff --git a/frontend/src/components/tables/SimpleTable.tsx b/frontend/src/components/tables/SimpleTable.tsx index 4723e9c21..70b57381c 100644 --- a/frontend/src/components/tables/SimpleTable.tsx +++ b/frontend/src/components/tables/SimpleTable.tsx @@ -13,13 +13,8 @@ export default function SimpleTable<T extends object>(props: Props<T>) { const instance = useTable(options, useDefaultSettings, ...(plugins ?? [])); - const { - getTableProps, - getTableBodyProps, - headerGroups, - rows, - prepareRow, - } = instance; + const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = + instance; return ( <BaseTable diff --git a/frontend/src/components/tables/plugins/useCustomSelection.tsx b/frontend/src/components/tables/plugins/useCustomSelection.tsx index 1f6ea2829..650c161fd 100644 --- a/frontend/src/components/tables/plugins/useCustomSelection.tsx +++ b/frontend/src/components/tables/plugins/useCustomSelection.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useEffect, useRef } from "react"; +import { forwardRef, useEffect, useRef } from "react"; import { Form } from "react-bootstrap"; import { CellProps, @@ -52,10 +52,6 @@ const Checkbox = forwardRef< }); function useCustomSelection<T extends object>(hooks: Hooks<T>) { - hooks.visibleColumnsDeps.push((deps, { instance }) => [ - ...deps, - instance.isSelecting, - ]); hooks.visibleColumns.push(visibleColumns); hooks.useInstance.push(useInstance); } @@ -68,7 +64,6 @@ function useInstance<T extends object>(instance: TableInstance<T>) { rows, onSelect, canSelect, - isSelecting, state: { selectedRowIds }, } = instance; @@ -76,18 +71,16 @@ function useInstance<T extends object>(instance: TableInstance<T>) { useEffect(() => { // Performance - if (isSelecting) { - let items = Object.keys(selectedRowIds).flatMap( - (v) => rows.find((n) => n.id === v)?.original ?? [] - ); - - if (canSelect) { - items = items.filter((v) => canSelect(v)); - } + let items = Object.keys(selectedRowIds).flatMap( + (v) => rows.find((n) => n.id === v)?.original ?? [] + ); - onSelect && onSelect(items); + if (canSelect) { + items = items.filter((v) => canSelect(v)); } - }, [selectedRowIds, onSelect, rows, isSelecting, canSelect]); + + onSelect && onSelect(items); + }, [selectedRowIds, onSelect, rows, canSelect]); } function visibleColumns<T extends object>( @@ -95,31 +88,27 @@ function visibleColumns<T extends object>( meta: MetaBase<T> ): Column<T>[] { const { instance } = meta; - if (instance.isSelecting) { - const checkbox: Column<T> = { - id: checkboxId, - Header: ({ getToggleAllRowsSelectedProps }: HeaderProps<any>) => ( + const checkbox: Column<T> = { + id: checkboxId, + Header: ({ getToggleAllRowsSelectedProps }: HeaderProps<T>) => ( + <Checkbox + idIn="table-header-selection" + {...getToggleAllRowsSelectedProps()} + ></Checkbox> + ), + Cell: ({ row }: CellProps<T>) => { + const canSelect = instance.canSelect; + const disabled = (canSelect && !canSelect(row.original)) ?? false; + return ( <Checkbox - idIn="table-header-selection" - {...getToggleAllRowsSelectedProps()} + idIn={`table-cell-${row.index}`} + disabled={disabled} + {...row.getToggleRowSelectedProps()} ></Checkbox> - ), - Cell: ({ row }: CellProps<any>) => { - const canSelect = instance.canSelect; - const disabled = (canSelect && !canSelect(row.original)) ?? false; - return ( - <Checkbox - idIn={`table-cell-${row.index}`} - disabled={disabled} - {...row.getToggleRowSelectedProps()} - ></Checkbox> - ); - }, - }; - return [checkbox, ...columns.filter((v) => v.selectHide !== true)]; - } else { - return columns; - } + ); + }, + }; + return [checkbox, ...columns]; } export default useCustomSelection; diff --git a/frontend/src/components/tables/plugins/useDefaultSettings.tsx b/frontend/src/components/tables/plugins/useDefaultSettings.tsx index 444ee2616..772dfb93a 100644 --- a/frontend/src/components/tables/plugins/useDefaultSettings.tsx +++ b/frontend/src/components/tables/plugins/useDefaultSettings.tsx @@ -1,5 +1,5 @@ +import { usePageSize } from "@/utilities/storage"; import { Hooks, TableOptions } from "react-table"; -import { usePageSize } from "utilities/storage"; const pluginName = "useLocalSettings"; diff --git a/frontend/src/components/views/HistoryView.tsx b/frontend/src/components/views/HistoryView.tsx index fb900218e..fa0bf625f 100644 --- a/frontend/src/components/views/HistoryView.tsx +++ b/frontend/src/components/views/HistoryView.tsx @@ -1,5 +1,4 @@ -import { UsePaginationQueryResult } from "apis/queries/hooks"; -import React from "react"; +import { UsePaginationQueryResult } from "@/apis/queries/hooks"; import { Container, Row } from "react-bootstrap"; import { Helmet } from "react-helmet"; import { Column } from "react-table"; diff --git a/frontend/src/components/views/ItemView.tsx b/frontend/src/components/views/ItemView.tsx index c1c0a2052..8c48e243d 100644 --- a/frontend/src/components/views/ItemView.tsx +++ b/frontend/src/components/views/ItemView.tsx @@ -1,69 +1,30 @@ -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 ".."; +import { UsePaginationQueryResult } from "@/apis/queries/hooks"; +import { TableStyleProps } from "@/components/tables/BaseTable"; +import { faList } from "@fortawesome/free-solid-svg-icons"; +import { Row } from "react-bootstrap"; +import { useNavigate } from "react-router-dom"; +import { Column, TableOptions } from "react-table"; +import { ContentHeader, QueryPageTable } 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 showModal = useShowModal(); - - const updateRow = useCallback<TableUpdater<T>>( - ({ original }, modalKey: string) => { - showModal(modalKey, original); - }, - [showModal] - ); +function ItemView<T extends Item.Base>({ query, columns }: Props<T>) { + const navigate = useNavigate(); const options: Partial<TableOptions<T> & TableStyleProps<T>> = { - emptyText: `No ${name} Found`, - update: updateRow, + emptyText: `No Items Found`, }; - const content = editMode ? ( - <ItemMassEditor - query={fullQuery} - columns={columns} - mutation={mutation} - onEnded={() => setEditMode(false)} - ></ItemMassEditor> - ) : ( + return ( <> <ContentHeader scroll={false}> <ContentHeader.Button disabled={query.paginationStatus.totalCount === 0} icon={faList} - onClick={() => setEditMode(true)} + onClick={() => navigate("edit")} > Mass Edit </ContentHeader.Button> @@ -75,134 +36,6 @@ function ItemView<T extends Item.Base>({ query={query} data={[]} ></QueryPageTable> - <ItemEditorModal modalKey="edit" mutation={mutation}></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> </> ); diff --git a/frontend/src/components/views/WantedView.tsx b/frontend/src/components/views/WantedView.tsx index ef0895066..93da3302a 100644 --- a/frontend/src/components/views/WantedView.tsx +++ b/frontend/src/components/views/WantedView.tsx @@ -1,9 +1,7 @@ +import { useIsAnyActionRunning } from "@/apis/hooks"; +import { UsePaginationQueryResult } from "@/apis/queries/hooks"; +import { createAndDispatchTask } from "@/modules/task/utilities"; 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"; @@ -16,8 +14,6 @@ interface Props<T extends Wanted.Base> { searchAll: () => Promise<void>; } -const TaskGroupName = "Searching wanted subtitles..."; - function WantedView<T extends Wanted.Base>({ name, columns, @@ -37,8 +33,7 @@ function WantedView<T extends Wanted.Base>({ <ContentHeader.Button disabled={hasTask || dataCount === 0} onClick={() => { - const task = createTask(name, undefined, searchAll); - dispatchTask(TaskGroupName, [task], "Searching..."); + createAndDispatchTask(name, "search-subtitles", searchAll); }} icon={faSearch} > |