diff --git a/Configuration/Settings.Features.yaml b/Configuration/Settings.Features.yaml index 2bfabedbd..f2341f19a 100644 --- a/Configuration/Settings.Features.yaml +++ b/Configuration/Settings.Features.yaml @@ -3,12 +3,8 @@ Neos: Ui: frontendConfiguration: Flowpack.Media.Ui: - useNewMediaSelection: true + # Use the new upload dialog where the user can add additional information like caption or copy right notice when uploading the asset useNewAssetUpload: true - queryAssetUsage: false - pollForChanges: true - showSimilarAssets: false - showVariantsEditor: false # 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/Resources/Private/JavaScript/asset-upload-screen/package.json b/Resources/Private/JavaScript/asset-upload-screen/package.json index 42c458279..24ce6281a 100644 --- a/Resources/Private/JavaScript/asset-upload-screen/package.json +++ b/Resources/Private/JavaScript/asset-upload-screen/package.json @@ -8,20 +8,18 @@ }, "dependencies": { "@apollo/client": "^3.3.13", - "@media-ui/core": "*", - "@media-ui/feature-asset-upload": "*", - "@media-ui/media-module": "*", + "@media-ui/core": "workspace:*", + "@media-ui/feature-asset-upload": "workspace:*", + "@media-ui/media-module": "workspace:*", "apollo-upload-client": "^14.1.3", - "plow-js": "^2.2.0", + "react": "^17.0.2", "react-redux": "^5.1.2", - "react": "^17.0.1", - "recoil": "^0.2.0" - }, - "devDependencies": { - "webpack": "^4.44.2", - "webpack-graphql-loader": "^1.0.2" + "recoil": "^0.7.7" }, "browserslist": [ - "defaults and > 1% and not ie <= 11" + "> 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 index a6510603a..83219e31e 100644 --- a/Resources/Private/JavaScript/asset-upload-screen/src/AssetUploadScreen.tsx +++ b/Resources/Private/JavaScript/asset-upload-screen/src/AssetUploadScreen.tsx @@ -1,9 +1,7 @@ -import * as React from 'react'; +import React, { createRef } from 'react'; import { connect } from 'react-redux'; -import { RecoilRoot } from 'recoil'; -import { ApolloClient, ApolloLink, ApolloProvider } from '@apollo/client'; +import { ApolloClient, ApolloLink } from '@apollo/client'; import { createUploadLink } from 'apollo-upload-client'; -import { $get, $transform } from 'plow-js'; // Neos dependencies are provided by the UI // @ts-ignore @@ -11,37 +9,20 @@ import { neos } from '@neos-project/neos-ui-decorators'; // @ts-ignore import { actions } from '@neos-project/neos-ui-redux-store'; -import { - I18nRegistry, - IntlProvider, - MediaUiProvider, - MediaUiThemeProvider, - Notify, - NotifyProvider, -} from '@media-ui/core/src'; -import { FeatureFlags, SelectionConstraints } from '@media-ui/core/src/interfaces'; -import { AssetMediaType } from '@media-ui/core/src/state/selectedMediaTypeState'; -import { ApolloErrorHandler, CacheFactory, PersistentStateManager } from '@media-ui/media-module/src/core'; - -import TYPE_DEFS_CORE from '@media-ui/core/schema.graphql'; -import TYPE_DEFS_CLIPBOARD from '@media-ui/feature-clipboard/schema.graphql'; -import TYPE_DEFS_ASSET_USAGE from '@media-ui/feature-asset-usage/schema.graphql'; - -// GraphQL local resolvers -import buildClipboardResolver from '@media-ui/feature-clipboard/src/resolvers/mutation'; -import buildModuleResolver from '@media-ui/media-module/src/resolvers/mutation'; -import { createRef } from 'react'; 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: { - queryAssetUsage: boolean; - }; + frontendConfiguration: FeatureFlags; neos: Record; - type: AssetMediaType | 'images'; // The image editor sets the type to 'images' + type: AssetType | 'images'; // The image editor sets the type to 'images' onComplete: (result: { object: { __identity: string } }) => void; isLeftSideBarHidden: boolean; isNodeCreationDialogOpen: boolean; @@ -55,23 +36,22 @@ interface AssetUploadScreenState { initialNodeCreationDialogOpenState: boolean; } -@connect( - $transform({ - isLeftSideBarHidden: $get('ui.leftSideBar.isHidden'), - isNodeCreationDialogOpen: $get('ui.nodeCreationDialog.isOpen'), - }), - { - addFlashMessage: actions.UI.FlashMessages.add, - toggleSidebar: actions.UI.LeftSideBar.toggle, - } -) -@neos((globalRegistry) => ({ - i18nRegistry: globalRegistry.get('i18n'), - frontendConfiguration: globalRegistry.get('frontendConfiguration').get('Flowpack.Media.Ui'), -})) 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() { @@ -90,22 +70,17 @@ export class AssetUploadScreen extends React.PureComponent { + const { frontendConfiguration, constraints, type } = this.props; + + return { + applicationContext: 'selection' as ApplicationContext, + featureFlags: frontendConfiguration, + constraints: { + ...(constraints || {}), + assetType: type === 'images' ? 'image' : type, + }, + }; + }; + render() { - const { addFlashMessage, onComplete, constraints, type } = this.props; - const client = this.getApolloClient(); + const { onComplete } = this.props; const { dummyImage } = this.getConfig(); const containerRef = createRef(); - - const featureFlags: FeatureFlags = this.props.frontendConfiguration as FeatureFlags; - - // The Neos.UI Flashmessages only support the levels 'success', 'error' and 'info' - const Notification: Notify = { - info: (message) => addFlashMessage(message, message, 'info'), - ok: (message) => addFlashMessage(message, message, 'success'), - notice: (message) => addFlashMessage(message, message, 'info'), - warning: (title, message = '') => addFlashMessage(title, message, 'error'), - error: (title, message = '') => addFlashMessage(title, message, 'error'), - }; + 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 index 35ba651aa..fcd396d03 100644 --- a/Resources/Private/JavaScript/asset-upload-screen/src/NewAssetUpload.tsx +++ b/Resources/Private/JavaScript/asset-upload-screen/src/NewAssetUpload.tsx @@ -2,29 +2,18 @@ import * as React from 'react'; import { Button } from '@neos-project/react-ui-components'; -import { createUseMediaUiStyles, MediaUiTheme, useIntl, useNotify } from '@media-ui/core/src'; +import { useIntl, useNotify } from '@media-ui/core/src'; import { useUploadDialogState, useUploadFiles } from '@media-ui/feature-asset-upload/src/hooks'; import { useCallback } from 'react'; -import { FilesUploadState, UploadedFile } from '@media-ui/feature-asset-upload/src/interfaces'; import { PreviewSection, UploadSection } from '@media-ui/feature-asset-upload/src/components'; - -const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - uploadArea: { - padding: theme.spacing.full, - }, - controls: { - marginTop: '2rem', - display: 'flex', - justifyContent: 'flex-end', - }, -})); +import { FilesUploadState, UploadedFile } from '@media-ui/feature-asset-upload/typings'; +import classes from './NewAssetUpload.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 classes = useStyles(); const onComplete = props.onComplete; const handleUpload = useCallback(() => { @@ -62,6 +51,7 @@ const NewAssetUpload = (props: { onComplete: (result: { object: { __identity: st ); } else { Notify.ok(translate('uploadDialog.uploadFinished', 'Upload finished')); + console.log(uploadFiles); onComplete({ object: { __identity: uploadFiles[0].assetId } }); } setUploadPossible(false); diff --git a/Resources/Private/JavaScript/asset-upload-screen/src/index.ts b/Resources/Private/JavaScript/asset-upload-screen/src/index.ts index e7c167fac..2295b9090 100644 --- a/Resources/Private/JavaScript/asset-upload-screen/src/index.ts +++ b/Resources/Private/JavaScript/asset-upload-screen/src/index.ts @@ -1 +1 @@ -export { AssetUploadScreen } from './AssetUploadScreen'; +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 545d2af5d..ec21081df 100644 --- a/Resources/Private/JavaScript/asset-upload/package.json +++ b/Resources/Private/JavaScript/asset-upload/package.json @@ -5,8 +5,7 @@ "private": true, "main": "src/index.ts", "dependencies": { - "@media-ui/core": "workspace:*" - "@media-ui/core": "*", - "@media-ui/media-module": "*" + "@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 f00cdbce1..98e7d271c 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/NewAssetDialog.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/NewAssetDialog.tsx @@ -11,13 +11,13 @@ import { useUploadDialogState, useUploadFiles } from '../../hooks'; import { useAssetsQuery } from '@media-ui/core/src/hooks'; import classes from './NewAssetDialog.module.css'; +import { FilesUploadState, UploadedFile } from '../../../typings'; const NewAssetDialog: React.FC = () => { const { translate } = useIntl(); const Notify = useNotify(); const { uploadFiles, uploadState, loading } = useUploadFiles(); const { state: dialogState, closeDialog, setFiles, setUploadPossible } = useUploadDialogState(); - const { state: dialogState, closeDialog, setFiles } = useUploadDialogState(); const { refetch } = useAssetsQuery(); const handleUpload = useCallback(() => { @@ -68,7 +68,16 @@ const NewAssetDialog: React.FC = () => { .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[]) => { 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 ae7315b31..d9e230ee0 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/ReplaceAssetDialog.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/ReplaceAssetDialog.tsx @@ -12,16 +12,16 @@ import UploadSection from '../UploadSection'; import PreviewSection from '../PreviewSection'; import { useUploadDialogState } from '../../hooks'; import useReplaceAsset, { AssetReplacementOptions } from '../../hooks/useReplaceAsset'; -import { UploadedFile } from '../../interfaces'; import { useSetRecoilState } from 'recoil'; + +import classes from './ReplaceAssetDialog.module.css'; +import { UploadedFile } from '../../../typings'; import { selectedAssetLabelState, selectedAssetCaptionState, selectedAssetCopyrightNoticeState, } from '@media-ui/media-module/src/state'; -import classes from './ReplaceAssetDialog.module.css'; - const ReplaceAssetDialog: React.FC = () => { const { translate } = useIntl(); const Notify = useNotify(); @@ -37,11 +37,9 @@ const ReplaceAssetDialog: React.FC = () => { keepOriginalFilename: false, generateRedirects: false, }); - const classes = useStyles(); const setLabel = useSetRecoilState(selectedAssetLabelState); const setCaption = useSetRecoilState(selectedAssetCaptionState); const setCopyrightNotice = useSetRecoilState(selectedAssetCopyrightNoticeState); - const uploadPossible = !loading && dialogState.files.selected.length > 0; const acceptedFileTypes = useMemo(() => { // TODO: Extract this into a helper function const completeMediaType = selectedAsset?.file.mediaType; 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..aa9783895 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.module.css +++ b/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.module.css @@ -1,25 +1,20 @@ -.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 #3f3f3f; } .thumb { display: inline-flex; border-radius: 2px; border: 1px solid #eaeaea; - margin-bottom: var(--theme-spacing-Half); - margin-right: var(--theme-spacing-Half); - width: 100px; - height: 100px; - padding: var(--theme-spacing-Quarter); + margin-bottom: var(--theme-spacing-Half, 8px); + margin-right: var(--theme-spacing-Half, 8px); + width: 270px; + height: 200px; + padding: var(--theme-spacing-Quarter, 4px); box-sizing: border-box; } @@ -30,13 +25,25 @@ display: flex; align-items: center; justify-content: center; + isolation: isolate; } .thumbInner span { - margin-left: var(--theme-spacing-Half); + margin-left: var(--theme-spacing-Half, 8px); user-select: none; } +.properties { + width: 100%; + display: flex; + flex-direction: column; + row-gap: 1rem; +} + +.properties label { + margin-bottom: .5rem; +} + .img { position: absolute; display: block; @@ -56,13 +63,13 @@ top: 0; right: 0; bottom: 0; - background-color: var(--theme-colors-alternatingBackground); + background-color: var(--theme-colors-alternatingBackground, #3f3f3f); opacity: 0.3; z-index: -1; } .loading { - border-color: var(--theme-colors-border); + border-color: var(--theme-colors-border, #3f3f3f); } .loading .thumbInner:after { @@ -70,23 +77,27 @@ } .success { - border-color: var(--theme-colors-Success); + border-color: var(--theme-colors-Success, #00a338); } .success .thumbInner:after { display: block; - background-color: var(--theme-colors-Success); + background-color: var(--theme-colors-Success, #00a338); } .error { - border-color: var(--theme-colors-Error); + border-color: var(--theme-colors-Error, #ff460d); } .error .thumbInner:after { display: block; - background-color: var(--theme-colors-Error); + background-color: var(--theme-colors-Error, #ff460d); } .warning { - color: var(--theme-colors-Warn); + color: var(--theme-colors-Warn, #ff8700); +} + +.neos textarea.textArea { + padding: var(--theme-spacing-Half, 8px); } diff --git a/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.tsx b/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.tsx index 54aeabd0c..72fef3cbe 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.tsx @@ -1,26 +1,68 @@ import React from 'react'; +import { SetStateAction, Dispatch, useState } from 'react'; import cx from 'classnames'; +import { SetterOrUpdater } from 'recoil'; -import { Icon, TextArea, TextInput } from '@neos-project/react-ui-components'; +import { Icon, TextArea, TextInput, CheckBox } from '@neos-project/react-ui-components'; import classes from './FilePreview.module.css'; +import { FileUploadResult, FilesUploadState, UploadedFile } from '../../typings'; +import { UploadDialogStateWithFiles } from '../state/uploadDialogState'; +import Property from '@media-ui/media-module/src/components/SideBarRight/Inspector/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: UploadDialogState; + 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]; - if (files.length === 0) { + const newFile = + files.length === 0 && + !(dialogState.files.finished.includes(file) || dialogState.files.rejected.includes(file)); + if (newFile) { file[propertyName] = propertyValue; files.push(file); } else { @@ -35,7 +77,20 @@ const FilePreview: React.FC = ({ file, loading = false, fileSt }; const getUploadPossibleValue = (files: UploadedFile[]) => { - return !loading && files.length > 0; + 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) => { @@ -48,7 +103,6 @@ const FilePreview: React.FC = ({ file, loading = false, fileSt setUploadPossible(getUploadPossibleValue(files)); }; - const setTitle = (title: string) => { const files = setUploadProperty('title', title); @@ -69,21 +123,94 @@ const FilePreview: React.FC = ({ file, loading = false, fileSt setUploadPossible(getUploadPossibleValue(files)); }; + const setCopyrightNoticeNotNeeded = (isChecked: boolean) => { + setCopyrightNoticeNotNeededChecked(isChecked); + const files = setUploadProperty('copyrightNoticeNotNeeded', isChecked); + + setFiles((prev) => { + return { ...prev, selected: files }; + }); + + setUploadPossible(getUploadPossibleValue(files)); + }; + // 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 ? ( + +