diff --git a/frontend/src/components/LanguageSelector.tsx b/frontend/src/components/LanguageSelector.tsx index 17e17d8ac..f2466e1bc 100644 --- a/frontend/src/components/LanguageSelector.tsx +++ b/frontend/src/components/LanguageSelector.tsx @@ -7,7 +7,7 @@ interface Props { type RemovedSelectorProps = Omit< SelectorProps, - "label" | "placeholder" + "label" >; export type LanguageSelectorProps = Override< diff --git a/frontend/src/components/modals/MovieUploadModal.tsx b/frontend/src/components/modals/MovieUploadModal.tsx index a0557e7b7..85e852f93 100644 --- a/frontend/src/components/modals/MovieUploadModal.tsx +++ b/frontend/src/components/modals/MovieUploadModal.tsx @@ -1,27 +1,19 @@ -import { faTrash } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React, { - FunctionComponent, - useCallback, - useMemo, - useState, -} from "react"; -import { Button, Container, Form } from "react-bootstrap"; -import { Column, Row } from "react-table"; +import React, { FunctionComponent, useCallback, useMemo } from "react"; +import { Form } from "react-bootstrap"; +import { Column } from "react-table"; import { dispatchTask } from "../../@modules/task"; import { createTask } from "../../@modules/task/utilities"; import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks"; import { MoviesApi } from "../../apis"; import { BuildKey } from "../../utilities"; -import { FileForm } from "../inputs"; -import { LanguageSelector } from "../LanguageSelector"; -import { SimpleTable } from "../tables"; -import BaseModal, { BaseModalProps } from "./BaseModal"; +import { BaseModalProps } from "./BaseModal"; import { useModalInformation } from "./hooks"; +import SubtitleUploadModal, { + PendingSubtitle, + Validator, +} from "./SubtitleUploadModal"; -interface PendingSubtitle { - file: File; - language: Language.Info; +interface Payload { forced: boolean; } @@ -30,178 +22,114 @@ export const TaskGroupName = "Uploading Subtitles..."; const MovieUploadModal: FunctionComponent = (props) => { const modal = props; - const { payload, closeModal } = useModalInformation( - modal.modalKey - ); + const { payload } = useModalInformation(modal.modalKey); const profile = useProfileBy(payload?.profileId); const availableLanguages = useProfileItemsToLanguages(profile); - const [pending, setPending] = useState([]); - - const filelist = useMemo(() => pending.map((v) => v.file), [pending]); - - const setFiles = useCallback( - (files: File[]) => { - const list: PendingSubtitle[] = files.map((v) => ({ - file: v, - forced: availableLanguages[0].forced ?? false, - language: availableLanguages[0], - })); - setPending(list); + const update = useCallback(async (list: PendingSubtitle[]) => { + return list; + }, []); + + const validate = useCallback>( + (item) => { + if (item.language === null) { + return { + state: "error", + messages: ["Language is not selected"], + }; + } else if ( + payload?.subtitles.find((v) => v.code2 === item.language?.code2) !== + undefined + ) { + return { + state: "warning", + messages: ["Override existing subtitle"], + }; + } + return { + state: "valid", + messages: [], + }; }, - [availableLanguages] + [payload?.subtitles] ); - const upload = useCallback(() => { - if (payload === null || pending.length === 0) { - return; - } - - const { radarrId } = payload; - - const tasks = pending.map((v) => { - const { file, language, forced } = v; - - return createTask( - file.name, - radarrId, - MoviesApi.uploadSubtitles.bind(MoviesApi), - radarrId, - { - file: file, - forced, - hi: false, - language: language.code2, - } - ); - }); - - dispatchTask(TaskGroupName, tasks, "Uploading..."); - setFiles([]); - closeModal(); - }, [payload, closeModal, pending, setFiles]); - - const modify = useCallback( - (row: Row, info?: PendingSubtitle) => { - setPending((pd) => { - const newPending = [...pd]; - if (info) { - newPending[row.index] = info; - } else { - newPending.splice(row.index, 1); - } - return newPending; - }); + const upload = useCallback( + (items: PendingSubtitle[]) => { + if (payload === null) { + return; + } + + const { radarrId } = payload; + + const tasks = items + .filter((v) => v.language !== null) + .map((v) => { + const { + file, + language, + payload: { forced }, + } = v; + + return createTask( + file.name, + radarrId, + MoviesApi.uploadSubtitles.bind(MoviesApi), + radarrId, + { + file: file, + forced, + hi: false, + language: language!.code2, + } + ); + }); + + dispatchTask(TaskGroupName, tasks, "Uploading..."); }, - [] + [payload] ); - const columns = useMemo[]>( + const columns = useMemo>[]>( () => [ { - id: "state", - Cell: () => { - return "hello"; - }, - }, - { - id: "name", - Header: "File", - accessor: (d) => d.file.name, - }, - { + id: "forced", Header: "Forced", - accessor: "forced", + accessor: "payload", Cell: ({ row, value, update }) => { const { original, index } = row; return ( { const newInfo = { ...row.original }; - newInfo.forced = v.target.checked; + newInfo.payload.forced = v.target.checked; update && update(row, newInfo); }} > ); }, }, - { - Header: "Language", - accessor: "language", - className: "w-25", - Cell: ({ row, update, value }) => { - return ( - { - if (lang && update) { - const newInfo = { ...row.original }; - newInfo.language = lang; - update(row, newInfo); - } - }} - > - ); - }, - }, - { - accessor: "file", - Cell: ({ row, update }) => { - return ( - - ); - }, - }, ], - [availableLanguages] - ); - - const canUpload = pending.length > 0; - - const footer = ( - + [] ); return ( - - -
- - - -
- -
-
+ ); }; diff --git a/frontend/src/components/modals/SeriesUploadModal.tsx b/frontend/src/components/modals/SeriesUploadModal.tsx index e91e7fb04..dd5a9040e 100644 --- a/frontend/src/components/modals/SeriesUploadModal.tsx +++ b/frontend/src/components/modals/SeriesUploadModal.tsx @@ -1,213 +1,126 @@ -import { - faCheck, - faCircleNotch, - faInfoCircle, - faTrash, -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import React, { - FunctionComponent, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; -import { Button, Container, Form } from "react-bootstrap"; -import { Column, TableUpdater } from "react-table"; -import { FileForm, LanguageSelector, MessageIcon, SimpleTable } from ".."; +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 { Selector } from "../inputs"; -import BaseModal, { BaseModalProps } from "./BaseModal"; +import { BaseModalProps } from "./BaseModal"; import { useModalInformation } from "./hooks"; +import SubtitleUploadModal, { + PendingSubtitle, + Validator, +} from "./SubtitleUploadModal"; -interface PendingSubtitle { - file: File; - fetching: boolean; - instance?: Item.Episode; +interface Payload { + instance: Item.Episode | null; } -type EpisodeMap = { - [name: string]: Item.Episode; -}; - -interface SerieProps { +interface SeriesProps { episodes: readonly Item.Episode[]; } export const TaskGroupName = "Uploading Subtitles..."; -const SeriesUploadModal: FunctionComponent = ({ +const SeriesUploadModal: FunctionComponent = ({ episodes, ...modal }) => { - const { payload, closeModal } = useModalInformation( - modal.modalKey - ); - - const [pending, setPending] = useState([]); + const { payload } = useModalInformation(modal.modalKey); const profile = useProfileBy(payload?.profileId); - const avaliableLanguages = useProfileItemsToLanguages(profile); - - const [language, setLanguage] = useState(null); - - useEffect(() => { - if (avaliableLanguages.length > 0) { - setLanguage(avaliableLanguages[0]); - } - }, [avaliableLanguages]); - - const filelist = useMemo(() => pending.map((v) => v.file), [pending]); + const availableLanguages = useProfileItemsToLanguages(profile); - const checkEpisodes = useCallback( - async (list: PendingSubtitle[]) => { + const update = useCallback( + async (list: PendingSubtitle[]) => { + const newList = [...list]; const names = list.map((v) => v.file.name); if (names.length > 0) { const results = await SubtitlesApi.info(names); - const episodeMap = results.reduce((prev, curr) => { - const ep = episodes.find( - (v) => v.season === curr.season && v.episode === curr.episode - ); - if (ep) { - prev[curr.filename] = ep; + // TODO: Optimization + newList.forEach((v) => { + const info = results.find((f) => f.filename === v.file.name); + if (info) { + v.payload.instance = + episodes.find( + (e) => e.season === info.season && e.episode === info.episode + ) ?? null; } - return prev; - }, {}); - - setPending((pd) => - pd.map((v) => { - const instance = episodeMap[v.file.name]; - return { - ...v, - instance, - fetching: false, - }; - }) - ); + }); } - }, - [episodes] - ); - const setFiles = useCallback( - (files: File[]) => { - // At lease 1 language is required - const list: PendingSubtitle[] = files.map((f) => { - return { - file: f, - didCheck: false, - fetching: true, - }; - }); - setPending(list); - checkEpisodes(list); + return newList; }, - [checkEpisodes] + [episodes] ); - const upload = useCallback(() => { - if (payload === null || language === null) { - return; + const validate = useCallback>((item) => { + const { language } = item; + const { instance } = item.payload; + if (language === null || instance === null) { + return { + state: "error", + messages: ["Language or Episode is not selected"], + }; + } else if ( + instance.subtitles.find((v) => v.code2 === language.code2) !== undefined + ) { + return { + state: "warning", + messages: ["Override existing subtitle"], + }; } + return { + state: "valid", + messages: [], + }; + }, []); + + const upload = useCallback( + (items: PendingSubtitle[]) => { + if (payload === null) { + return; + } - const { sonarrSeriesId: seriesid } = payload; - const { code2, hi, forced } = language; - - const tasks = pending - .filter((v) => v.instance !== undefined) - .map((v) => { - const { sonarrEpisodeId: episodeid } = v.instance!; - - const form: FormType.UploadSubtitle = { - file: v.file, - language: code2, - hi: hi ?? false, - forced: forced ?? false, - }; - - return createTask( - v.file.name, - episodeid, - EpisodesApi.uploadSubtitles.bind(EpisodesApi), - seriesid, - episodeid, - form - ); - }); - - dispatchTask(TaskGroupName, tasks, "Uploading subtitles..."); - setFiles([]); - closeModal(); - }, [payload, pending, language, closeModal, setFiles]); + const { sonarrSeriesId: seriesid } = payload; + + const tasks = items + .filter((v) => v.payload.instance !== undefined) + .map((v) => { + const { code2, hi, forced } = v.language!; + const { sonarrEpisodeId: episodeid } = v.payload.instance!; + + const form: FormType.UploadSubtitle = { + file: v.file, + language: code2, + hi: hi ?? false, + forced: forced ?? false, + }; + + return createTask( + v.file.name, + episodeid, + EpisodesApi.uploadSubtitles.bind(EpisodesApi), + seriesid, + episodeid, + form + ); + }); - const canUpload = useMemo( - () => - pending.length > 0 && - pending.every((v) => v.instance !== undefined) && - language, - [pending, language] + dispatchTask(TaskGroupName, tasks, "Uploading subtitles..."); + }, + [payload] ); - const showTable = pending.length > 0; - - const columns = useMemo[]>( + const columns = useMemo>[]>( () => [ { - id: "Icon", - accessor: "fetching", - className: "text-center", - Cell: ({ value: fetching, row: { original } }) => { - let icon = faCircleNotch; - let color: string | undefined = undefined; - let spin = false; - let msgs: string[] = []; - - const override = useMemo( - () => - original.instance?.subtitles.find( - (v) => v.code2 === language?.code2 - ) !== undefined, - [original.instance?.subtitles] - ); - - if (fetching) { - spin = true; - } else if (override) { - icon = faInfoCircle; - color = "var(--warning)"; - msgs.push("Overwrite existing subtitle"); - } else if (original.instance) { - icon = faCheck; - color = "var(--success)"; - } else { - icon = faInfoCircle; - color = "var(--warning)"; - msgs.push("Season or episode info is missing"); - } - - return ( - - ); - }, - }, - { - Header: "File", - accessor: (d) => d.file.name, - }, - { + id: "instance", Header: "Episode", - accessor: "instance", + accessor: "payload", className: "vw-1", Cell: ({ value, row, update }) => { const options = episodes.map>((ep) => ({ @@ -219,7 +132,7 @@ const SeriesUploadModal: FunctionComponent = ({ (ep: Nullable) => { if (ep) { const newInfo = { ...row.original }; - newInfo.instance = ep; + newInfo.payload.instance = ep; update && update(row, newInfo); } }, @@ -228,101 +141,28 @@ const SeriesUploadModal: FunctionComponent = ({ return ( ); }, }, - { - accessor: "file", - Cell: ({ row, update }) => { - return ( - - ); - }, - }, ], - [language?.code2, episodes] - ); - - const updateItem = useCallback>( - (row, info?: PendingSubtitle) => { - setPending((pd) => { - const newPending = [...pd]; - if (info) { - newPending[row.index] = info; - } else { - newPending.splice(row.index, 1); - } - return newPending; - }); - }, - [] - ); - - const footer = ( -
-
- { - if (l) { - setLanguage(l); - } - }} - > -
-
- - -
-
+ [episodes] ); return ( - - -
- - - -
- -
-
+ ); }; diff --git a/frontend/src/components/modals/SubtitleUploadModal.tsx b/frontend/src/components/modals/SubtitleUploadModal.tsx new file mode 100644 index 000000000..4584826fb --- /dev/null +++ b/frontend/src/components/modals/SubtitleUploadModal.tsx @@ -0,0 +1,290 @@ +import { + faCheck, + faCircleNotch, + faInfoCircle, + faTimes, + faTrash, +} from "@fortawesome/free-solid-svg-icons"; +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 { LanguageSelector, MessageIcon } from ".."; +import { FileForm } from "../inputs"; +import { SimpleTable } from "../tables"; +import BaseModal, { BaseModalProps } from "./BaseModal"; +import { useCloseModal } from "./hooks"; + +export interface PendingSubtitle

{ + file: File; + state: "valid" | "fetching" | "warning" | "error"; + messages: string[]; + language: Language.Info | null; + payload: P; +} + +export type Validator = ( + item: PendingSubtitle +) => Pick, "state" | "messages">; + +interface Props { + initial: T; + availableLanguages: Language.Info[]; + upload: (items: PendingSubtitle[]) => void; + update: (items: PendingSubtitle[]) => Promise[]>; + validate: Validator; + columns: Column>[]; + hideAllLanguages?: boolean; +} + +export default function SubtitleUploadModal( + props: Props & Omit +) { + const { + initial, + columns, + upload, + update, + validate, + availableLanguages, + hideAllLanguages, + } = props; + + const closeModal = useCloseModal(); + + const [pending, setPending] = useState[]>([]); + + const fileList = useMemo(() => pending.map((v) => v.file), [pending]); + + const initialRef = useRef(initial); + + const setFiles = useCallback( + async (files: File[]) => { + const initialLanguage = + availableLanguages.length > 0 ? availableLanguages[0] : null; + let list = files.map>((file) => ({ + file, + state: "fetching", + messages: [], + language: initialLanguage, + payload: { ...initialRef.current }, + })); + + if (update) { + setPending(list); + list = await update(list); + } else { + list = list.map>((v) => ({ + ...v, + state: "valid", + })); + } + + list = list.map((v) => ({ + ...v, + ...validate(v), + })); + + setPending(list); + }, + [update, validate, availableLanguages] + ); + + const modify = useCallback>>( + (row, info?: PendingSubtitle) => { + setPending((pd) => { + const newPending = [...pd]; + if (info) { + info = { ...info, ...validate(info) }; + newPending[row.index] = info; + } else { + newPending.splice(row.index, 1); + } + return newPending; + }); + }, + [validate] + ); + + useEffect(() => { + setPending((pd) => { + const newPd = pd.map((v) => { + if (v.state !== "fetching") { + return { ...v, ...validate(v) }; + } else { + return v; + } + }); + + return newPd; + }); + }, [validate]); + + const columnsWithAction = useMemo>[]>( + () => [ + { + id: "icon", + accessor: "state", + className: "text-center", + Cell: ({ value, row }) => { + let icon = faCircleNotch; + let color: string | undefined = undefined; + let spin = false; + + switch (value) { + case "fetching": + spin = true; + break; + case "warning": + icon = faInfoCircle; + color = "var(--warning)"; + break; + case "valid": + icon = faCheck; + color = "var(--success)"; + break; + default: + icon = faTimes; + color = "var(--danger)"; + break; + } + + const messages = row.original.messages; + + return ( + + ); + }, + }, + { + Header: "File", + accessor: (d) => d.file.name, + }, + ...columns, + { + id: "language", + Header: "Language", + accessor: "language", + className: "w-25", + Cell: ({ row, update, value }) => { + return ( + { + if (lang && update) { + const newInfo = { ...row.original }; + newInfo.language = lang; + update(row, newInfo); + } + }} + > + ); + }, + }, + { + id: "action", + accessor: "file", + Cell: ({ row, update }) => ( + + ), + }, + ], + [columns, availableLanguages] + ); + + const showTable = pending.length > 0; + + const canUpload = useMemo( + () => + pending.length > 0 && + pending.every((v) => v.state === "valid" || v.state === "warning"), + [pending] + ); + + const footer = ( +

+
+ + +
+ +
+ ); + + return ( + + +
+ + + +
+ +
+
+ ); +}