diff --git a/package-lock.json b/package-lock.json index 601f0ef44..ec0c3a21e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@fontsource-variable/roboto-flex": "^5.0.15", "@iarna/toml": "^2.2.5", "@mui/material": "^5.15.19", - "@opencast/appkit": "^0.2.4", + "@opencast/appkit": "^0.3.0", "@reduxjs/toolkit": "^2.2.5", "@testing-library/jest-dom": "^6.4.5", "@types/iarna__toml": "^2.0.5", @@ -2766,12 +2766,13 @@ } }, "node_modules/@opencast/appkit": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@opencast/appkit/-/appkit-0.2.4.tgz", - "integrity": "sha512-EHByrN6o8elbQIOI4XB1vqxA5IxStLGgoX8nfOQ3EXWHbx0n/ZNeZ/DrOTTmxgTNfRxvjZR7sbjIlbSIyL5SKw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@opencast/appkit/-/appkit-0.3.0.tgz", + "integrity": "sha512-w9jZBKpzJokT0HNjtO3rxx94Qimt8W6WmjDkg61rj5ETHv3Im794jyZLf/LRA3dsN+GfhE0dW3QuvIzXE/b2pw==", "peerDependencies": { - "@emotion/react": "^11.11.1", + "@emotion/react": "^11.11.4", "@floating-ui/react": "^0.24.3", + "focus-trap-react": "^10.2.3", "react": "^18.2.0", "react-icons": "^4.9.0", "react-merge-refs": "^2.0.2" @@ -5574,6 +5575,30 @@ "dev": true, "license": "ISC" }, + "node_modules/focus-trap": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.4.tgz", + "integrity": "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==", + "peer": true, + "dependencies": { + "tabbable": "^6.2.0" + } + }, + "node_modules/focus-trap-react": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.3.tgz", + "integrity": "sha512-YXBpFu/hIeSu6NnmV2xlXzOYxuWkoOtar9jzgp3lOmjWLWY59C/b8DtDHEAV4SPU07Nd/t+nS/SBNGkhUBFmEw==", + "peer": true, + "dependencies": { + "focus-trap": "^7.5.4", + "tabbable": "^6.2.0" + }, + "peerDependencies": { + "prop-types": "^15.8.1", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", diff --git a/package.json b/package.json index 890242e14..b82cff5b8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@fontsource-variable/roboto-flex": "^5.0.15", "@iarna/toml": "^2.2.5", "@mui/material": "^5.15.19", - "@opencast/appkit": "^0.2.4", + "@opencast/appkit": "^0.3.0", "@reduxjs/toolkit": "^2.2.5", "@testing-library/jest-dom": "^6.4.5", "@types/iarna__toml": "^2.0.5", diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 1ed004ca4..b775856ac 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -247,7 +247,7 @@ "subtitles": { "selectSubtitleButton-tooltip": "Edit subtitles for {{title}}", "selectSubtitleButton-tooltip-aria": "Select {{title}} for subtitle editing", - "createSubtitleButton-tooltip": "Opens a dialog for creating new subtitles", + "createSubtitleButton-tooltip": "Opens a dialog for creating/uploading new subtitles", "createSubtitleButton-clicked-tooltip-aria": "Contains a dialog for creating new subtitles", "createSubtitleButton-createButton": "Create", "createSubtitleButton-createButton-tooltip": "Start a new subtitle file with the chosen title.", @@ -257,6 +257,13 @@ "backButton-tooltip": "Return to subtitle selection", "downloadButton-title": "Download", "downloadButton-tooltip": "Download subtitle as vtt file", + "uploadButton-title": "Upload", + "uploadButton-tooltip": "Upload subtitle as vtt file", + "uploadButton-warning-header": "Caution!", + "uploadButton-warning": "Uploading will overwrite the current subtitle. This cannot be undone. Are you sure?", + "uploadButton-error": "Upload failed.", + "uploadButton-error-filetype": "Wrong file type.", + "uploadButton-error-parse": "Could not parse subtitle file. Please ensure that the file contains valid WebVTT.", "editTitle": "Subtitle Editor - {{title}}", "editTitle-loading": "Loading", "generic": "Generic", @@ -311,5 +318,12 @@ "language": { "language": "Language" + }, + + "modal": { + "areYouSure": "Are you sure?", + "cancel": "Cancel", + "close": "Close", + "confirm": "Confirm" } } diff --git a/src/main/SubtitleEditor.tsx b/src/main/SubtitleEditor.tsx index 8b61920cd..b88c2e769 100644 --- a/src/main/SubtitleEditor.tsx +++ b/src/main/SubtitleEditor.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import { css } from "@emotion/react"; import { basicButtonStyle, flexGapReplacementStyle } from "../cssStyles"; -import { LuChevronLeft, LuDownload } from "react-icons/lu"; +import { LuChevronLeft, LuDownload, LuUpload } from "react-icons/lu"; import { selectSubtitlesFromOpencastById, } from "../redux/videoSlice"; @@ -16,11 +16,12 @@ import { import SubtitleVideoArea from "./SubtitleVideoArea"; import SubtitleTimeline from "./SubtitleTimeline"; import { useTranslation } from "react-i18next"; -import { useTheme } from "../themes"; +import { Theme, useTheme } from "../themes"; import { parseSubtitle, serializeSubtitle } from "../util/utilityFunctions"; import { ThemedTooltip } from "./Tooltip"; import { titleStyle, titleStyleBold } from "../cssStyles"; import { generateButtonTitle } from "./SubtitleSelect"; +import { ConfirmationModal, ConfirmationModalHandle, Modal, ModalHandle } from "@opencast/appkit"; /** * Displays an editor view for a selected subtitle file @@ -35,6 +36,8 @@ const SubtitleEditor: React.FC = () => { const selectedId = useAppSelector(selectSelectedSubtitleId); const captionTrack = useAppSelector(state => selectSubtitlesFromOpencastById(state, selectedId)); const theme = useTheme(); + const modalRef = React.useRef(null); + const [uploadErrorMessage, setUploadErrorMessage] = useState(undefined); // Prepare subtitle in redux useEffect(() => { @@ -61,6 +64,17 @@ const SubtitleEditor: React.FC = () => { } }, [dispatch, captionTrack, subtitle, selectedId]); + // Display error modal + useEffect(() => { + if (modalRef.current && uploadErrorMessage) { + modalRef.current?.open(); + } + if (modalRef.current && modalRef.current.close && !uploadErrorMessage) { + setUploadErrorMessage(undefined); + modalRef.current.close(); + } + }, [uploadErrorMessage]); + const getTitle = () => { if (subtitle) { return generateButtonTitle(subtitle.tags, t); @@ -84,6 +98,14 @@ const SubtitleEditor: React.FC = () => { justifyContent: "space-between", alignItems: "center", width: "100%", + ...(flexGapReplacementStyle(10, false)), + padding: "15px", + }); + + const topRightButtons = css({ + display: "flex", + flexDirection: "row", + ...(flexGapReplacementStyle(10, false)), }); const subAreaStyle = css({ @@ -99,7 +121,6 @@ const SubtitleEditor: React.FC = () => { borderBottom: `${theme.menuBorder}`, }); - const render = () => { if (getError !== undefined) { return ( @@ -110,10 +131,20 @@ const SubtitleEditor: React.FC = () => { <>
-
+
{t("subtitles.editTitle", { title: getTitle() })}
- +
+ + + + {uploadErrorMessage} + +
@@ -132,6 +163,15 @@ const SubtitleEditor: React.FC = () => { ); }; +const subtitleButtonStyle = (theme: Theme) => css({ + fontSize: "16px", + height: "10px", + padding: "16px", + justifyContent: "space-around", + boxShadow: `${theme.boxShadow}`, + background: `${theme.element_bg}`, +}); + const DownloadButton: React.FC = () => { const subtitle = useAppSelector(selectSelectedSubtitleById); @@ -151,18 +191,10 @@ const DownloadButton: React.FC = () => { const { t } = useTranslation(); const theme = useTheme(); - const style = css({ - fontSize: "16px", - height: "10px", - padding: "16px", - justifyContent: "space-around", - boxShadow: `${theme.boxShadow}`, - background: `${theme.element_bg}`, - }); return ( -
downloadSubtitles()} > @@ -173,6 +205,102 @@ const DownloadButton: React.FC = () => { ); }; +const UploadButton: React.FC<{ + setErrorMessage: React.Dispatch>, +}> = ({ + setErrorMessage, +}) => { + + const { t } = useTranslation(); + const theme = useTheme(); + const dispatch = useAppDispatch(); + + const [isFileUploadTriggered, setisFileUploadTriggered] = useState(false); + const subtitle = useAppSelector(selectSelectedSubtitleById); + const selectedId = useAppSelector(selectSelectedSubtitleId); + // Upload Ref + const inputRef = React.useRef(null); + // Modal Ref + const modalRef = React.useRef(null); + + const triggerFileUpload = () => { + modalRef.current?.done(); + setisFileUploadTriggered(true); + }; + + useEffect(() => { + if (isFileUploadTriggered) { + inputRef.current?.click(); + setisFileUploadTriggered(false); + } + }, [isFileUploadTriggered]); + + // Save uploaded file in redux + const uploadCallback = (event: React.ChangeEvent) => { + const fileObj = event.target.files && event.target.files[0]; + if (!fileObj) { + return; + } + + // Check if not text + if (fileObj.type.split("/")[0] !== "text") { + setErrorMessage(t("subtitles.uploadButton-error-filetype")); + return; + } + + const reader = new FileReader(); + reader.onload = e => { + if (e.target && (e.target.result || e.target.result === "")) { + try { + const text = e.target.result.toString(); + const subtitleParsed = parseSubtitle(text); + dispatch(setSubtitle({ identifier: selectedId, subtitles: { cues: subtitleParsed, tags: subtitle.tags } })); + } catch (e) { + console.error(e); + setErrorMessage(t("subtitles.uploadButton-error-parse")); + } + } + }; + reader.readAsText(fileObj); + }; + + return ( + <> + +
modalRef.current?.open()} + > + + {t("subtitles.uploadButton-title")} +
+
+ {/* Hidden input field for upload */} + uploadCallback(event)} + aria-hidden="true" + /> + + {t("subtitles.uploadButton-warning")} + + + ); +}; + /** * Takes you to a different page @@ -183,17 +311,9 @@ export const BackButton: React.FC = () => { const theme = useTheme(); const dispatch = useAppDispatch(); - const backButtonStyle = css({ - height: "10px", - padding: "16px", - boxShadow: `${theme.boxShadow}`, - background: `${theme.element_bg}`, - justifyContent: "space-around", - }); - return ( -
dispatch(setIsDisplayEditView(false))} diff --git a/src/util/utilityFunctions.ts b/src/util/utilityFunctions.ts index 7b39a2f56..cf25a6ba9 100644 --- a/src/util/utilityFunctions.ts +++ b/src/util/utilityFunctions.ts @@ -129,6 +129,10 @@ export function parseSubtitle(subtitle: string): SubtitleCue[] { // - Pros: Parses styles, can also parse SRT, actively maintained // - Cons: Uses node streaming library, can"t polyfill without ejecting CreateReactApp // TODO: Parse caption + if (subtitle === "") { + throw new Error("File is empty"); + } + const parser = new WebVTTParser(); const tree = parser.parse(subtitle, "metadata"); if (tree.errors.length !== 0) {