diff --git a/package.json b/package.json index fb618dd2bf..aa6f1d7189 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "devDependencies": { "@babel/core": "^7.29.0", "@ndla/preset-panda": "^0.0.76", - "@ndla/types-backend": "^1.0.125", + "@ndla/types-backend": "^1.0.130", "@ndla/types-embed": "^5.0.22-alpha.0", "@pandacss/dev": "^1.10.0", "@playwright/test": "^1.57.0", diff --git a/src/constants.ts b/src/constants.ts index 429f5ea37d..8b1fbb0a42 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_BULK_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"; @@ -255,3 +257,5 @@ export const Revision = { revised: "revised" as RevisionType, needsRevision: "needs-revision" as RevisionType, }; + +export const ALLOWED_IMAGE_FILE_TYPES = ["image/gif", "image/png", "image/jpeg", "image/svg+xml"]; diff --git a/src/containers/ImageUploader/BulkUploadImagePage.tsx b/src/containers/ImageUploader/BulkUploadImagePage.tsx new file mode 100644 index 0000000000..e4c588e907 --- /dev/null +++ b/src/containers/ImageUploader/BulkUploadImagePage.tsx @@ -0,0 +1,225 @@ +/** + * 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 { Heading, PageContainer, Text } from "@ndla/primitives"; +import { styled } from "@ndla/styled-system/jsx"; +import { BulkUploadStartedDTO, BulkUploadStateDTO, 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 SaveButton from "../../components/SaveButton"; +import { IMAGE_BULK_SCOPE } from "../../constants"; +import { useLicenses } from "../../modules/draft/draftQueries"; +import { bulkUploadImages } from "../../modules/image/imageApi"; +import NotFound from "../NotFoundPage/NotFoundPage"; +import PrivateRoute from "../PrivateRoute/PrivateRoute"; +import { useSession } from "../Session/SessionProvider"; +import { BulkImageUploader } from "./components/bulk/BulkImageUploader"; +import { CommonImageInfoForm, toImageFormValues } from "./components/bulk/CommonInfoForm"; +import { ImageListItem } from "./components/bulk/ImageListItem"; +import { ImageFormikType, imageFormTypeToApiType, imageRules } from "./imageTransformers"; +import { useImageUploadStatus } from "./useImageUploadStatus"; + +const StyledList = styled("ul", { + base: { + display: "flex", + flexDirection: "column", + gap: "xsmall", + }, +}); + +const StyledPageContainer = styled(PageContainer, { + base: { + gap: "medium", + }, +}); + +export const Component = () => { + return } />; +}; + +export const BulkUploadImagePage = () => { + const [bulkUploadId, setBulkUploadId] = useState(undefined); + const [acceptedFiles, setAcceptedFiles] = useState([]); + const [commonMetadata, setCommonMetadata] = useState(undefined); + const [specifiedMetadata, setSpecifiedMetadata] = useState>({}); + const [invalidFiles, setInvalidFiles] = useState>({}); + const [hasImageWithErrors, setHasImageWithErrors] = useState(false); + const uploadState = useImageUploadStatus(bulkUploadId?.uploadId); + + 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 || uploadState?.status === "Complete") return; + setBulkUploadId(undefined); + 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), f]; + }); + + const res = await bulkUploadImages(metadatas, acceptedFiles); + setBulkUploadId(res); + + return transformed; + }; + + if (!userPermissions?.includes(IMAGE_BULK_SCOPE)) { + return ; + } + + return ( + + {t("htmlTitles.bulkUploadImagePage")} + {t("bulkUploadImagePage.heading")} + {t("bulkUploadImagePage.description")} + +

{t("bulkUploadImagePage.commonMetaHeading")}

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

{t("bulkUploadImagePage.uploadImages")}

+
+ + +

{t("bulkUploadImagePage.uploadedImages")}

+
+ {t("bulkUploadImagePage.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("bulkUploadImagePage.hasImagesWithErrors")} + ))} + + + {t("bulkUploadImagePage.createImages")} + + + {!!uploadState && !!bulkUploadId && } + + )} +
+ ); +}; + +interface BulkUploadStateProps { + state: BulkUploadStateDTO; +} + +const TextContainer = styled("div", { + base: { + display: "flex", + flexDirection: "column", + gap: "4xsmall", + }, +}); + +const BulkUploadState = ({ state }: BulkUploadStateProps) => { + const { t } = useTranslation(); + + if (state.status === "Pending" || state.status === "Running") { + return ( + + {t("bulkUploadImagePage.uploadInProgress")} + {t("bulkUploadImagePage.progressText", { completed: state.completed, total: state.total })} + {!!state.failed && {t("bulkUploadImagePage.progressFailed", { failed: state.failed })}} + + ); + } + + if (state.status === "Failed") { + return ( + + + {t("bulkUploadImagePage.uploadFailed", { + completed: state.completed, + total: state.total, + failed: state.failed, + })} + + + ); + } else if (state.status === "Complete" && state.failed) { + return {t("bulkUploadImagePage.uploadCompletedWithFailed", { failed: state.failed })}; + } else if (state.status === "Complete") { + return {t("bulkUploadImagePage.uploadCompleted", { total: state.completed })}; + } +}; 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..7d6b9111c2 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 { ALLOWED_IMAGE_FILE_TYPES, MAX_IMAGE_UPLOAD_SIZE } from "../../../constants"; import { ImageFormikType } from "../imageTransformers"; +import { translateFileError } from "./imageUtils"; const StyledImg = styled("img", { base: { @@ -100,7 +102,7 @@ export const ImageUploadFormElement = ({ language, image }: Props) => { {!field.value && ( { const file = details.files?.[0]; if (!file) return; @@ -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/bulk/BulkImageUploader.tsx b/src/containers/ImageUploader/components/bulk/BulkImageUploader.tsx new file mode 100644 index 0000000000..495ce4233a --- /dev/null +++ b/src/containers/ImageUploader/components/bulk/BulkImageUploader.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 { ALLOWED_IMAGE_FILE_TYPES, 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 BulkImageUploader = ({ onFileAccept, acceptedFiles }: Props) => { + const { t } = useTranslation(); + return ( + onFileAccept(details.files)} + > + + {t("form.image.fileUpload.description")} + + + + + + + {({ rejectedFiles }) => + rejectedFiles.length ? ( + + {t("bulkImageUploadPage.rejectedFiles")} + + {rejectedFiles.map((file) => ( + +
  • + {file.file.name} + {uniq(file.errors.map((err) => translateFileError(err, t))).join(", ")} +
  • +
    + ))} +
    +
    + ) : null + } +
    +
    + ); +}; diff --git a/src/containers/ImageUploader/components/bulk/CommonInfoForm.tsx b/src/containers/ImageUploader/components/bulk/CommonInfoForm.tsx new file mode 100644 index 0000000000..f420b77ce2 --- /dev/null +++ b/src/containers/ImageUploader/components/bulk/CommonInfoForm.tsx @@ -0,0 +1,152 @@ +/** + * 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, FieldLabel, FieldRoot, FieldTextArea } from "@ndla/primitives"; +import { styled } from "@ndla/styled-system/jsx"; +import { Form, Formik, useFormikContext } 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 Titlefield from "../../../FormikForm/TitleField"; +import { ImageFormikType, imageRules } from "../../imageTransformers"; +import ImageMetaData from "../ImageMetaData"; +const ACCEPTED_EXTENSIONS = new Set([".gif", ".png", ".jpg", ".jpeg", ".svg"]); + +const stripFileExtension = (name: string) => { + const lastDot = name.lastIndexOf("."); + if (lastDot === -1) return name; + + const ext = name.slice(lastDot).toLowerCase(); + if (!ACCEPTED_EXTENSIONS.has(ext)) return name; + + return name.slice(0, lastDot); +}; + +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(stripFileExtension(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 FormFieldsContainer = styled("div", { + base: { + width: "100%", + display: "flex", + flexDirection: "column", + gap: "medium", + }, +}); + +const StyledForm = styled( + Form, + { + base: { + width: "100%", + }, + }, + { 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(); + const { handleSubmit, errors } = useFormikContext(); + return ( + + {type === "specific" && } + + {({ 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/bulk/ImageListItem.tsx b/src/containers/ImageUploader/components/bulk/ImageListItem.tsx new file mode 100644 index 0000000000..077ae7a110 --- /dev/null +++ b/src/containers/ImageUploader/components/bulk/ImageListItem.tsx @@ -0,0 +1,105 @@ +/** + * 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 && ( + { + handleSubmit(values); + setIsEditing(false); + }} + /> + )} +
  • +
    + ); +}; 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/ImageUploader/useImageUploadStatus.tsx b/src/containers/ImageUploader/useImageUploadStatus.tsx new file mode 100644 index 0000000000..03bb84f61b --- /dev/null +++ b/src/containers/ImageUploader/useImageUploadStatus.tsx @@ -0,0 +1,57 @@ +/** + * 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 { BulkUploadStateDTO } from "@ndla/types-backend/image-api"; +import { useEffect, useState } from "react"; +import { getBulkUploadStatus } from "../../modules/image/imageApi"; + +const initiateBulkUploadStatus = async ( + bulkUploadId: string, + signal: AbortSignal, + onEvent: (event: BulkUploadStateDTO) => void, +) => { + const res = await getBulkUploadStatus(bulkUploadId, signal); + if (!res.body) { + throw new Error("Missing response body"); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + onEvent(JSON.parse(line.replace("data: ", ""))); + } + } + } +}; + +export const useImageUploadStatus = (bulkUploadId: string | undefined) => { + const [event, setEvent] = useState(undefined); + + useEffect(() => { + if (!bulkUploadId) return; + const controller = new AbortController(); + initiateBulkUploadStatus(bulkUploadId, controller.signal, setEvent); + + return () => controller.abort(); + }, [bulkUploadId]); + + return event; +}; diff --git a/src/containers/Masthead/components/MastheadDrawer.tsx b/src/containers/Masthead/components/MastheadDrawer.tsx index 01d0c4f749..9e38d69475 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_BULK_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.bulk, text: "subNavigation.bulkImageUpload", permission: IMAGE_BULK_SCOPE }, ]; const externalItems: MenuItem[] = [{ to: routes.h5p.edit, text: "subNavigation.h5p", external: true }]; diff --git a/src/modules/image/imageApi.ts b/src/modules/image/imageApi.ts index 979e5a7ee7..23c46f56b2 100644 --- a/src/modules/image/imageApi.ts +++ b/src/modules/image/imageApi.ts @@ -14,8 +14,9 @@ import { TagsSearchResultDTO, SearchParamsDTO, NewImageMetaInformationV2DTO, + BulkUploadStartedDTO, } from "@ndla/types-backend/image-api"; -import { throwErrorPayload, createAuthClient } from "../../util/apiHelpers"; +import { throwErrorPayload, createAuthClient, fetchAuthorized, apiResourceUrl } from "../../util/apiHelpers"; import { createFormData } from "../../util/formDataHelper"; import { resolveJsonOATS, resolveOATS } from "../../util/resolveJsonOrRejectWithError"; @@ -129,3 +130,34 @@ export const cloneImage = async (imageId: number, file: Blob): Promise resolveJsonOATS(r)); + +export const bulkUploadImages = async ( + metadatas: NewImageMetaInformationV2DTO[], + files: Blob[], +): Promise => { + const res = await client.POST("/image-api/v1/bulk", { + body: { + metadatas: metadatas, + files: files, + }, + bodySerializer(body) { + const form = new FormData(); + metadatas.forEach((metadata, idx) => { + form.append("metadatas", JSON.stringify(metadata)); + form.append("files", body.files[idx]); + }); + return form; + }, + }); + + return resolveJsonOATS(res); +}; + +export const getBulkUploadStatus = async (uploadId: string, signal: AbortSignal) => { + return await fetchAuthorized(apiResourceUrl(`/image-api/v1/bulk/status/${uploadId}`), { + signal, + headers: { + "Content-Type": "text/event-stream", + }, + }); +}; diff --git a/src/phrases/phrases-en.ts b/src/phrases/phrases-en.ts index 78625bd892..95935fcee0 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}`, + bulkUploadImagePage: `Bulk 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", + bulkImageUpload: "Bulk upload images", }, logo: { altText: "The Norwegian Digital Learning Arena", @@ -2569,6 +2571,28 @@ const phrases = { linksTo: "Links to ", inPath: 'In learning path "{{title}}"', }, + bulkUploadImagePage: { + heading: "Bulk 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", + uploadInProgress: "Uploading images...", + progressText: "Uploaded {{completed}} of {{total}} images", + progressFailed_one: "Error uploading one image", + progressFailed_other: "Error uploading {{count}} images", + uploadCompletedWithFailed_one: "Image upload completed with one error", + uploadCompletedWithFailed_other: "Image upload completed with {{count}} errors", + uploadCompleted: "Image upload completed!", + }, }; export default phrases; diff --git a/src/phrases/phrases-nb.ts b/src/phrases/phrases-nb.ts index ad1d32c2c3..5399f7d43a 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}`, + bulkUploadImagePage: `Multi-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", + bulkImageUpload: "Multi-opplasting av bilder", }, logo: { altText: "Nasjonal digital læringsarena", @@ -2567,6 +2569,28 @@ const phrases = { linksTo: "Lenker til ", inPath: 'I læringsstien "{{title}}"', }, + bulkUploadImagePage: { + heading: "Multi-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", + uploadInProgress: "Laster opp bilder...", + progressText: "{{completed}} av {{total}} bilder lastet opp", + progressFailed_one: "Feil ved opplastning av ett bilde", + progressFailed_other: "Feil ved opplastning av {{count}} bilder", + uploadCompletedWithFailed_one: "Opplastning av bilder er fullført med en feil", + uploadCompletedWithFailed_other: "Opplastning av bilder er fullført med {{count}} feil", + uploadCompleted: "Opplastning fullført!", + }, }; export default phrases; diff --git a/src/phrases/phrases-nn.ts b/src/phrases/phrases-nn.ts index 766670902a..393d65a865 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}`, + bulkUploadImagePage: `Multi-opplasting av bilete ${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", + bulkImageUpload: "Multi-opplasting av bilete", }, logo: { altText: "Nasjonal digital læringsarena", @@ -2570,6 +2572,27 @@ const phrases = { linksTo: "Lenkar til ", inPath: 'I læringsstien "{{title}}"', }, + bulkUploadImagePage: { + heading: "Multi-opplasting av bilete", + description: "Denne sida lar deg spesifisere felles metadata for eit sett med bilete.", + commonMetaHeading: "Felles metadata for alle bilete", + commonMetaHeadingDescription: + "Disse metadataa vil bli brukt for alle bilete du lastar opp. Metadata som ikkje spesifiserast her må spesifiserast for kvart bilete individuelt. Du kan oppdatere metadata her etter å ha lasta opp bilete, men vær varsam! 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 bileta du har lasta opp inneheld feil. Du må fikse feila før bileta kan opprettast.", + createImages: "Opprett bilete", + rejectedFiles: "Avviste filer", + uploadInProgress: "Lastar opp bilete", + progressText: "{{completed}} av {{total}} bilete lasta opp", + progressFailed_one: "Feil ved opplasting av eit bilete", + progressFailed_other: "Feil ved opplasting av {{count}} bilete", + uploadCompletedWithFailed_one: "Opplasting av bileter er fullført med ein feil", + uploadCompletedWithFailed_other: "Opplasting av bileter er fullført med {{count}} feil", + uploadCompleted: "Opplasting fullført!", + }, }; export default phrases; diff --git a/src/routes.tsx b/src/routes.tsx index 93e83f92a8..400b4fe16c 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: "bulk", + lazy: () => import("./containers/ImageUploader/BulkUploadImagePage"), + }, { path: ":id/edit", lazy: () => import("./containers/ImageUploader/ImageRedirect"), diff --git a/src/util/routeHelpers.ts b/src/util/routeHelpers.ts index 8f9c10fbd3..05dd6e0c30 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, + bulk: "/media/image-upload/bulk", }, preview: { draft: toPreviewDraft, diff --git a/yarn.lock b/yarn.lock index 13b9ff54ae..87c485e2f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2612,10 +2612,10 @@ __metadata: languageName: node linkType: hard -"@ndla/types-backend@npm:^1.0.125": - version: 1.0.125 - resolution: "@ndla/types-backend@npm:1.0.125" - checksum: 10c0/c82b1934f56e88f2ebea64f40b98b8ada2f57d2222c95583b11fc036180811ab9d3e79e3574fcc9999a5d6d1b592f64b92591789e8338730c725e0d7ecd12509 +"@ndla/types-backend@npm:^1.0.130": + version: 1.0.130 + resolution: "@ndla/types-backend@npm:1.0.130" + checksum: 10c0/f4c541e1779fd37e53400be8229846cf27a297375502635373e61a4a06a4276c4829e8657574d054dfea8617027473d5aa43b220e4264080f7afb90005eafa7c languageName: node linkType: hard @@ -7089,7 +7089,7 @@ __metadata: "@ndla/primitives": "npm:^1.0.128-alpha.0" "@ndla/safelink": "npm:^7.0.131-alpha.0" "@ndla/styled-system": "npm:^0.0.49" - "@ndla/types-backend": "npm:^1.0.125" + "@ndla/types-backend": "npm:^1.0.130" "@ndla/types-embed": "npm:^5.0.22-alpha.0" "@ndla/ui": "npm:^56.0.190-alpha.0" "@ndla/util": "npm:^5.0.21-alpha.0"