diff --git a/Classes/GraphQL/Resolver/Type/MutationResolver.php b/Classes/GraphQL/Resolver/Type/MutationResolver.php index b527f193e..994c45a02 100644 --- a/Classes/GraphQL/Resolver/Type/MutationResolver.php +++ b/Classes/GraphQL/Resolver/Type/MutationResolver.php @@ -362,9 +362,13 @@ public function uploadFile($_, array $variables): array $file = $variables['file']; $tagId = $variables['tagId'] ?? null; $assetCollectionId = $variables['assetCollectionId'] ?? null; + $copyrightNotice = $variables['uploadProperties']['copyrightNotice'] ?? ''; + $title = $variables['uploadProperties']['title'] ?? ''; + $caption = $variables['uploadProperties']['caption'] ?? ''; $success = false; $result = 'ERROR'; + $assetUuid = ''; $filename = $file->getClientFilename(); try { @@ -387,6 +391,16 @@ public function uploadFile($_, array $variables): array $asset = new $className($resource); if ($this->persistenceManager->isNewObject($asset)) { + if ($copyrightNotice !== '') { + $asset->setCopyrightNotice($copyrightNotice); + } + if ($title !== '') { + $asset->setTitle($title); + } + if ($caption !== '') { + $asset->setCaption($caption); + } + if ($tagId) { /** @var Tag $tag */ $tag = $this->tagRepository->findByIdentifier($tagId); @@ -408,6 +422,7 @@ public function uploadFile($_, array $variables): array } else { $result = 'EXISTS'; } + $assetUuid = $asset->getIdentifier(); } catch (IllegalObjectTypeException $e) { $this->systemLogger->error('Type of uploaded file cannot be stored'); } @@ -419,6 +434,7 @@ public function uploadFile($_, array $variables): array 'filename' => $filename, 'success' => $success, 'result' => $result, + 'assetId' => $assetUuid, ]; } @@ -431,6 +447,15 @@ public function uploadFiles($_, array $variables): array { /** @var array $files */ $files = $variables['files']; + $uploadProperties = $variables['uploadProperties']; + $sortedUploadProperties = []; + foreach ($uploadProperties as $property) { + $sortedUploadProperties[$property['filename']] = [ + 'copyrightNotice' => $property['copyrightNotice'] ?? '', + 'title' => $property['title'] ?? '', + 'caption' => $property['caption'] ?? '', + ]; + } $tagId = $variables['tagId'] ?? null; $assetCollectionId = $variables['assetCollectionId'] ?? null; @@ -440,6 +465,7 @@ public function uploadFiles($_, array $variables): array 'file' => $file, 'tagId' => $tagId, 'assetCollectionId' => $assetCollectionId, + 'uploadProperties' => $sortedUploadProperties[$file->getClientFilename()] ?? '', ]); } return $results; @@ -461,7 +487,8 @@ public function replaceAsset($_, array $variables, AssetSourceContext $assetSour 'options' => [ 'generateRedirects' => $generateRedirects, 'keepOriginalFilename' => $keepOriginalFilename - ] + ], + 'uploadProperties' => $uploadProperties ] = $variables; $assetProxy = $assetSourceContext->getAssetProxy($id, $assetSourceId); @@ -480,6 +507,7 @@ public function replaceAsset($_, array $variables, AssetSourceContext $assetSour $success = false; $result = 'ERROR'; + $assetUuid = $asset->getIdentifier(); $sourceMediaType = MediaTypes::parseMediaType($asset->getMediaType()); $replacementMediaType = MediaTypes::parseMediaType($file->getClientMediaType()); $filename = $file->getClientFilename(); @@ -491,6 +519,7 @@ public function replaceAsset($_, array $variables, AssetSourceContext $assetSour 'filename' => $filename, 'success' => false, 'result' => $result, + 'assetId' => $assetUuid, ]; } @@ -510,8 +539,23 @@ public function replaceAsset($_, array $variables, AssetSourceContext $assetSour 'generateRedirects' => $generateRedirects, 'keepOriginalFilename' => $keepOriginalFilename ]); + $copyrightNotice = $uploadProperties['copyrightNotice'] ?? ''; + $title = $uploadProperties['title'] ?? ''; + $caption = $uploadProperties['caption'] ?? ''; + + if ($copyrightNotice !== '') { + $asset->setCopyrightNotice($copyrightNotice); + } + if ($title !== '') { + $asset->setTitle($title); + } + if ($caption !== '') { + $asset->setCaption($caption); + } + $success = true; $result = 'REPLACED'; + $assetUuid = $asset->getIdentifier(); } catch (\Exception $exception) { $this->systemLogger->error(sprintf('Asset %s could not be replaced', $asset->getIdentifier()), [$exception]); } @@ -521,6 +565,7 @@ public function replaceAsset($_, array $variables, AssetSourceContext $assetSour 'filename' => $filename, 'success' => $success, 'result' => $result, + 'assetId' => $assetUuid, ]; } diff --git a/Classes/GraphQL/Resolver/Type/QueryResolver.php b/Classes/GraphQL/Resolver/Type/QueryResolver.php index 44773a07f..3c7830f2a 100644 --- a/Classes/GraphQL/Resolver/Type/QueryResolver.php +++ b/Classes/GraphQL/Resolver/Type/QueryResolver.php @@ -182,6 +182,7 @@ public function config($_): array 'uploadMaxFileSize' => $this->getMaximumFileUploadSize(), 'uploadMaxFileUploadLimit' => $this->getMaximumFileUploadLimit(), 'currentServerTime' => (new \DateTime())->format(DATE_W3C), + 'uploadProperties' => $this->getUploadProperties(), ]; } @@ -208,6 +209,24 @@ protected function getMaximumFileUploadLimit(): int return (int)($this->settings['maximumFileUploadLimit'] ?? 10); } + /** + * Returns the additionally required fields for the file upload like the copyright notice + * + * @return string[] + */ + protected function getUploadProperties(): array + { + $data = []; + foreach ($this->settings['upload']['properties'] as $fieldName => $config) { + $data[] = [ + 'name' => $fieldName, + 'show' => $config['show'] ?? false, + 'required' => $config['required'] ?? false, + ]; + } + return $data; + } + /** * Provides a filterable list of asset proxies. These are the main entities for media management. */ diff --git a/Configuration/Settings.Features.yaml b/Configuration/Settings.Features.yaml index 0c77c98b9..f2341f19a 100644 --- a/Configuration/Settings.Features.yaml +++ b/Configuration/Settings.Features.yaml @@ -3,6 +3,8 @@ Neos: Ui: frontendConfiguration: Flowpack.Media.Ui: + # Use the new upload dialog where the user can add additional information like caption or copy right notice when uploading the asset + useNewAssetUpload: true # Allow the user to let the system create redirects when assets are replaced or renamed createAssetRedirectsOption: true # Only allow a single asset collection selection per asset to treat collection like folders diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 6f687b629..d93771aa2 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -2,3 +2,15 @@ Flowpack: Media: Ui: maximumFileUploadLimit: 10 + # Configure which properties in the new upload dialog are show and which are required + upload: + properties: + copyrightNotice: + show: true + required: false + title: + show: true + required: false + caption: + show: true + required: false diff --git a/Readme.md b/Readme.md index a8af5f4fe..fb287f346 100644 --- a/Readme.md +++ b/Readme.md @@ -148,6 +148,29 @@ Neos: Flowpack.Media.Ui: createAssetRedirectsOption: false ``` + +### Add additional field to the upload dialog + +You can add additional fields to the upload dialog to add property data while uploading a file. Add the moment the fields title, caption and copyright notice can be used. +This additional the fields can be configured to be required for the upload. See the following settings example: + + +```yaml +Flowpack: + Media: + Ui: + upload: + properties: + copyrightNotice: + show: true + required: true + title: + show: true + required: false + caption: + show: true + required: false +``` ## Architecture diff --git a/Resources/Private/GraphQL/schema.root.graphql b/Resources/Private/GraphQL/schema.root.graphql index 41114f4ee..3ce423cde 100644 --- a/Resources/Private/GraphQL/schema.root.graphql +++ b/Resources/Private/GraphQL/schema.root.graphql @@ -119,6 +119,7 @@ type Mutation { assetSourceId: AssetSourceId! file: Upload! options: AssetReplacementOptionsInput! + uploadProperties: UploadProperties ): FileUploadResult! editAsset( @@ -142,7 +143,7 @@ type Mutation { uploadFile(file: Upload!, tagId: TagId, assetCollectionId: AssetCollectionId): FileUploadResult! - uploadFiles(files: [Upload!]!, tagId: TagId, assetCollectionId: AssetCollectionId): [FileUploadResult!]! + uploadFiles(files: [Upload!]!, tagId: TagId, assetCollectionId: AssetCollectionId, uploadProperties: [UploadProperties]): [FileUploadResult!]! importAsset(id: AssetId!, assetSourceId: AssetSourceId!): Asset! @@ -164,6 +165,13 @@ type Config { uploadMaxFileSize: FileSize! uploadMaxFileUploadLimit: Int! currentServerTime: DateTime! + uploadProperties: [UploadPropertiesConfig] +} + +type UploadPropertiesConfig { + name: String! + show: Boolean! + required: Boolean! } """ @@ -335,6 +343,7 @@ type FileUploadResult { filename: Filename! success: Boolean! result: String! + assetId: AssetId! } """ @@ -517,6 +526,13 @@ Variant Name """ scalar VariantName +input UploadProperties { + filename: String! + copyrightNotice: String + title: String + caption: String +} + schema { query: Query mutation: Mutation diff --git a/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts b/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts index 62ec7bd50..ebc20bb37 100644 --- a/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts +++ b/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts @@ -1,5 +1,6 @@ type AssetCollectionType = 'AssetCollection'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars interface AssetCollection extends GraphQlEntity { __typename: AssetCollectionType; readonly id: string; diff --git a/Resources/Private/JavaScript/asset-collections/typings/TreeNodeProps.ts b/Resources/Private/JavaScript/asset-collections/typings/TreeNodeProps.ts index 795a91ec8..bf629b492 100644 --- a/Resources/Private/JavaScript/asset-collections/typings/TreeNodeProps.ts +++ b/Resources/Private/JavaScript/asset-collections/typings/TreeNodeProps.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars interface TreeNodeProps { title?: string; label?: string; diff --git a/Resources/Private/JavaScript/asset-sources/typings/AssetSource.ts b/Resources/Private/JavaScript/asset-sources/typings/AssetSource.ts index a4ab7b3fc..a3b5d4c4d 100644 --- a/Resources/Private/JavaScript/asset-sources/typings/AssetSource.ts +++ b/Resources/Private/JavaScript/asset-sources/typings/AssetSource.ts @@ -2,6 +2,7 @@ type AssetSourceType = 'AssetSource'; type AssetSourceId = string; +// eslint-disable-next-line @typescript-eslint/no-unused-vars interface AssetSource extends GraphQlEntity { __typename: AssetSourceType; readonly id: AssetSourceId; diff --git a/Resources/Private/JavaScript/asset-tags/typings/Tag.ts b/Resources/Private/JavaScript/asset-tags/typings/Tag.ts index 3a7d1f862..9921bb7d9 100644 --- a/Resources/Private/JavaScript/asset-tags/typings/Tag.ts +++ b/Resources/Private/JavaScript/asset-tags/typings/Tag.ts @@ -1,5 +1,6 @@ type TagType = 'Tag'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars interface Tag extends GraphQlEntity { __typename: TagType; id: string; diff --git a/Resources/Private/JavaScript/asset-upload-screen/Readme.md b/Resources/Private/JavaScript/asset-upload-screen/Readme.md new file mode 100644 index 000000000..381145e6d --- /dev/null +++ b/Resources/Private/JavaScript/asset-upload-screen/Readme.md @@ -0,0 +1 @@ +This subpackage provides the media upload screen / secondary inspector for the Neos content module. diff --git a/Resources/Private/JavaScript/asset-upload-screen/index.js b/Resources/Private/JavaScript/asset-upload-screen/index.js new file mode 100644 index 000000000..8420b1093 --- /dev/null +++ b/Resources/Private/JavaScript/asset-upload-screen/index.js @@ -0,0 +1 @@ +export * from './src'; diff --git a/Resources/Private/JavaScript/asset-upload-screen/package.json b/Resources/Private/JavaScript/asset-upload-screen/package.json new file mode 100644 index 000000000..b98620223 --- /dev/null +++ b/Resources/Private/JavaScript/asset-upload-screen/package.json @@ -0,0 +1,24 @@ +{ + "name": "@media-ui/asset-upload-screen", + "version": "1.0.0", + "license": "GNU GPLv3", + "private": true, + "engines": { + "node": ">=12" + }, + "dependencies": { + "@apollo/client": "^3.3.13", + "@media-ui/core": "workspace:*", + "@media-ui/feature-asset-upload": "workspace:*", + "apollo-upload-client": "^14.1.3", + "react": "^17.0.2", + "react-redux": "^5.1.2", + "recoil": "^0.7.7" + }, + "browserslist": [ + "> 0.5%", + "last 2 versions", + "not dead", + "supports async-functions" + ] +} diff --git a/Resources/Private/JavaScript/asset-upload-screen/src/AssetUploadScreen.tsx b/Resources/Private/JavaScript/asset-upload-screen/src/AssetUploadScreen.tsx new file mode 100644 index 000000000..83219e31e --- /dev/null +++ b/Resources/Private/JavaScript/asset-upload-screen/src/AssetUploadScreen.tsx @@ -0,0 +1,153 @@ +import React, { createRef } from 'react'; +import { connect } from 'react-redux'; +import { ApolloClient, ApolloLink } from '@apollo/client'; +import { createUploadLink } from 'apollo-upload-client'; + +// Neos dependencies are provided by the UI +// @ts-ignore +import { neos } from '@neos-project/neos-ui-decorators'; +// @ts-ignore +import { actions } from '@neos-project/neos-ui-redux-store'; + +import NewAssetUpload from './NewAssetUpload'; + +import { MediaUiProvider, typeDefs as TYPE_DEFS_CORE } from '@media-ui/core'; +import MediaApplicationWrapper from '@media-ui/core/src/components/MediaApplicationWrapper'; +import { CacheFactory, createErrorHandler } from '@media-ui/media-module/src/core'; +import { typeDefs as TYPE_DEFS_ASSET_USAGE } from '@media-ui/feature-asset-usage'; + +let apolloClient = null; + +interface AssetUploadScreenProps { + i18nRegistry: I18nRegistry; + frontendConfiguration: FeatureFlags; + neos: Record; + type: AssetType | 'images'; // The image editor sets the type to 'images' + onComplete: (result: { object: { __identity: string } }) => void; + isLeftSideBarHidden: boolean; + isNodeCreationDialogOpen: boolean; + toggleSidebar: () => void; + addFlashMessage: (title: string, message: string, severity?: string, timeout?: number) => void; + constraints?: SelectionConstraints; +} + +interface AssetUploadScreenState { + initialLeftSideBarHiddenState: boolean; + initialNodeCreationDialogOpenState: boolean; +} + +export class AssetUploadScreen extends React.PureComponent { + notificationHandler: NeosNotification; + + constructor(props) { + super(props); + this.state = { + initialLeftSideBarHiddenState: false, + initialNodeCreationDialogOpenState: false, + }; + this.notificationHandler = { + info: (message) => props.addFlashMessage(message, message, 'info'), + ok: (message) => props.addFlashMessage(message, message, 'success'), + notice: (message) => props.addFlashMessage(message, message, 'info'), + warning: (title, message = '') => props.addFlashMessage(title, message, 'error'), + error: (title, message = '') => props.addFlashMessage(title, message, 'error'), + }; + } + + getConfig() { + return { + endpoints: { + // TODO: Generate uri from Neos maybe like $get('routes.core.modules.mediaBrowser', neos); + graphql: '/neos/graphql/media-assets', + upload: '/neos/media-ui/upload', + }, + // TODO: Generate image uri from Neos + dummyImage: '/_Resources/Static/Packages/Neos.Neos/Images/dummy-image.svg', + }; + } + + getApolloClient() { + if (!apolloClient) { + const { endpoints } = this.getConfig(); + const cache = CacheFactory.createCache(this.props.frontendConfiguration as FeatureFlags); + + apolloClient = new ApolloClient({ + cache, + link: ApolloLink.from([ + createErrorHandler(this.notificationHandler), + createUploadLink({ + uri: endpoints.graphql, + credentials: 'same-origin', + }), + ]), + typeDefs: [TYPE_DEFS_CORE, TYPE_DEFS_ASSET_USAGE], + }); + } + return apolloClient; + } + + translate = ( + id?: string, + fallback?: string, + params?: Record | string[], + packageKey = 'Flowpack.Media.Ui', + sourceName = 'Main' + ) => { + return this.props.i18nRegistry.translate(id, fallback, params, packageKey, sourceName); + }; + + getInitialState = () => { + const { frontendConfiguration, constraints, type } = this.props; + + return { + applicationContext: 'selection' as ApplicationContext, + featureFlags: frontendConfiguration, + constraints: { + ...(constraints || {}), + assetType: type === 'images' ? 'image' : type, + }, + }; + }; + + render() { + const { onComplete } = this.props; + const { dummyImage } = this.getConfig(); + const containerRef = createRef(); + const isInNodeCreationDialog = this.state.initialNodeCreationDialogOpenState; + + return ( +
+ + + + + +
+ ); + } +} + +const mapStateToProps = (state: any) => ({ + isLeftSideBarHidden: state.ui.leftSideBar.isHidden, + isNodeCreationDialogOpen: state.ui.nodeCreationDialog.isOpen, +}); + +const mapGlobalRegistryToProps = neos((globalRegistry: any) => ({ + i18nRegistry: globalRegistry.get('i18n'), + frontendConfiguration: globalRegistry.get('frontendConfiguration').get('Flowpack.Media.Ui'), +})); + +export default connect(() => ({}), { + addFlashMessage: actions.UI.FlashMessages.add, + toggleSidebar: actions.UI.LeftSideBar.toggle, +})(connect(mapStateToProps)(mapGlobalRegistryToProps(AssetUploadScreen))); diff --git a/Resources/Private/JavaScript/asset-upload-screen/src/NewAssetUpload.module.css b/Resources/Private/JavaScript/asset-upload-screen/src/NewAssetUpload.module.css new file mode 100644 index 000000000..1d8d57462 --- /dev/null +++ b/Resources/Private/JavaScript/asset-upload-screen/src/NewAssetUpload.module.css @@ -0,0 +1,9 @@ +.uploadArea { + padding: 1rem; +} + +.controls { + margin-top: 2rem; + display: flex; + justify-content: flex-end; +} diff --git a/Resources/Private/JavaScript/asset-upload-screen/src/NewAssetUpload.tsx b/Resources/Private/JavaScript/asset-upload-screen/src/NewAssetUpload.tsx new file mode 100644 index 000000000..499db7f45 --- /dev/null +++ b/Resources/Private/JavaScript/asset-upload-screen/src/NewAssetUpload.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; + +import { Button } from '@neos-project/react-ui-components'; + +import { useIntl, useNotify } from '@media-ui/core/src'; +import { useUploadDialogState, useUploadFiles } from '@media-ui/feature-asset-upload/src/hooks'; +import { useCallback } from 'react'; +import { PreviewSection, UploadSection } from '@media-ui/feature-asset-upload/src/components'; +import classes from './NewAssetUpload.module.css'; +import cx from 'classnames'; +import theme from '@media-ui/core/src/Theme.module.css'; + +const NewAssetUpload = (props: { onComplete: (result: { object: { __identity: string } }) => void }) => { + const { translate } = useIntl(); + const Notify = useNotify(); + const { uploadFiles, uploadState, loading } = useUploadFiles(); + const { state: dialogState, setFiles, setUploadPossible } = useUploadDialogState(); + const onComplete = props.onComplete; + + const handleUpload = useCallback(() => { + uploadFiles(dialogState.files.selected) + .then(({ data: { uploadFiles } }) => { + setFiles((prev) => { + return { + selected: [], + finished: [ + ...prev.finished, + ...prev.selected.filter((file) => + uploadFiles.find((result) => { + return result.success && result.filename === file.name + ? (file.uploadStateResult = result.result) + : false; + }) + ), + ], + rejected: [ + ...prev.rejected, + ...prev.selected.filter((file) => + uploadFiles.find((result) => { + return !result.success && result.filename === file.name + ? (file.uploadStateResult = result.result) + : false; + }) + ), + ], + } as FilesUploadState; + }); + if (!uploadFiles[0].success) { + Notify.warning( + translate('uploadDialog.uploadFinishedWithErrors', 'Some files could not be uploaded'), + translate('uploadDialog.uploadFinishedWithErrors', 'Some files could not be uploaded') + ); + } else { + Notify.ok(translate('uploadDialog.uploadFinished', 'Upload finished')); + onComplete({ object: { __identity: uploadFiles[0].assetId } }); + } + setUploadPossible(false); + }) + .catch((error) => { + Notify.error(translate('fileUpload.error', 'Upload failed'), error); + }); + }, [uploadFiles, dialogState.files.selected, setFiles, setUploadPossible, Notify, translate, onComplete]); + + const handleSetFiles = useCallback( + (files: UploadedFile[]) => { + setFiles((prev) => { + const fileNames = new Set(); + for (const file of prev.finished.concat(prev.rejected)) { + fileNames.add(file.name); + } + const newSelectedFiles = files.filter((file) => { + return fileNames.has(file.name) ? false : fileNames.add(file.name); + }); + return { ...prev, selected: newSelectedFiles }; + }); + }, + [setFiles] + ); + + return ( +
+ + +
+ +
+
+ ); +}; + +export default NewAssetUpload; diff --git a/Resources/Private/JavaScript/asset-upload-screen/src/index.ts b/Resources/Private/JavaScript/asset-upload-screen/src/index.ts new file mode 100644 index 000000000..2295b9090 --- /dev/null +++ b/Resources/Private/JavaScript/asset-upload-screen/src/index.ts @@ -0,0 +1 @@ +export { default as AssetUploadScreen } from './AssetUploadScreen'; diff --git a/Resources/Private/JavaScript/asset-upload/package.json b/Resources/Private/JavaScript/asset-upload/package.json index b2ffaa4cb..ec21081df 100644 --- a/Resources/Private/JavaScript/asset-upload/package.json +++ b/Resources/Private/JavaScript/asset-upload/package.json @@ -5,6 +5,7 @@ "private": true, "main": "src/index.ts", "dependencies": { - "@media-ui/core": "workspace:*" + "@media-ui/core": "workspace:*", + "@media-ui/media-module": "workspace:*" } } diff --git a/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/NewAssetDialog.tsx b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/NewAssetDialog.tsx index aa19cd192..a046c83c1 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/NewAssetDialog.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/NewAssetDialog.tsx @@ -16,12 +16,12 @@ const NewAssetDialog: React.FC = () => { const { translate } = useIntl(); const Notify = useNotify(); const { uploadFiles, uploadState, loading } = useUploadFiles(); - const { state: dialogState, closeDialog, setFiles } = useUploadDialogState(); + const { state: dialogState, closeDialog, setFiles, setUploadPossible } = useUploadDialogState(); const { refetch } = useAssetsQuery(); - const uploadPossible = !loading && dialogState.files.selected.length > 0; const handleUpload = useCallback(() => { - uploadFiles(dialogState.files.selected) + const filesToUpload = dialogState.files.selected.filter((file) => !dialogState.files.finished.includes(file)); + uploadFiles(filesToUpload) .then(({ data: { uploadFiles } }) => { // FIXME: Mapping the uploadState to the files name is not the best solution as the same filename might be used multiple times // Move uploaded or failed files into separate lists @@ -31,13 +31,21 @@ const NewAssetDialog: React.FC = () => { finished: [ ...prev.finished, ...prev.selected.filter((file) => - uploadFiles.find((result) => result.success && result.filename === file.name) + uploadFiles.find((result) => { + return result.success && result.filename === file.name + ? (file.uploadStateResult = result.result) + : false; + }) ), ], rejected: [ ...prev.rejected, ...prev.selected.filter((file) => - uploadFiles.find((result) => !result.success && result.filename === file.name) + uploadFiles.find((result) => { + return !result.success && result.filename === file.name + ? (file.uploadStateResult = result.result) + : false; + }) ), ], } as FilesUploadState; @@ -54,16 +62,33 @@ const NewAssetDialog: React.FC = () => { if (uploadFiles.some((result) => result.success)) { void refetch(); } + setUploadPossible(false); }) .catch((error) => { Notify.error(translate('fileUpload.error', 'Upload failed'), error); }); - }, [uploadFiles, dialogState.files.selected, setFiles, Notify, translate, refetch]); + }, [ + dialogState.files.selected, + dialogState.files.finished, + uploadFiles, + setFiles, + setUploadPossible, + Notify, + translate, + refetch, + ]); const handleSetFiles = useCallback( (files: UploadedFile[]) => { setFiles((prev) => { - return { ...prev, selected: files }; + const fileNames = new Set(); + for (const file of prev.finished.concat(prev.rejected)) { + fileNames.add(file.name); + } + const newSelectedFiles = files.filter((file) => { + return fileNames.has(file.name) ? false : fileNames.add(file.name); + }); + return { ...prev, selected: newSelectedFiles }; }); }, [setFiles] @@ -84,7 +109,7 @@ const NewAssetDialog: React.FC = () => { key="upload" style="success" hoverStyle="success" - disabled={!uploadPossible} + disabled={!dialogState.uploadPossible} onClick={handleUpload} > {translate('uploadDialog.upload', 'Upload')} @@ -94,7 +119,14 @@ const NewAssetDialog: React.FC = () => { >
- +
); diff --git a/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/ReplaceAssetDialog.tsx b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/ReplaceAssetDialog.tsx index e6a9e89fc..4919b8bc4 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/ReplaceAssetDialog.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/ReplaceAssetDialog.tsx @@ -12,8 +12,14 @@ import UploadSection from '../UploadSection'; import PreviewSection from '../PreviewSection'; import { useUploadDialogState } from '../../hooks'; import useReplaceAsset, { AssetReplacementOptions } from '../../hooks/useReplaceAsset'; +import { useSetRecoilState } from 'recoil'; import classes from './ReplaceAssetDialog.module.css'; +import { + selectedAssetLabelState, + selectedAssetCaptionState, + selectedAssetCopyrightNoticeState, +} from '@media-ui/media-module/src/state'; const ReplaceAssetDialog: React.FC = () => { const { translate } = useIntl(); @@ -24,13 +30,15 @@ const ReplaceAssetDialog: React.FC = () => { const { approvalAttainmentStrategy: { obtainApprovalToReplaceAsset }, } = useMediaUi(); + const { state: dialogState, closeDialog, setFiles, setUploadPossible } = useUploadDialogState(); const featureFlags = useRecoilValue(featureFlagsState); - const { state: dialogState, closeDialog, setFiles } = useUploadDialogState(); const [replacementOptions, setReplacementOptions] = React.useState({ keepOriginalFilename: false, generateRedirects: false, }); - const uploadPossible = !loading && dialogState.files.selected.length > 0; + const setLabel = useSetRecoilState(selectedAssetLabelState); + const setCaption = useSetRecoilState(selectedAssetCaptionState); + const setCopyrightNotice = useSetRecoilState(selectedAssetCopyrightNoticeState); const acceptedFileTypes = useMemo(() => { // TODO: Extract this into a helper function const completeMediaType = selectedAsset?.file.mediaType; @@ -50,11 +58,16 @@ const ReplaceAssetDialog: React.FC = () => { if (hasApprovalToReplaceAsset) { try { - await replaceAsset({ asset: selectedAsset, file, options: replacementOptions }); + const result = await replaceAsset({ asset: selectedAsset, file, options: replacementOptions }); Notify.ok(translate('uploadDialog.replacementFinished', 'Replacement finished')); closeDialog(); void refetch(); + if (result?.data.replaceAsset.success) { + setLabel(file.title); + setCaption(file.caption); + setCopyrightNotice(file.copyrightNotice); + } } catch (error) { Notify.error(translate('assetReplacement.error', 'Replacement failed'), error); } @@ -69,6 +82,9 @@ const ReplaceAssetDialog: React.FC = () => { selectedAsset, closeDialog, obtainApprovalToReplaceAsset, + setLabel, + setCaption, + setCopyrightNotice, ]); const handleSetFiles = useCallback( @@ -95,7 +111,7 @@ const ReplaceAssetDialog: React.FC = () => { key="upload" style="success" hoverStyle="success" - disabled={!uploadPossible} + disabled={!dialogState.uploadPossible} onClick={handleUpload} > {translate('uploadDialog.replace', 'Replace')} @@ -141,6 +157,9 @@ const ReplaceAssetDialog: React.FC = () => { files={dialogState.files} loading={loading} uploadState={uploadState ? [uploadState] : []} + dialogState={dialogState} + setFiles={setFiles} + setUploadPossible={setUploadPossible} /> diff --git a/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.module.css b/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.module.css index 7b27b4bb0..049352220 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.module.css +++ b/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.module.css @@ -1,14 +1,9 @@ -.fileList { - margin-top: var(--theme-spacing-Full); +.preview { display: flex; flex-direction: row; - flex-wrap: wrap; -} - -.fileListHeader { - flex: 1 1 100%; - margin-bottom: var(--theme-spacing-Full); - font-size: var(--theme-fontSize-base); + column-gap: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--theme-colors-border); } .thumb { @@ -23,6 +18,11 @@ box-sizing: border-box; } +.thumbWide { + width: 270px; + height: 200px; +} + .thumbInner { position: relative; width: 100%; @@ -30,6 +30,7 @@ display: flex; align-items: center; justify-content: center; + isolation: isolate; } .thumbInner span { @@ -37,6 +38,17 @@ user-select: none; } +.properties { + width: 100%; + display: flex; + flex-direction: column; + row-gap: 1rem; +} + +.properties label { + margin-bottom: .5rem; +} + .img { position: absolute; display: block; @@ -48,16 +60,16 @@ z-index: -1; } -.thumbInner:after { +.thumbInner::after { display: none; position: absolute; - content: ""; + content: ''; left: 0; top: 0; right: 0; bottom: 0; background-color: var(--theme-colors-alternatingBackground); - opacity: 0.3; + opacity: .3; z-index: -1; } @@ -65,7 +77,7 @@ border-color: var(--theme-colors-border); } -.loading .thumbInner:after { +.loading .thumbInner::after { display: block; } @@ -73,7 +85,7 @@ border-color: var(--theme-colors-Success); } -.success .thumbInner:after { +.success .thumbInner::after { display: block; background-color: var(--theme-colors-Success); } @@ -82,7 +94,7 @@ border-color: var(--theme-colors-Error); } -.error .thumbInner:after { +.error .thumbInner::after { display: block; background-color: var(--theme-colors-Error); } @@ -90,3 +102,8 @@ .warning { color: var(--theme-colors-Warn); } + +.properties .textInput, +.properties .textArea { + padding: var(--theme-spacing-Half) 14px !important; +} diff --git a/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.tsx b/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.tsx index 7533aea7e..107ddb647 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.tsx @@ -1,35 +1,222 @@ import React from 'react'; +import { SetStateAction, Dispatch, useState } from 'react'; import cx from 'classnames'; +import { SetterOrUpdater } from 'recoil'; -import { Icon } from '@neos-project/react-ui-components'; +import { Icon, TextArea, TextInput, CheckBox } from '@neos-project/react-ui-components'; import classes from './FilePreview.module.css'; +import { UploadDialogStateWithFiles } from '../state/uploadDialogState'; +import Property from '@media-ui/core/src/components/Property'; +import { useIntl } from '@media-ui/core/src'; +import { useConfigQuery } from '@media-ui/core/src/hooks'; + +interface UploadPropertiesConfig { + [key: string]: { + show: boolean; + required: boolean; + }; +} interface FilePreviewProps { file: UploadedFile; loading?: boolean; fileState: FileUploadResult; + dialogState: UploadDialogStateWithFiles; + setFiles: Dispatch>; + setUploadPossible: SetterOrUpdater; } -const FilePreview: React.FC = ({ file, loading = false, fileState }: FilePreviewProps) => { - const success = fileState?.success; - const error = fileState && !success; +type UploadProperty = string | boolean; + +const FilePreview: React.FC = ({ + file, + loading = false, + fileState, + dialogState, + setFiles, + setUploadPossible, +}: FilePreviewProps) => { + const success = fileState?.success || dialogState.files.finished.includes(file); + const disabled = success || fileState?.result === 'EXISTS' || dialogState.files.rejected.includes(file); + const error = (fileState && !success) || dialogState.files.rejected.includes(file); + const result = + fileState?.result || + dialogState.files.rejected[dialogState.files.rejected.indexOf(file)]?.uploadStateResult || + dialogState.files.finished[dialogState.files.finished.indexOf(file)]?.uploadStateResult; + const { translate } = useIntl(); + const { config } = useConfigQuery(); + const [copyrightNoticeNotNeededChecked, setCopyrightNoticeNotNeededChecked] = useState(false); + const uploadPropertiesConfig: UploadPropertiesConfig = {}; + + config.uploadProperties.forEach((config) => { + uploadPropertiesConfig[config.name] = { + show: config.show, + required: config.required, + }; + }); + + const setUploadProperty = (propertyName: string, propertyValue: UploadProperty) => { + const files: UploadedFile[] = [...dialogState.files.selected]; + const newFile = + files.length === 0 && + !(dialogState.files.finished.includes(file) || dialogState.files.rejected.includes(file)); + if (newFile) { + file[propertyName] = propertyValue; + files.push(file); + } else { + files.forEach((selectedFile) => { + if (selectedFile.name === file.name) { + file[propertyName] = propertyValue; + } + }); + } + + return files; + }; + + const getUploadPossibleValue = (files: UploadedFile[]) => { + return ( + !loading && + files.length > 0 && + files.reduce((current, file) => { + return ( + current && + (!uploadPropertiesConfig['copyrightNotice'].required || + (!!file?.copyrightNotice && file?.copyrightNotice !== '') || + !!file?.copyrightNoticeNotNeeded) && + (!uploadPropertiesConfig['title'].required || (!!file?.title && file?.title !== '')) && + (!uploadPropertiesConfig['caption'].required || (!!file?.caption && file?.caption !== '')) + ); + }, true) + ); + }; + + const setCopyrightNotice = (copyrightNotice: string) => { + const files = setUploadProperty('copyrightNotice', copyrightNotice); + + setFiles((prev) => { + return { ...prev, selected: files }; + }); + + setUploadPossible(getUploadPossibleValue(files)); + }; + const setTitle = (title: string) => { + const files = setUploadProperty('title', title); + + setFiles((prev) => { + return { ...prev, selected: files }; + }); + + setUploadPossible(getUploadPossibleValue(files)); + }; + + const setCaption = (caption: string) => { + const files = setUploadProperty('caption', caption); + + setFiles((prev) => { + return { ...prev, selected: files }; + }); + + setUploadPossible(getUploadPossibleValue(files)); + }; + + const setCopyrightNoticeNotNeeded = (isChecked: boolean) => { + setCopyrightNoticeNotNeededChecked(isChecked); + const files = setUploadProperty('copyrightNoticeNotNeeded', isChecked); + + setFiles((prev) => { + return { ...prev, selected: files }; + }); + + setUploadPossible(getUploadPossibleValue(files)); + }; + + const isWideThumb = + uploadPropertiesConfig.title.show || + uploadPropertiesConfig.caption.show || + uploadPropertiesConfig.copyrightNotice?.show; // TODO: Output helpful localised messages for results 'EXISTS', 'ADDED', 'ERROR' return ( -
-
- {file.name} - {loading && } - {success && } - {error && } - {fileState?.result && {fileState.result}} +
+
+
+ {file.name} + {loading && } + {success && } + {error && } + {result && {result}} +
+
+
+ {uploadPropertiesConfig['title'].show ? ( + + + + ) : ( + '' + )} + {uploadPropertiesConfig['caption'].show ? ( + +