diff --git a/src/constants.ts b/src/constants.ts index 429f5ea37d..ebcd0b0c46 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -92,6 +92,8 @@ export const FRONTPAGE_ADMIN_SCOPE = "frontpage:admin"; export const AUDIO_ADMIN_SCOPE = "audio:admin"; +export const IMAGE_BATCH_SCOPE = "images:batch"; + export const TAXONOMY_CUSTOM_FIELD_LANGUAGE = "language"; export const TAXONOMY_CUSTOM_FIELD_TOPIC_RESOURCES = "topic-resources"; export const TAXONOMY_CUSTOM_FIELD_GROUPED_RESOURCE = "grouped"; diff --git a/src/containers/ImageUploader/BatchUploadImagePage.tsx b/src/containers/ImageUploader/BatchUploadImagePage.tsx new file mode 100644 index 0000000000..b312a820a1 --- /dev/null +++ b/src/containers/ImageUploader/BatchUploadImagePage.tsx @@ -0,0 +1,167 @@ +/** + * Copyright (c) 2026-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { Button, Heading, PageContainer, Text } from "@ndla/primitives"; +import { styled } from "@ndla/styled-system/jsx"; +import { NewImageMetaInformationV2DTO } from "@ndla/types-backend/image-api"; +import { uniqBy } from "@ndla/util"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FormActionsContainer } from "../../components/FormikForm"; +import validateFormik from "../../components/formikValidationSchema"; +import { IMAGE_BATCH_SCOPE } from "../../constants"; +import { useLicenses } from "../../modules/draft/draftQueries"; +import NotFound from "../NotFoundPage/NotFoundPage"; +import PrivateRoute from "../PrivateRoute/PrivateRoute"; +import { useSession } from "../Session/SessionProvider"; +import { BatchImageUploader } from "./components/batch/BatchImageUploader"; +import { CommonImageInfoForm, toImageFormValues } from "./components/batch/CommonInfoForm"; +import { ImageListItem } from "./components/batch/ImageListItem"; +import { ImageFormikType, imageFormTypeToApiType, imageRules } from "./imageTransformers"; + +const StyledList = styled("ul", { + base: { + display: "flex", + flexDirection: "column", + gap: "xsmall", + }, +}); + +const StyledPageContainer = styled(PageContainer, { + base: { + gap: "medium", + }, +}); + +export const Component = () => { + return } />; +}; + +export const BatchUploadImagePage = () => { + const [acceptedFiles, setAcceptedFiles] = useState([]); + const [commonMetadata, setCommonMetadata] = useState(undefined); + const [specifiedMetadata, setSpecifiedMetadata] = useState>({}); + const [invalidFiles, setInvalidFiles] = useState>({}); + const [hasImageWithErrors, setHasImageWithErrors] = useState(false); + + const { userPermissions } = useSession(); + + const { t } = useTranslation(); + + const { data: licenses } = useLicenses({ + placeholderData: [], + select: (data) => data.map((lic) => ({ ...lic, description: lic.description ?? "" })) ?? [], + }); + + const onAcceptFiles = (files: File[]) => { + setAcceptedFiles((prev) => uniqBy([...prev, ...files], (file) => file.name)); + }; + + const onRemoveFile = (file: File) => { + setAcceptedFiles((prev) => prev.filter((f) => f.name !== file.name)); + }; + + const onSave = async () => { + if (!commonMetadata) return; + setHasImageWithErrors(false); + const formValues = acceptedFiles.map((f) => + toImageFormValues(commonMetadata, specifiedMetadata[f.name], f, commonMetadata.language), + ); + + const invalidValues = formValues.reduce>((acc, value) => { + const errors = Object.values(validateFormik(value, imageRules, t)); + if (errors.length) { + acc[(value.imageFile as File).name] = errors; + } + return acc; + }, {}); + + if (Object.keys(invalidValues).length) { + setInvalidFiles(invalidValues); + return; + } + + const metadatas = formValues.reduce((acc, value) => { + const meta = imageFormTypeToApiType(value, licenses); + if (meta) { + acc.push(meta); + } + return acc; + }, []); + + if (metadatas.length !== formValues.length) { + setHasImageWithErrors(true); + return; + } + + const transformed = acceptedFiles.map((f) => { + const stitched = { ...commonMetadata, ...(specifiedMetadata[f.name] ?? {}), imageFile: f }; + return imageFormTypeToApiType(stitched, licenses); + }); + + // TODO: Implement + return transformed; + }; + + if (!userPermissions?.includes(IMAGE_BATCH_SCOPE)) { + return ; + } + + return ( + + {t("htmlTitles.batchUploadImagePage")} + {t("batchUploadImagePage.heading")} + {t("batchUploadImagePage.description")} + +

{t("batchUploadImagePage.commonMetaHeading")}

+
+ {t("batchUploadImagePage.commonMetaHeadingDescription")} + + {!!commonMetadata && ( + <> + +

{t("batchUploadImagePage.uploadImages")}

+
+ + +

{t("batchUploadImagePage.uploadedImages")}

+
+ {t("batchUploadImagePage.specificImageDescription")} + + {acceptedFiles.map((file) => ( + { + setInvalidFiles((prev) => { + delete prev[file.name]; + return prev; + }); + setSpecifiedMetadata((prev) => ({ ...prev, [file.name]: values })); + }} + invalid={!!invalidFiles[file.name]} + /> + ))} + + {hasImageWithErrors || + (!!Object.keys(invalidFiles).length && ( + {t("batchUploadImagePage.hasImagesWithErrors")} + ))} + + + + + )} +
+ ); +}; diff --git a/src/containers/ImageUploader/components/ImageForm.tsx b/src/containers/ImageUploader/components/ImageForm.tsx index d799158cb2..4bd73d0a00 100644 --- a/src/containers/ImageUploader/components/ImageForm.tsx +++ b/src/containers/ImageUploader/components/ImageForm.tsx @@ -20,17 +20,16 @@ import { useLocation, useNavigate } from "react-router"; import FormAccordion from "../../../components/Accordion/FormAccordion"; import FormAccordions from "../../../components/Accordion/FormAccordions"; import { FormActionsContainer } from "../../../components/FormikForm"; -import validateFormik, { RulesType, getWarnings } from "../../../components/formikValidationSchema"; +import validateFormik, { getWarnings } from "../../../components/formikValidationSchema"; import FormWrapper from "../../../components/FormWrapper"; import SaveButton from "../../../components/SaveButton"; import { SAVE_BUTTON_ID } from "../../../constants"; import { useLicenses } from "../../../modules/draft/draftQueries"; -import { editorValueToPlainText } from "../../../util/articleContentConverter"; import { isFormikFormDirty } from "../../../util/formHelper"; import { NewlyCreatedLocationState } from "../../../util/routeHelpers"; import { AlertDialogWrapper } from "../../FormikForm/AlertDialogWrapper"; import SimpleVersionPanel from "../../FormikForm/SimpleVersionPanel"; -import { imageApiTypeToFormType, ImageFormikType } from "../imageTransformers"; +import { imageApiTypeToFormType, ImageFormikType, imageFormTypeToApiType, imageRules } from "../imageTransformers"; import ImageContent from "./ImageContent"; import ImageCopyright from "./ImageCopyright"; import { ImageFormHeader } from "./ImageFormHeader"; @@ -48,52 +47,6 @@ const StyledPageContent = styled(PageContent, { }, }); -const imageRules: RulesType = { - title: { - required: true, - warnings: { - languageMatch: true, - }, - }, - caption: { - warnings: { - languageMatch: true, - }, - }, - alttext: { - required: true, - warnings: { - languageMatch: true, - }, - }, - tags: { - minItems: 3, - warnings: { - languageMatch: true, - }, - }, - creators: { - allObjectFieldsRequired: true, - }, - processors: { - allObjectFieldsRequired: true, - }, - rightsholders: { - allObjectFieldsRequired: true, - }, - imageFile: { - required: true, - }, - license: { - required: true, - test: (values) => { - const authors = values.creators.concat(values.rightsholders).concat(values.processors); - if (!values.license || authors.length > 0) return undefined; - return { translationKey: "validation.noLicenseWithoutCopyrightHolder" }; - }, - }, -}; - interface Props { image?: TImage; onSubmitFunc: ( @@ -140,45 +93,15 @@ const ImageForm = ) => { - const license = licenses?.find((license) => license.license === values.license); + const imageMetaData = imageFormTypeToApiType(values, licenses); - if ( - license === undefined || - values.title === undefined || - values.alttext === undefined || - values.caption === undefined || - values.language === undefined || - values.tags === undefined || - values.origin === undefined || - values.creators === undefined || - values.processors === undefined || - values.rightsholders === undefined || - values.imageFile === undefined || - values.modelReleased === undefined - ) { + if (!imageMetaData || !values.imageFile) { actions.setSubmitting(false); setSavedToServer(false); return; } actions.setSubmitting(true); - const imageMetaData: NewImageMetaInformationV2DTO & UpdateImageMetaInformationDTO = { - title: editorValueToPlainText(values.title), - alttext: values.alttext, - caption: values.caption, - language: values.language, - tags: values.tags, - inactive: values.inactive, - copyright: { - license, - origin: values.origin, - creators: values.creators, - processors: values.processors, - rightsholders: values.rightsholders, - processed: values.processed, - }, - modelReleased: values.modelReleased, - }; await onSubmitFunc(imageMetaData, values.imageFile); setSavedToServer(true); actions.resetForm(); diff --git a/src/containers/ImageUploader/components/ImageUploadFormElement.tsx b/src/containers/ImageUploader/components/ImageUploadFormElement.tsx index a97d93cccd..4a2dde9239 100644 --- a/src/containers/ImageUploader/components/ImageUploadFormElement.tsx +++ b/src/containers/ImageUploader/components/ImageUploadFormElement.tsx @@ -22,11 +22,13 @@ import { import { SafeLink } from "@ndla/safelink"; import { styled } from "@ndla/styled-system/jsx"; import { ImageDimensionsDTO, ImageMetaInformationV3DTO } from "@ndla/types-backend/image-api"; +import { uniq } from "@ndla/util"; import { useField } from "formik"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { MAX_IMAGE_UPLOAD_SIZE } from "../../../constants"; import { ImageFormikType } from "../imageTransformers"; +import { translateFileError } from "./imageUtils"; const StyledImg = styled("img", { base: { @@ -114,26 +116,9 @@ export const ImageUploadFormElement = ({ language, image }: Props) => { // as discussed here: https://github.com/jaredpalmer/formik/discussions/3870 const fileErrors = details.files?.[0]?.errors; if (!fileErrors) return; - if (fileErrors.includes("FILE_TOO_LARGE")) { - const errorMessage = `${t("form.image.fileUpload.genericError")}: ${t( - "form.image.fileUpload.tooLargeError", - )}`; - setTimeout(() => { - helpers.setError(errorMessage); - }, 0); - return; - } - if (fileErrors.includes("FILE_INVALID_TYPE")) { - const errorMessage = `${t("form.image.fileUpload.genericError")}: ${t( - "form.image.fileUpload.fileTypeInvalidError", - )}`; - setTimeout(() => { - helpers.setError(errorMessage); - }, 0); - return; - } + const translatedErrors = fileErrors.map((err) => translateFileError(err, t)); setTimeout(() => { - helpers.setError(t("form.image.fileUpload.genericError")); + helpers.setError(uniq(translatedErrors).join(", ")); }, 0); }} > diff --git a/src/containers/ImageUploader/components/batch/BatchImageUploader.tsx b/src/containers/ImageUploader/components/batch/BatchImageUploader.tsx new file mode 100644 index 0000000000..39c0467d57 --- /dev/null +++ b/src/containers/ImageUploader/components/batch/BatchImageUploader.tsx @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2026-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { UploadCloudLine } from "@ndla/icons"; +import { + Button, + FileUploadContext, + FileUploadDropzone, + FileUploadHiddenInput, + FileUploadLabel, + FileUploadRoot, + FileUploadTrigger, + ListItemHeading, + ListItemRoot, + Text, +} from "@ndla/primitives"; +import { styled } from "@ndla/styled-system/jsx"; +import { uniq } from "@ndla/util"; +import { useTranslation } from "react-i18next"; +import { MAX_IMAGE_UPLOAD_SIZE } from "../../../../constants"; +import { translateFileError } from "../imageUtils"; + +interface Props { + onFileAccept: (files: File[]) => void; + acceptedFiles: File[]; +} + +const StyledRejectFilesContainer = styled("div", { + base: { + marginBlockStart: "medium", + display: "flex", + flexDirection: "column", + gap: "xsmall", + }, +}); + +const StyledList = styled("ul", { + base: { + display: "flex", + flexDirection: "column", + gap: "xsmall", + }, +}); + +export const BatchImageUploader = ({ onFileAccept, acceptedFiles }: Props) => { + const { t } = useTranslation(); + return ( + onFileAccept(details.files)} + > + + {t("form.image.fileUpload.description")} + + + + + + + {({ rejectedFiles }) => + rejectedFiles.length ? ( + + {t("batchImageUploadPage.rejectedFiles")} + + {rejectedFiles.map((file) => ( + +
  • + {file.file.name} + {uniq(file.errors.map((err) => translateFileError(err, t))).join(", ")} +
  • +
    + ))} +
    +
    + ) : null + } +
    +
    + ); +}; diff --git a/src/containers/ImageUploader/components/batch/CommonInfoForm.tsx b/src/containers/ImageUploader/components/batch/CommonInfoForm.tsx new file mode 100644 index 0000000000..600bc56d9e --- /dev/null +++ b/src/containers/ImageUploader/components/batch/CommonInfoForm.tsx @@ -0,0 +1,139 @@ +/** + * Copyright (c) 2026-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { Button, FieldErrorMessage, FieldInput, FieldLabel, FieldRoot, FieldTextArea } from "@ndla/primitives"; +import { styled } from "@ndla/styled-system/jsx"; +import { Form, Formik } from "formik"; +import { useTranslation } from "react-i18next"; +import { FormField } from "../../../../components/FormField"; +import { FormActionsContainer } from "../../../../components/FormikForm"; +import validateFormik from "../../../../components/formikValidationSchema"; +import { plainTextToEditorValue } from "../../../../util/articleContentConverter"; +import { CopyrightFieldGroup } from "../../../FormikForm"; +import { ImageFormikType, imageRules } from "../../imageTransformers"; +import ImageMetaData from "../ImageMetaData"; + +export const toImageFormValues = ( + common: ImageFormikType | undefined, + specific: ImageFormikType | undefined, + file: File | undefined, + language: string, +): ImageFormikType => { + return { + language: language, + supportedLanguages: [language], + title: specific?.title ?? (file ? plainTextToEditorValue(file.name) : []), + alttext: specific?.alttext ?? common?.alttext ?? "", + caption: specific?.caption ?? common?.caption ?? "", + imageFile: file, + tags: specific?.tags ?? common?.tags ?? [], + creators: specific?.creators ?? common?.creators ?? [], + processors: specific?.processors ?? common?.processors ?? [], + rightsholders: specific?.rightsholders ?? common?.rightsholders ?? [], + processed: specific?.processed ?? common?.processed ?? false, + origin: specific?.origin ?? common?.origin ?? "", + license: specific?.license ?? common?.license, + modelReleased: specific?.modelReleased ?? common?.modelReleased ?? "not-set", + inactive: specific?.inactive ?? common?.inactive ?? false, + }; +}; + +interface CommonProps { + handleSubmit: (values: ImageFormikType) => void; +} + +interface SpecificProps { + file: File; + commonValues: ImageFormikType; + initialValues: ImageFormikType | undefined; + handleSubmit: (values: ImageFormikType) => void; +} + +const StyledForm = styled( + Form, + { + base: { + width: "100%", + display: "flex", + flexDirection: "column", + gap: "medium", + }, + }, + { baseComponent: true }, +); + +export const SpecificImageInfoForm = ({ initialValues, commonValues, file, handleSubmit }: SpecificProps) => { + const { t } = useTranslation(); + + return ( + validateFormik(values, imageRules, t)} + validateOnMount + > + + + ); +}; + +export const CommonImageInfoForm = ({ handleSubmit }: CommonProps) => { + const { i18n } = useTranslation(); + + return ( + + + + ); +}; + +interface FormFieldsProps { + type: "common" | "specific"; +} + +const FormFields = ({ type }: FormFieldsProps) => { + const { t, i18n } = useTranslation(); + return ( + + {type === "specific" && ( + + {({ field, meta }) => ( + + {t("form.title.label")} + + {meta.error} + + )} + + )} + + {({ field, meta }) => ( + + {t("form.image.caption.label")} + + {meta.error} + + )} + + + {({ field, meta }) => ( + + {t("form.image.alt.label")} + + {meta.error} + + )} + + + + + + + + ); +}; diff --git a/src/containers/ImageUploader/components/batch/ImageListItem.tsx b/src/containers/ImageUploader/components/batch/ImageListItem.tsx new file mode 100644 index 0000000000..aefc44e788 --- /dev/null +++ b/src/containers/ImageUploader/components/batch/ImageListItem.tsx @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2026-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { CloseLine, DeleteBinLine, PencilLine } from "@ndla/icons"; +import { IconButton, ListItemContent, ListItemHeading, ListItemRoot } from "@ndla/primitives"; +import { styled } from "@ndla/styled-system/jsx"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ImageFormikType } from "../../imageTransformers"; +import { SpecificImageInfoForm } from "./CommonInfoForm"; + +interface Props { + file: File; + initialValues: ImageFormikType | undefined; + commonData: ImageFormikType; + handleSubmit: (values: ImageFormikType) => void; + onRemoveFile: (file: File) => void; + invalid: boolean; +} + +const InfoContainer = styled("div", { + base: { + display: "flex", + gap: "xsmall", + }, +}); + +const StyledImg = styled("img", { + base: { + minHeight: "50px", + maxHeight: "50px", + minWidth: "70px", + maxWidth: "70px", + objectFit: "cover", + }, +}); + +const StyledListItemRoot = styled(ListItemRoot, { + base: { + flexDirection: "column", + width: "100%", + }, + variants: { + invalid: { + true: { + backgroundColor: "surface.errorSubtle", + borderColor: "stroke.error", + }, + false: {}, + }, + }, +}); + +export const ImageListItem = ({ file, initialValues, commonData, handleSubmit, invalid, onRemoveFile }: Props) => { + const [isEditing, setIsEditing] = useState(false); + const { t } = useTranslation(); + return ( + +
  • + + + + + {file.name} + + + + setIsEditing((prev) => !prev)} + aria-label={isEditing ? t("close") : t("form.edit")} + title={isEditing ? t("close") : t("form.edit")} + > + {isEditing ? : } + + onRemoveFile(file)} + > + + + + + {!!isEditing && ( + + )} +
  • +
    + ); +}; diff --git a/src/containers/ImageUploader/components/imageUtils.ts b/src/containers/ImageUploader/components/imageUtils.ts new file mode 100644 index 0000000000..b103374faf --- /dev/null +++ b/src/containers/ImageUploader/components/imageUtils.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { FileUploadFileError } from "@ark-ui/react"; +import { TFunction } from "i18next"; + +export const translateFileError = (error: FileUploadFileError, t: TFunction) => { + const prefix = t("form.image.fileUpload.genericError"); + if (error === "FILE_TOO_LARGE") { + return `${prefix}: ${t("form.image.fileUpload.tooLargeError")}`; + } else if (error === "FILE_INVALID_TYPE") { + return `${prefix}: ${t("form.image.fileUpload.fileTypeInvalidError")}`; + } else return prefix; +}; diff --git a/src/containers/ImageUploader/imageTransformers.ts b/src/containers/ImageUploader/imageTransformers.ts index a9fa931c4d..d6081a423d 100644 --- a/src/containers/ImageUploader/imageTransformers.ts +++ b/src/containers/ImageUploader/imageTransformers.ts @@ -6,15 +6,68 @@ * */ -import { ImageMetaInformationV3DTO, AuthorDTO } from "@ndla/types-backend/image-api"; +import { + ImageMetaInformationV3DTO, + AuthorDTO, + NewImageMetaInformationV2DTO, + UpdateImageMetaInformationDTO, + LicenseDTO, +} from "@ndla/types-backend/image-api"; import { Descendant } from "slate"; -import { plainTextToEditorValue } from "../../util/articleContentConverter"; +import { RulesType } from "../../components/formikValidationSchema"; +import { editorValueToPlainText, plainTextToEditorValue } from "../../util/articleContentConverter"; + +export const imageRules: RulesType = { + title: { + required: true, + warnings: { + languageMatch: true, + }, + }, + caption: { + warnings: { + languageMatch: true, + }, + }, + alttext: { + required: true, + warnings: { + languageMatch: true, + }, + }, + tags: { + minItems: 3, + warnings: { + languageMatch: true, + }, + }, + creators: { + allObjectFieldsRequired: true, + }, + processors: { + allObjectFieldsRequired: true, + }, + rightsholders: { + allObjectFieldsRequired: true, + }, + imageFile: { + required: true, + }, + license: { + required: true, + test: (values) => { + const authors = values.creators.concat(values.rightsholders).concat(values.processors); + if (!values.license || authors.length > 0) return undefined; + return { translationKey: "validation.noLicenseWithoutCopyrightHolder" }; + }, + }, +}; export interface ImageFormikType { id?: number; - language: string; supportedLanguages: string[]; title: Descendant[]; + language: string; alttext: string; caption: string; /** If undefined, we're creating an image. If string, we're editing an existing image. If blob, the currently active image hasn't been uploaded yet. */ @@ -53,3 +106,44 @@ export const imageApiTypeToFormType = ( inactive: image?.inactive ?? false, }; }; + +export const imageFormTypeToApiType = ( + values: ImageFormikType, + licenses: LicenseDTO[] | undefined, +): (NewImageMetaInformationV2DTO & UpdateImageMetaInformationDTO) | undefined => { + const license = licenses?.find((license) => license.license === values.license); + if ( + license === undefined || + values.title === undefined || + values.alttext === undefined || + values.caption === undefined || + values.language === undefined || + values.tags === undefined || + values.origin === undefined || + values.creators === undefined || + values.processors === undefined || + values.rightsholders === undefined || + values.imageFile === undefined || + values.modelReleased === undefined + ) { + return undefined; + } + + return { + title: editorValueToPlainText(values.title), + alttext: values.alttext, + caption: values.caption, + language: values.language, + tags: values.tags, + inactive: values.inactive, + copyright: { + license, + origin: values.origin, + creators: values.creators, + processors: values.processors, + rightsholders: values.rightsholders, + processed: values.processed, + }, + modelReleased: values.modelReleased, + }; +}; diff --git a/src/containers/Masthead/components/MastheadDrawer.tsx b/src/containers/Masthead/components/MastheadDrawer.tsx index 01d0c4f749..a947d41361 100644 --- a/src/containers/Masthead/components/MastheadDrawer.tsx +++ b/src/containers/Masthead/components/MastheadDrawer.tsx @@ -28,6 +28,7 @@ import { AUDIO_ADMIN_SCOPE, DRAFT_ADMIN_SCOPE, FRONTPAGE_ADMIN_SCOPE, + IMAGE_BATCH_SCOPE, LEARNING_PATH_ADMIN_SCOPE, TAXONOMY_ADMIN_SCOPE, } from "../../../constants"; @@ -161,6 +162,7 @@ const adminItems: MenuItem[] = [ { to: routes.podcastSeries.create, text: "subNavigation.podcastSeries", permission: AUDIO_ADMIN_SCOPE }, { to: routes.updateCodes, text: "subNavigation.updateCodes", permission: DRAFT_ADMIN_SCOPE }, { to: routes.learningpath.samples, text: "subNavigation.learningStepSamples", permission: LEARNING_PATH_ADMIN_SCOPE }, + { to: routes.image.batch, text: "subNavigation.batchImageUpload", permission: IMAGE_BATCH_SCOPE }, ]; const externalItems: MenuItem[] = [{ to: routes.h5p.edit, text: "subNavigation.h5p", external: true }]; diff --git a/src/phrases/phrases-en.ts b/src/phrases/phrases-en.ts index 78625bd892..a229d9686f 100644 --- a/src/phrases/phrases-en.ts +++ b/src/phrases/phrases-en.ts @@ -32,6 +32,7 @@ const phrases = { editFrontpage: "Edit front page", comparePage: `Compare versions ${titleTemplate}`, notFoundPage: `Not found ${titleTemplate}`, + batchUploadImagePage: `Batch upload images ${titleTemplate}`, search: { "podcast-series": `Search podcast series ${titleTemplate}`, audio: `Search audio files ${titleTemplate}`, @@ -350,6 +351,7 @@ const phrases = { frontpage: "NDLA frontpage", updateCodes: "Update curriculum codes", learningStepSamples: "External learning step samples", + batchImageUpload: "Batch upload images", }, logo: { altText: "The Norwegian Digital Learning Arena", @@ -2569,6 +2571,21 @@ const phrases = { linksTo: "Links to ", inPath: 'In learning path "{{title}}"', }, + batchUploadImagePage: { + heading: "Batch upload images", + description: "This page allows you to upload multiple images at once, and set common metadata for all the images.", + commonMetaHeading: "Common metadata for all images", + commonMetaHeadingDescription: + "The metadata you set here will be applied to all the images you upload. Metadata that is not specified here must be specified for each image individually. You can change the metadata here after having uploaded images, but be careful! If you have overridden the common metadata for a specific image, changing the common metadata will not change the metadata for that specific image.", + uploadImages: "Upload images", + saveCommon: "Save common metadata", + uploadedImages: "Uploaded images", + specificImageDescription: "Here you can change metadata for a specific image.", + hasImagesWithErrors: + "One of the images you have uploaded has errors. You have to fix the errors before the images can be created.", + createImages: "Create images", + rejectedFiles: "Rejected files", + }, }; export default phrases; diff --git a/src/phrases/phrases-nb.ts b/src/phrases/phrases-nb.ts index ad1d32c2c3..cfc63ba63d 100644 --- a/src/phrases/phrases-nb.ts +++ b/src/phrases/phrases-nb.ts @@ -32,6 +32,7 @@ const phrases = { editFrontpage: "Rediger forside", comparePage: `Sammenlign versjoner ${titleTemplate}`, notFoundPage: `Siden finnes ikke ${titleTemplate}`, + batchUploadImagePage: `Batch-opplasting av bilder ${titleTemplate}`, search: { "podcast-series": `Søk podkastserier ${titleTemplate}`, audio: `Søk lydfiler ${titleTemplate}`, @@ -349,6 +350,7 @@ const phrases = { frontpage: "NDLA forside", updateCodes: "Oppdater læreplankoder", learningStepSamples: "Stikkprøver av eksterne læringssteg", + batchImageUpload: "Batch-opplasting av bilder", }, logo: { altText: "Nasjonal digital læringsarena", @@ -2567,6 +2569,21 @@ const phrases = { linksTo: "Lenker til ", inPath: 'I læringsstien "{{title}}"', }, + batchUploadImagePage: { + heading: "Batch-opplastning av bilder", + description: "Denne siden lar deg spesifisere felles metadata for et sett med bilder.", + commonMetaHeading: "Felles metadata for alle bilder", + commonMetaHeadingDescription: + "Disse metadataene vil bli brukt for alle bildene du laster opp. Metadata som ikke spesifiseres her må spesifiseres for hvert bilde individuelt. Du kan oppdatere metadata her etter å ha lastet opp bilder, men vær varsom! Dersom du har overstyrt metadata for et spesifikt bilde vil ikke oppdateringene her gjelde for det bildet.", + uploadImages: "Last opp bilder", + saveCommon: "Lagre felles metadata", + uploadedImages: "Opplastede bilder", + specificImageDescription: "Her kan du endre metadata for et spesifikt bilde.", + hasImagesWithErrors: + "Et av bildene du har lastet opp inneholder feil. Du må fikse feilene før bildene kan opprettes.", + createImages: "Opprett bilder", + rejectedFiles: "Avviste filer", + }, }; export default phrases; diff --git a/src/phrases/phrases-nn.ts b/src/phrases/phrases-nn.ts index 766670902a..88d34bc39c 100644 --- a/src/phrases/phrases-nn.ts +++ b/src/phrases/phrases-nn.ts @@ -32,6 +32,7 @@ const phrases = { editFrontpage: "Rediger forside", comparePage: `Samanlikne versjonar ${titleTemplate}`, notFoundPage: `Sida finst ikkje ${titleTemplate}`, + batchUploadImagePage: `Batch-opplasting av bileta ${titleTemplate}`, search: { "podcast-series": `Søk podkastserier ${titleTemplate}`, audio: `Søk lydfiler ${titleTemplate}`, @@ -349,6 +350,7 @@ const phrases = { frontpage: "NDLA forside", updateCodes: "Oppdater læreplankoder", learningStepSamples: "Stikkprøver av eksterne læringssteg", + batchImageUpload: "Batch-opplasting av bileter", }, logo: { altText: "Nasjonal digital læringsarena", @@ -2570,6 +2572,21 @@ const phrases = { linksTo: "Lenkar til ", inPath: 'I læringsstien "{{title}}"', }, + batchUploadImagePage: { + heading: "Batch-opplasting av bilete", + description: "Denne sida lar deg spesifisere felles metadata for eit sett med bilete.", + commonMetaHeading: "Felles metadata for alle bilete", + commonMetaHeadingDescription: + "Disse metadataene vil bli brukt for alle biletene du lastar opp. Metadata som ikkje spesifiserast her må spesifiserast for kvart bilete individuelt. Du kan oppdatere metadata her etter å ha lasta opp bileter, men vær varsom! Dersom du har overstyrt metadata for eit spesifikt bilete vil ikkje oppdateringane her gjelde for det biletet.", + uploadImages: "Last opp bilete", + saveCommon: "Lagre felles metadata", + uploadedImages: "Opplasta bilete", + specificImageDescription: "Her kan du endre metadata for eit spesifikt bilete.", + hasImagesWithErrors: + "Eit av biletene du har lasta opp innehald feil. Du må fikse feilane før biletene kan opprettast.", + createImages: "Opprett bilete", + rejectedFiles: "Avviste filar", + }, }; export default phrases; diff --git a/src/routes.tsx b/src/routes.tsx index 93e83f92a8..3b19238e5c 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -185,6 +185,10 @@ export const routes: RouteObject[] = [ path: "new", lazy: () => import("./containers/ImageUploader/CreateImage"), }, + { + path: "batch", + lazy: () => import("./containers/ImageUploader/BatchUploadImagePage"), + }, { path: ":id/edit", lazy: () => import("./containers/ImageUploader/ImageRedirect"), diff --git a/src/util/routeHelpers.ts b/src/util/routeHelpers.ts index 8f9c10fbd3..d415c3d41e 100644 --- a/src/util/routeHelpers.ts +++ b/src/util/routeHelpers.ts @@ -91,6 +91,7 @@ export const routes = { image: { create: "/media/image-upload/new", edit: toEditImage, + batch: "/media/image-upload/batch", }, preview: { draft: toPreviewDraft,