mirror of https://github.com/morpheus65535/bazarr
Support multi-language in subtitle upload modal
This commit is contained in:
parent
e0b988b20f
commit
4a890b2561
|
@ -7,7 +7,7 @@ interface Props {
|
|||
|
||||
type RemovedSelectorProps<M extends boolean> = Omit<
|
||||
SelectorProps<Language.Info, M>,
|
||||
"label" | "placeholder"
|
||||
"label"
|
||||
>;
|
||||
|
||||
export type LanguageSelectorProps<M extends boolean> = Override<
|
||||
|
|
|
@ -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<BaseModalProps> = (props) => {
|
||||
const modal = props;
|
||||
|
||||
const { payload, closeModal } = useModalInformation<Item.Movie>(
|
||||
modal.modalKey
|
||||
);
|
||||
const { payload } = useModalInformation<Item.Movie>(modal.modalKey);
|
||||
|
||||
const profile = useProfileBy(payload?.profileId);
|
||||
|
||||
const availableLanguages = useProfileItemsToLanguages(profile);
|
||||
|
||||
const [pending, setPending] = useState<PendingSubtitle[]>([]);
|
||||
const update = useCallback(async (list: PendingSubtitle<Payload>[]) => {
|
||||
return list;
|
||||
}, []);
|
||||
|
||||
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 validate = useCallback<Validator<Payload>>(
|
||||
(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 upload = useCallback(
|
||||
(items: PendingSubtitle<Payload>[]) => {
|
||||
if (payload === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { radarrId } = payload;
|
||||
const { radarrId } = payload;
|
||||
|
||||
const tasks = pending.map((v) => {
|
||||
const { file, language, forced } = v;
|
||||
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,
|
||||
}
|
||||
);
|
||||
});
|
||||
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<PendingSubtitle>, info?: PendingSubtitle) => {
|
||||
setPending((pd) => {
|
||||
const newPending = [...pd];
|
||||
if (info) {
|
||||
newPending[row.index] = info;
|
||||
} else {
|
||||
newPending.splice(row.index, 1);
|
||||
}
|
||||
return newPending;
|
||||
});
|
||||
dispatchTask(TaskGroupName, tasks, "Uploading...");
|
||||
},
|
||||
[]
|
||||
[payload]
|
||||
);
|
||||
|
||||
const columns = useMemo<Column<PendingSubtitle>[]>(
|
||||
const columns = useMemo<Column<PendingSubtitle<Payload>>[]>(
|
||||
() => [
|
||||
{
|
||||
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 (
|
||||
<Form.Check
|
||||
custom
|
||||
disabled={original.state === "fetching"}
|
||||
id={BuildKey(index, original.file.name, "forced")}
|
||||
checked={value}
|
||||
checked={value.forced}
|
||||
onChange={(v) => {
|
||||
const newInfo = { ...row.original };
|
||||
newInfo.forced = v.target.checked;
|
||||
newInfo.payload.forced = v.target.checked;
|
||||
update && update(row, newInfo);
|
||||
}}
|
||||
></Form.Check>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Language",
|
||||
accessor: "language",
|
||||
className: "w-25",
|
||||
Cell: ({ row, update, value }) => {
|
||||
return (
|
||||
<LanguageSelector
|
||||
options={availableLanguages}
|
||||
value={value}
|
||||
onChange={(lang) => {
|
||||
if (lang && update) {
|
||||
const newInfo = { ...row.original };
|
||||
newInfo.language = lang;
|
||||
update(row, newInfo);
|
||||
}
|
||||
}}
|
||||
></LanguageSelector>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "file",
|
||||
Cell: ({ row, update }) => {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
update && update(row);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[availableLanguages]
|
||||
);
|
||||
|
||||
const canUpload = pending.length > 0;
|
||||
|
||||
const footer = (
|
||||
<Button disabled={!canUpload} onClick={upload}>
|
||||
Upload
|
||||
</Button>
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModal title={`Upload - ${payload?.title}`} footer={footer} {...modal}>
|
||||
<Container fluid className="flex-column">
|
||||
<Form>
|
||||
<Form.Group>
|
||||
<FileForm
|
||||
emptyText="Select..."
|
||||
disabled={canUpload || availableLanguages.length === 0}
|
||||
multiple
|
||||
value={filelist}
|
||||
onChange={setFiles}
|
||||
></FileForm>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
<div hidden={!canUpload}>
|
||||
<SimpleTable
|
||||
columns={columns}
|
||||
data={pending}
|
||||
responsive={false}
|
||||
update={modify}
|
||||
></SimpleTable>
|
||||
</div>
|
||||
</Container>
|
||||
</BaseModal>
|
||||
<SubtitleUploadModal
|
||||
hideAllLanguages
|
||||
initial={{ forced: false }}
|
||||
availableLanguages={availableLanguages}
|
||||
columns={columns}
|
||||
upload={upload}
|
||||
update={update}
|
||||
validate={validate}
|
||||
{...modal}
|
||||
></SubtitleUploadModal>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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<SerieProps & BaseModalProps> = ({
|
||||
const SeriesUploadModal: FunctionComponent<SeriesProps & BaseModalProps> = ({
|
||||
episodes,
|
||||
...modal
|
||||
}) => {
|
||||
const { payload, closeModal } = useModalInformation<Item.Series>(
|
||||
modal.modalKey
|
||||
);
|
||||
|
||||
const [pending, setPending] = useState<PendingSubtitle[]>([]);
|
||||
const { payload } = useModalInformation<Item.Series>(modal.modalKey);
|
||||
|
||||
const profile = useProfileBy(payload?.profileId);
|
||||
|
||||
const avaliableLanguages = useProfileItemsToLanguages(profile);
|
||||
const availableLanguages = useProfileItemsToLanguages(profile);
|
||||
|
||||
const [language, setLanguage] = useState<Language.Info | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (avaliableLanguages.length > 0) {
|
||||
setLanguage(avaliableLanguages[0]);
|
||||
}
|
||||
}, [avaliableLanguages]);
|
||||
|
||||
const filelist = useMemo(() => pending.map((v) => v.file), [pending]);
|
||||
|
||||
const checkEpisodes = useCallback(
|
||||
async (list: PendingSubtitle[]) => {
|
||||
const update = useCallback(
|
||||
async (list: PendingSubtitle<Payload>[]) => {
|
||||
const newList = [...list];
|
||||
const names = list.map((v) => v.file.name);
|
||||
|
||||
if (names.length > 0) {
|
||||
const results = await SubtitlesApi.info(names);
|
||||
|
||||
const episodeMap = results.reduce<EpisodeMap>((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,
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return newList;
|
||||
},
|
||||
[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);
|
||||
},
|
||||
[checkEpisodes]
|
||||
);
|
||||
|
||||
const upload = useCallback(() => {
|
||||
if (payload === null || language === null) {
|
||||
return;
|
||||
const validate = useCallback<Validator<Payload>>((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 { sonarrSeriesId: seriesid } = payload;
|
||||
const { code2, hi, forced } = language;
|
||||
const upload = useCallback(
|
||||
(items: PendingSubtitle<Payload>[]) => {
|
||||
if (payload === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = pending
|
||||
.filter((v) => v.instance !== undefined)
|
||||
.map((v) => {
|
||||
const { sonarrEpisodeId: episodeid } = v.instance!;
|
||||
const { sonarrSeriesId: seriesid } = payload;
|
||||
|
||||
const form: FormType.UploadSubtitle = {
|
||||
file: v.file,
|
||||
language: code2,
|
||||
hi: hi ?? false,
|
||||
forced: forced ?? false,
|
||||
};
|
||||
const tasks = items
|
||||
.filter((v) => v.payload.instance !== undefined)
|
||||
.map((v) => {
|
||||
const { code2, hi, forced } = v.language!;
|
||||
const { sonarrEpisodeId: episodeid } = v.payload.instance!;
|
||||
|
||||
return createTask(
|
||||
v.file.name,
|
||||
episodeid,
|
||||
EpisodesApi.uploadSubtitles.bind(EpisodesApi),
|
||||
seriesid,
|
||||
episodeid,
|
||||
form
|
||||
);
|
||||
});
|
||||
const form: FormType.UploadSubtitle = {
|
||||
file: v.file,
|
||||
language: code2,
|
||||
hi: hi ?? false,
|
||||
forced: forced ?? false,
|
||||
};
|
||||
|
||||
dispatchTask(TaskGroupName, tasks, "Uploading subtitles...");
|
||||
setFiles([]);
|
||||
closeModal();
|
||||
}, [payload, pending, language, closeModal, setFiles]);
|
||||
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<Column<PendingSubtitle>[]>(
|
||||
const columns = useMemo<Column<PendingSubtitle<Payload>>[]>(
|
||||
() => [
|
||||
{
|
||||
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 (
|
||||
<MessageIcon
|
||||
messages={msgs}
|
||||
color={color}
|
||||
icon={icon}
|
||||
spin={spin}
|
||||
></MessageIcon>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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<SelectorOption<Item.Episode>>((ep) => ({
|
||||
|
@ -219,7 +132,7 @@ const SeriesUploadModal: FunctionComponent<SerieProps & BaseModalProps> = ({
|
|||
(ep: Nullable<Item.Episode>) => {
|
||||
if (ep) {
|
||||
const newInfo = { ...row.original };
|
||||
newInfo.instance = ep;
|
||||
newInfo.payload.instance = ep;
|
||||
update && update(row, newInfo);
|
||||
}
|
||||
},
|
||||
|
@ -228,101 +141,28 @@ const SeriesUploadModal: FunctionComponent<SerieProps & BaseModalProps> = ({
|
|||
|
||||
return (
|
||||
<Selector
|
||||
disabled={row.original.state === "fetching"}
|
||||
options={options}
|
||||
value={value ?? null}
|
||||
value={value.instance}
|
||||
onChange={change}
|
||||
></Selector>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "file",
|
||||
Cell: ({ row, update }) => {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
update && update(row);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[language?.code2, episodes]
|
||||
);
|
||||
|
||||
const updateItem = useCallback<TableUpdater<PendingSubtitle>>(
|
||||
(row, info?: PendingSubtitle) => {
|
||||
setPending((pd) => {
|
||||
const newPending = [...pd];
|
||||
if (info) {
|
||||
newPending[row.index] = info;
|
||||
} else {
|
||||
newPending.splice(row.index, 1);
|
||||
}
|
||||
return newPending;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<div className="d-flex flex-row flex-grow-1 justify-content-between">
|
||||
<div className="w-25">
|
||||
<LanguageSelector
|
||||
options={avaliableLanguages}
|
||||
value={language}
|
||||
onChange={(l) => {
|
||||
if (l) {
|
||||
setLanguage(l);
|
||||
}
|
||||
}}
|
||||
></LanguageSelector>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
disabled={pending.length === 0}
|
||||
variant="outline-secondary"
|
||||
className="mr-2"
|
||||
onClick={() => setFiles([])}
|
||||
>
|
||||
Clean
|
||||
</Button>
|
||||
<Button disabled={!canUpload} onClick={upload}>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
[episodes]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModal size="lg" title="Upload Subtitles" footer={footer} {...modal}>
|
||||
<Container fluid className="flex-column">
|
||||
<Form>
|
||||
<Form.Group>
|
||||
<FileForm
|
||||
emptyText="Select..."
|
||||
disabled={showTable || avaliableLanguages.length === 0}
|
||||
multiple
|
||||
value={filelist}
|
||||
onChange={setFiles}
|
||||
></FileForm>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
<div hidden={!showTable}>
|
||||
<SimpleTable
|
||||
columns={columns}
|
||||
data={pending}
|
||||
responsive={false}
|
||||
update={updateItem}
|
||||
></SimpleTable>
|
||||
</div>
|
||||
</Container>
|
||||
</BaseModal>
|
||||
<SubtitleUploadModal
|
||||
columns={columns}
|
||||
initial={{ instance: null }}
|
||||
availableLanguages={availableLanguages}
|
||||
upload={upload}
|
||||
update={update}
|
||||
validate={validate}
|
||||
{...modal}
|
||||
></SubtitleUploadModal>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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<P> {
|
||||
file: File;
|
||||
state: "valid" | "fetching" | "warning" | "error";
|
||||
messages: string[];
|
||||
language: Language.Info | null;
|
||||
payload: P;
|
||||
}
|
||||
|
||||
export type Validator<T> = (
|
||||
item: PendingSubtitle<T>
|
||||
) => Pick<PendingSubtitle<T>, "state" | "messages">;
|
||||
|
||||
interface Props<T> {
|
||||
initial: T;
|
||||
availableLanguages: Language.Info[];
|
||||
upload: (items: PendingSubtitle<T>[]) => void;
|
||||
update: (items: PendingSubtitle<T>[]) => Promise<PendingSubtitle<T>[]>;
|
||||
validate: Validator<T>;
|
||||
columns: Column<PendingSubtitle<T>>[];
|
||||
hideAllLanguages?: boolean;
|
||||
}
|
||||
|
||||
export default function SubtitleUploadModal<T>(
|
||||
props: Props<T> & Omit<BaseModalProps, "footer" | "title" | "size">
|
||||
) {
|
||||
const {
|
||||
initial,
|
||||
columns,
|
||||
upload,
|
||||
update,
|
||||
validate,
|
||||
availableLanguages,
|
||||
hideAllLanguages,
|
||||
} = props;
|
||||
|
||||
const closeModal = useCloseModal();
|
||||
|
||||
const [pending, setPending] = useState<PendingSubtitle<T>[]>([]);
|
||||
|
||||
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<PendingSubtitle<T>>((file) => ({
|
||||
file,
|
||||
state: "fetching",
|
||||
messages: [],
|
||||
language: initialLanguage,
|
||||
payload: { ...initialRef.current },
|
||||
}));
|
||||
|
||||
if (update) {
|
||||
setPending(list);
|
||||
list = await update(list);
|
||||
} else {
|
||||
list = list.map<PendingSubtitle<T>>((v) => ({
|
||||
...v,
|
||||
state: "valid",
|
||||
}));
|
||||
}
|
||||
|
||||
list = list.map((v) => ({
|
||||
...v,
|
||||
...validate(v),
|
||||
}));
|
||||
|
||||
setPending(list);
|
||||
},
|
||||
[update, validate, availableLanguages]
|
||||
);
|
||||
|
||||
const modify = useCallback<TableUpdater<PendingSubtitle<T>>>(
|
||||
(row, info?: PendingSubtitle<T>) => {
|
||||
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<Column<PendingSubtitle<T>>[]>(
|
||||
() => [
|
||||
{
|
||||
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 (
|
||||
<MessageIcon
|
||||
messages={messages}
|
||||
color={color}
|
||||
icon={icon}
|
||||
spin={spin}
|
||||
></MessageIcon>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "File",
|
||||
accessor: (d) => d.file.name,
|
||||
},
|
||||
...columns,
|
||||
{
|
||||
id: "language",
|
||||
Header: "Language",
|
||||
accessor: "language",
|
||||
className: "w-25",
|
||||
Cell: ({ row, update, value }) => {
|
||||
return (
|
||||
<LanguageSelector
|
||||
disabled={row.original.state === "fetching"}
|
||||
options={availableLanguages}
|
||||
value={value}
|
||||
onChange={(lang) => {
|
||||
if (lang && update) {
|
||||
const newInfo = { ...row.original };
|
||||
newInfo.language = lang;
|
||||
update(row, newInfo);
|
||||
}
|
||||
}}
|
||||
></LanguageSelector>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
],
|
||||
[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 = (
|
||||
<div className="d-flex flex-row-reverse flex-grow-1 justify-content-between">
|
||||
<div>
|
||||
<Button
|
||||
hidden={!showTable}
|
||||
variant="outline-secondary"
|
||||
className="mr-2"
|
||||
onClick={() => setFiles([])}
|
||||
>
|
||||
Clean
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canUpload || !showTable}
|
||||
onClick={() => {
|
||||
upload(pending);
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-25" hidden={hideAllLanguages}>
|
||||
<LanguageSelector
|
||||
options={availableLanguages}
|
||||
value={null}
|
||||
disabled={!showTable}
|
||||
onChange={(lang) => {
|
||||
if (lang) {
|
||||
setPending((pd) =>
|
||||
pd
|
||||
.map((v) => ({ ...v, language: lang }))
|
||||
.map((v) => ({ ...v, ...validate(v) }))
|
||||
);
|
||||
}
|
||||
}}
|
||||
></LanguageSelector>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
size={showTable ? "xl" : "lg"}
|
||||
title="Upload Subtitles"
|
||||
footer={footer}
|
||||
{...props}
|
||||
>
|
||||
<Container fluid className="flex-column">
|
||||
<Form>
|
||||
<Form.Group>
|
||||
<FileForm
|
||||
disabled={showTable}
|
||||
emptyText="Select..."
|
||||
multiple
|
||||
value={fileList}
|
||||
onChange={setFiles}
|
||||
></FileForm>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
<div hidden={!showTable}>
|
||||
<SimpleTable
|
||||
columns={columnsWithAction}
|
||||
data={pending}
|
||||
responsive={false}
|
||||
update={modify}
|
||||
></SimpleTable>
|
||||
</div>
|
||||
</Container>
|
||||
</BaseModal>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue