diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 2c63c0851..000000000 --- a/.babelrc +++ /dev/null @@ -1,2 +0,0 @@ -{ -} diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index a9812ed1f..000000000 --- a/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -.cache -dist -node_modules diff --git a/.eslintrc.js b/.eslintrc.js index 57c5e5c2b..7b2404114 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,13 +22,21 @@ module.exports = { es2020: true, node: true, }, + ignorePatterns: ['.cache', '.parcel-cache', 'dist'], rules: { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + args: 'none', // FIXME: Eslint shows warnings for used interfaces + }, + ], '@typescript-eslint/no-var-requires': 'off', '@typescript-eslint/ban-ts-ignore': 'off', '@typescript-eslint/ban-ts-comment': 'off', + 'no-unused-vars': 'off', 'prettier/prettier': ['error'], 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': [ diff --git a/.gitattributes b/.gitattributes index b96954d18..ee8abc007 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,20 @@ *.map binary Plugin.js binary Plugin.css binary + +/Resources/Private/JavaScript export-ignore +/Tests export-ignore +/.yarn export-ignore +/.env export-ignore +/.github export-ignore +/.eslintrc.js export-ignore +/.eslintrc.js export-ignore +/.mocharc export-ignore +/.nvmrc export-ignore +/.testcaferc.json export-ignore +/.yarn.yml export-ignore +/.apollo.config.js export-ignore +/package.json export-ignore +/phpstan.neon export-ignore +/tsconfig.json export-ignore +/yarn.lock export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ff45502f1..4e5faac61 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '16' + node-version-file: '.nvmrc' cache: 'yarn' - name: Install dependencies @@ -53,15 +53,28 @@ jobs: command: analyse php-unit-tests: + env: + FLOW_CONTEXT: Testing + FLOW_FOLDER: ../flow-base-distribution + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-versions: ['7.4'] + flow-versions: ['7.3'] + steps: - uses: actions/checkout@v2 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 7.4 + php-version: ${{ matrix.php-versions }} + extensions: mbstring, xml, json, zlib, iconv, intl, pdo_sqlite, mysql + coverage: xdebug #optional + ini-values: opcache.fast_shutdown=0 - name: Cache dependencies uses: actions/cache@v2 @@ -69,24 +82,38 @@ jobs: path: ~/.composer/cache key: dependencies-composer-${{ hashFiles('composer.json') }} - - name: Install dependencies - uses: php-actions/composer@v6 - with: - php_version: 7.4 - version: 2 - - - name: Run PHPUnit tests - run: composer test + - name: Prepare Flow distribution + run: | + git clone https://github.com/neos/flow-base-distribution.git -b ${{ matrix.flow-versions }} ${FLOW_FOLDER} + cd ${FLOW_FOLDER} + composer require --no-update --no-interaction flowpack/media-ui + + - name: Install distribution + run: | + cd ${FLOW_FOLDER} + composer install --no-interaction --no-progress + rm -rf Packages/Application/Flowpack.Media.Ui + cp -r ../media-ui Packages/Application/Flowpack.Media.Ui + + - name: Run Unit tests + run: | + cd ${FLOW_FOLDER} + bin/phpunit --colors -c Build/BuildEssentials/PhpUnit/UnitTests.xml Packages/Application/Flowpack.Media.Ui/Tests/Unit/ + + - name: Run Functional tests + run: | + cd ${FLOW_FOLDER} + bin/phpunit --colors -c Build/BuildEssentials/PhpUnit/FunctionalTests.xml Packages/Application/Flowpack.Media.Ui/Tests/Functional/ js-unit-tests: runs-on: ubuntu-latest steps: - - uses: actions/setup-node@v1 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: - node-version: '16' + node-version-file: '.nvmrc' cache: 'yarn' - - uses: actions/checkout@v2 - name: Install dependencies run: yarn @@ -105,7 +132,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '16' + node-version-file: '.nvmrc' cache: 'yarn' - name: Install dependencies diff --git a/.gitignore b/.gitignore index 934169b77..4541f06e8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,18 +7,23 @@ # Application & development dependencies. # node_modules -.yarn/cache -.yarn/install-state.gz +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions Packages vendor composer.lock +/.parcel-cache # # Compiled assets. # .cache Resources/Private/JavaScript/dev-server/dist -Resources/Public yarn-error.log # diff --git a/.npmrc b/.npmrc deleted file mode 100644 index dd4e12d78..000000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -@fortawesome:registry= diff --git a/.yarn/patches/@neos-project-neos-ui-extensibility-webpack-adapter-npm-8.3.0-fcedc456c3.patch b/.yarn/patches/@neos-project-neos-ui-extensibility-webpack-adapter-npm-8.3.0-fcedc456c3.patch new file mode 100644 index 000000000..ad72b95d2 --- /dev/null +++ b/.yarn/patches/@neos-project-neos-ui-extensibility-webpack-adapter-npm-8.3.0-fcedc456c3.patch @@ -0,0 +1,16 @@ +diff --git a/scripts/helpers/webpack.config.js b/scripts/helpers/webpack.config.js +index 25930d442a5aec4d22f1e890bd061ea3256570af..9f94a69f2892c156c5b2da64ba56363ffdaa1dfc 100644 +--- a/scripts/helpers/webpack.config.js ++++ b/scripts/helpers/webpack.config.js +@@ -135,6 +135,11 @@ module.exports = function (neosPackageJson) { + } + ], + }, ++ performance: { ++ hints : false, ++ maxEntrypointSize: 1000000, ++ maxAssetSize: 1000000 ++ }, + entry: { + Plugin: './src/index.js' + }, diff --git a/Classes/Aspect/HierarchicalAssetCollectionAspect.php b/Classes/Aspect/HierarchicalAssetCollectionAspect.php new file mode 100644 index 000000000..43e10045d --- /dev/null +++ b/Classes/Aspect/HierarchicalAssetCollectionAspect.php @@ -0,0 +1,121 @@ +getParent())") + */ + public function getParent(JoinPointInterface $joinPoint): ?HierarchicalAssetCollectionInterface + { + /** @var HierarchicalAssetCollectionInterface $assetCollection */ + $assetCollection = $joinPoint->getProxy(); + return ObjectAccess::getProperty($assetCollection, 'parent', true); + } + + /** + * @Flow\Around("method(Neos\Media\Domain\Model\AssetCollection->setParent())") + */ + public function setParent(JoinPointInterface $joinPoint): void + { + /** @var HierarchicalAssetCollectionInterface $assetCollection */ + $assetCollection = $joinPoint->getProxy(); + /** @var HierarchicalAssetCollectionInterface $parentAssetCollection */ + $parentAssetCollection = $joinPoint->getMethodArgument('parent'); + if (!$parentAssetCollection instanceof AssetCollection && $parentAssetCollection !== null) { + throw new \InvalidArgumentException('Parent must be an AssetCollection', 1678330583); + } + ObjectAccess::setProperty($assetCollection, 'parent', $parentAssetCollection, true); + + // Throws an error if a circular dependency has been detected + $parent = $assetCollection->getParent(); + while ($parent !== null) { + $parent = $parent->getParent(); + if ($parent === $assetCollection->getParent()) { + throw new \InvalidArgumentException(sprintf( + 'Circular reference detected, parent AssetCollection "%s" appeared twice in the hierarchy', + $parent->getTitle() + ), 1678330856); + } + } + } + + /** + * @Flow\Around("method(Neos\Media\Domain\Model\AssetCollection->unsetParent())") + */ + public function unsetParent(JoinPointInterface $joinPoint): void + { + /** @var HierarchicalAssetCollectionInterface $assetCollection */ + $assetCollection = $joinPoint->getProxy(); + ObjectAccess::setProperty($assetCollection, 'parent', null, true); + } + + /** + * @Flow\Around("method(Neos\Media\Domain\Model\AssetCollection->hasParent())") + */ + public function hasParent(JoinPointInterface $joinPoint): bool + { + /** @var HierarchicalAssetCollectionInterface $assetCollection */ + $assetCollection = $joinPoint->getProxy(); + return ObjectAccess::getProperty($assetCollection, 'parent', true) !== null; + } + + /** + * @Flow\Around("method(Neos\Media\Domain\Model\AssetCollection->getTitle())") + */ + public function getTitle(JoinPointInterface $joinPoint): string + { + /** @var AssetCollection $assetCollection */ + $assetCollection = $joinPoint->getProxy(); + return $assetCollection->getTitle(); + } + + /** + * @Flow\Around("method(Neos\Media\Domain\Model\AssetCollection->getTags())") + */ + public function getTags(JoinPointInterface $joinPoint): Collection + { + /** @var AssetCollection $assetCollection */ + $assetCollection = $joinPoint->getProxy(); + return $assetCollection->getTags(); + } +} diff --git a/Classes/Aspect/HierarchicalAssetCollectionRepositoryAspect.php b/Classes/Aspect/HierarchicalAssetCollectionRepositoryAspect.php new file mode 100644 index 000000000..95253a133 --- /dev/null +++ b/Classes/Aspect/HierarchicalAssetCollectionRepositoryAspect.php @@ -0,0 +1,58 @@ +remove())") + */ + public function remove(JoinPointInterface $joinPoint): void + { + /** @var AssetCollectionRepository $assetCollectionRepository */ + $assetCollectionRepository = $joinPoint->getProxy(); + /** @var AssetCollection $assetCollection */ + $assetCollection = $joinPoint->getMethodArgument('object'); + $persistenceManager = ObjectAccess::getProperty($assetCollectionRepository, 'persistenceManager', true); + + $deleteRecursively = static function (AssetCollection $collection) use (&$deleteRecursively, $persistenceManager, $assetCollectionRepository) { + $childCollections = $assetCollectionRepository->findByParent($collection); + foreach ($childCollections as $childCollection) { + $deleteRecursively($childCollection); + } + $persistenceManager->remove($collection); + }; + $deleteRecursively($assetCollection); + } +} diff --git a/Classes/Command/AssetCollectionsCommandController.php b/Classes/Command/AssetCollectionsCommandController.php new file mode 100644 index 000000000..6d9428805 --- /dev/null +++ b/Classes/Command/AssetCollectionsCommandController.php @@ -0,0 +1,73 @@ +assetCollectionRepository->findByParent($assetCollection)->toArray(); + + return [ + $this->persistenceManager->getIdentifierByObject($assetCollection), + $assetCollection->getTitle(), + $assetCollection->getParent() ? $this->persistenceManager->getIdentifierByObject($assetCollection->getParent()) : 'None', + $assetCollection->getParent() ? $assetCollection->getParent()->getTitle() : 'None', + implode(', ', array_map(static fn (AssetCollection $assetCollection) => $assetCollection->getTitle(), $children)), + implode("\n", array_map(static fn (Tag $tag) => $tag->getLabel(), $assetCollection->getTags()->toArray())), + ]; + }, $this->assetCollectionRepository->findAll()->toArray()); + + $this->output->outputTable($rows, ['Id', 'Title', 'ParentId', 'Parent title', 'Children', 'Tags']); + } + + public function setParentCommand(string $assetCollectionIdentifier, string $parentAssetCollectionIdentifier): void + { + /** @var AssetCollection $assetCollection */ + $assetCollection = $this->assetCollectionRepository->findByIdentifier($assetCollectionIdentifier); + /** @var AssetCollection $parentAssetCollection */ + $parentAssetCollection = $this->assetCollectionRepository->findByIdentifier($parentAssetCollectionIdentifier); + /** @var HierarchicalAssetCollectionInterface $assetCollection */ + $assetCollection->setParent($parentAssetCollection); + $this->assetCollectionRepository->update($assetCollection); + $this->outputLine('Asset collection "%s" has been set as child of "%s"', [$assetCollection->getTitle(), $parentAssetCollection ? $parentAssetCollection->getTitle() : 'none']); + } + +} diff --git a/Classes/Domain/Model/AssetProxyIteratorAggregate.php b/Classes/Domain/Model/AssetProxyIteratorAggregate.php index 328375608..98c1ee988 100644 --- a/Classes/Domain/Model/AssetProxyIteratorAggregate.php +++ b/Classes/Domain/Model/AssetProxyIteratorAggregate.php @@ -23,9 +23,5 @@ interface AssetProxyIteratorAggregate extends \Countable, \IteratorAggregate { public function setOffset(int $offset): void; - /** - * @param null|integer $limit - * @return void - */ - public function setLimit($limit): void; + public function setLimit(?int $limit): void; } diff --git a/Classes/Domain/Model/AssetSource/NeosAssetProxyRepository.php b/Classes/Domain/Model/AssetSource/NeosAssetProxyRepository.php new file mode 100644 index 000000000..433cba2e0 --- /dev/null +++ b/Classes/Domain/Model/AssetSource/NeosAssetProxyRepository.php @@ -0,0 +1,273 @@ + AssetRepository::class, + 'Image' => ImageRepository::class, + 'Document' => DocumentRepository::class, + 'Video' => VideoRepository::class, + 'Audio' => AudioRepository::class + ]; + + public function __construct(NeosAssetSource $assetSource) + { + $this->assetSource = $assetSource; + } + + public function initializeObject(): void + { + /** @var AssetRepository $assetRepositoryForType */ + $assetRepositoryForType = $this->objectManager->get($this->assetRepositoryClassNames[$this->assetTypeFilter]); + $this->assetRepository = $assetRepositoryForType; + } + + /** + * Sets the property names to order results by. Expected like this: + * array( + * 'foo' => \Neos\Flow\Persistence\QueryInterface::ORDER_ASCENDING, + * 'bar' => \Neos\Flow\Persistence\QueryInterface::ORDER_DESCENDING + * ) + * + * @param array $orderings The property names to order by by default + */ + public function orderBy(array $orderings): void + { + $this->assetRepository->setDefaultOrderings($orderings); + } + + public function filterByType(AssetTypeFilter $assetType = null): void + { + $this->assetTypeFilter = (string)$assetType ?: 'All'; + $this->initializeObject(); + } + + public function filterByMediaType(string $mediaType): void + { + $this->mediaTypeFilter = $mediaType; + } + + /** + * NOTE: This needs to be refactored to use an asset collection identifier instead of Media's domain model before + * it can become a public API for other asset sources. + */ + public function filterByCollection(AssetCollection $assetCollection = null): void + { + $this->activeAssetCollection = $assetCollection; + } + + private function filterQuery(QueryInterface $query, bool $filterOtherCollections = false): QueryInterface + { + $query = $this->filterOutImportedAssetsFromOtherAssetSources($query); + $query = $this->filterOutImageVariants($query); + if ($filterOtherCollections) { + $query = $this->filterOutAssetsFromOtherAssetCollections($query); + } + if ($this->mediaTypeFilter) { + $query = $this->filterOutAssetsWithOtherMediaTypes($query); + } + return $query; + } + + /** + * @throws NeosAssetNotFoundException + */ + public function getAssetProxy(string $identifier): AssetProxyInterface + { + $asset = $this->assetRepository->findByIdentifier($identifier); + if (!$asset instanceof AssetInterface) { + throw new NeosAssetNotFoundException('The specified asset was not found.', 1509632861); + } + return new NeosAssetProxy($asset, $this->assetSource); + } + + public function findAll(): AssetProxyQueryResultInterface + { + $query = $this->filterQuery($this->assetRepository->findAll($this->activeAssetCollection)->getQuery()); + return new NeosAssetProxyQueryResult($query->execute(), $this->assetSource); + } + + public function findBySearchTerm(string $searchTerm): AssetProxyQueryResultInterface + { + $query = $this->filterQuery($this->assetRepository->findBySearchTermOrTags($searchTerm, [], + $this->activeAssetCollection)->getQuery()); + return new NeosAssetProxyQueryResult($query->execute(), $this->assetSource); + } + + public function findByTag(Tag $tag): AssetProxyQueryResultInterface + { + $query = $this->filterQuery($this->assetRepository->findByTag($tag, $this->activeAssetCollection)->getQuery()); + return new NeosAssetProxyQueryResult($query->execute(), $this->assetSource); + } + + public function findUntagged(): AssetProxyQueryResultInterface + { + $query = $this->filterQuery($this->assetRepository->findUntagged($this->activeAssetCollection)->getQuery()); + return new NeosAssetProxyQueryResult($query->execute(), $this->assetSource); + } + + public function findUnassigned(): AssetProxyQueryResultInterface + { + $query = $this->filterQuery($this->assetRepository->createQuery()); + $query = $this->filterOutAssetsWithAssetCollections($query); + return new NeosAssetProxyQueryResult($query->execute(), $this->assetSource); + } + + public function countAll(): int + { + return $this->filterQuery($this->assetRepository->createQuery(), true)->count(); + } + + public function countUntagged(): int + { + $query = $this->filterQuery($this->assetRepository->createQuery(), true); + try { + $query->matching($query->isEmpty('tags')); + } catch (InvalidQueryException $e) { + } + return $query->count(); + } + + public function countByTag(Tag $tag): int + { + return $this->filterQuery($this->assetRepository->findByTag($tag, $this->activeAssetCollection)->getQuery(), + true)->count(); + } + + private function filterOutImportedAssetsFromOtherAssetSources(QueryInterface $query): QueryInterface + { + $constraint = $query->getConstraint(); + $query->matching( + $query->logicalAnd([ + $constraint, + $query->equals('assetSourceIdentifier', 'neos') + ]) + ); + return $query; + } + + private function filterOutImageVariants(QueryInterface $query): QueryInterface + { + if (!method_exists($query, 'getQueryBuilder')) { + return $query; + } + $query->getQueryBuilder()->andWhere('e NOT INSTANCE OF ' . ImageVariant::class); + return $query; + } + + private function filterOutAssetsFromOtherAssetCollections(QueryInterface $query): QueryInterface + { + $constraints = $query->getConstraint(); + try { + $query->matching( + $query->logicalAnd([ + $constraints, + $query->contains('assetCollections', $this->activeAssetCollection), + ]) + ); + } catch (InvalidQueryException $e) { + } + return $query; + } + + private function filterOutAssetsWithAssetCollections(QueryInterface $query): QueryInterface + { + $constraints = $query->getConstraint(); + try { + $query->matching( + $query->logicalAnd([ + $constraints, + $query->isEmpty('assetCollections'), + ]) + ); + } catch (InvalidQueryException $e) { + } + return $query; + } + + private function filterOutAssetsWithOtherMediaTypes(QueryInterface $query) + { + $constraints = $query->getConstraint(); + return $query->matching( + $query->logicalAnd([ + $constraints, + $query->equals('resource.mediaType', $this->mediaTypeFilter), + ]) + ); + } +} diff --git a/Classes/Domain/Model/Dto/AssetUsage.php b/Classes/Domain/Model/Dto/AssetUsage.php index 6fd23593e..a362a983b 100644 --- a/Classes/Domain/Model/Dto/AssetUsage.php +++ b/Classes/Domain/Model/Dto/AssetUsage.php @@ -1,4 +1,5 @@ + * @return AssetInterface[] */ public function getSimilarAssets(AssetInterface $asset): array; } diff --git a/Classes/GraphQL/Context/AssetSourceContext.php b/Classes/GraphQL/Context/AssetSourceContext.php index 4d5bb1d43..2dd9c8cbf 100644 --- a/Classes/GraphQL/Context/AssetSourceContext.php +++ b/Classes/GraphQL/Context/AssetSourceContext.php @@ -85,15 +85,11 @@ public function getAssetProxy(string $id, string $assetSourceIdentifier): ?Asset } try { - $assetProxy = $activeAssetSource->getAssetProxyRepository()->getAssetProxy($id); + return $activeAssetSource->getAssetProxyRepository()->getAssetProxy($id); } catch (\Exception $e) { // Some assetproxy repositories like the NeosAssetProxyRepository throw exceptions if an asset was not found return null; } - if (!$assetProxy) { - return null; - } - return $assetProxy; } /** diff --git a/Classes/GraphQL/Resolver/Type/AssetCollectionResolver.php b/Classes/GraphQL/Resolver/Type/AssetCollectionResolver.php index 993bfec9f..48ea7dbde 100644 --- a/Classes/GraphQL/Resolver/Type/AssetCollectionResolver.php +++ b/Classes/GraphQL/Resolver/Type/AssetCollectionResolver.php @@ -15,6 +15,7 @@ */ use Neos\Flow\Annotations as Flow; +use Neos\Media\Domain\Repository\AssetRepository; use t3n\GraphQL\ResolverInterface; use Neos\Media\Domain\Model\AssetCollection; use Neos\Flow\Persistence\PersistenceManagerInterface; @@ -31,11 +32,18 @@ class AssetCollectionResolver implements ResolverInterface protected $persistenceManager; /** - * @param AssetCollection $assetCollection - * @return string + * @Flow\Inject + * @var AssetRepository */ + protected $assetRepository; + public function id(AssetCollection $assetCollection): string { return $this->persistenceManager->getIdentifierByObject($assetCollection); } + + public function assetCount(AssetCollection $assetCollection): int + { + return $this->assetRepository->countByAssetCollection($assetCollection); + } } diff --git a/Classes/GraphQL/Resolver/Type/MutationResolver.php b/Classes/GraphQL/Resolver/Type/MutationResolver.php index 0f7d5f6d6..aa1ea0b0d 100644 --- a/Classes/GraphQL/Resolver/Type/MutationResolver.php +++ b/Classes/GraphQL/Resolver/Type/MutationResolver.php @@ -17,6 +17,7 @@ */ use Doctrine\Common\Collections\ArrayCollection; +use Flowpack\Media\Ui\Domain\Model\HierarchicalAssetCollectionInterface; use Flowpack\Media\Ui\Exception; use Flowpack\Media\Ui\GraphQL\Context\AssetSourceContext; use Neos\Flow\Annotations as Flow; @@ -266,7 +267,7 @@ public function setAssetTags($_, array $variables, AssetSourceContext $assetSour /** * @throws Exception */ - public function setAssetCollections($_, array $variables, AssetSourceContext $assetSourceContext): ?AssetProxyInterface + public function setAssetCollections($_, array $variables, AssetSourceContext $assetSourceContext): bool { [ 'id' => $id, @@ -275,7 +276,7 @@ public function setAssetCollections($_, array $variables, AssetSourceContext $as ] = $variables; $assetProxy = $assetSourceContext->getAssetProxy($id, $assetSourceId); if (!$assetProxy) { - return null; + return false; } $asset = $assetSourceContext->getAssetForProxy($assetProxy); @@ -303,7 +304,7 @@ public function setAssetCollections($_, array $variables, AssetSourceContext $as throw new Exception('Failed to assign asset collections', 1594621296); } - return $assetProxy; + return true; } /** @@ -525,7 +526,7 @@ public function replaceAsset($_, array $variables, AssetSourceContext $assetSour /** * @throws Exception|\Neos\Flow\ResourceManagement\Exception */ - public function editAsset($_, array $variables, AssetSourceContext $assetSourceContext): array + public function editAsset($_, array $variables, AssetSourceContext $assetSourceContext): bool { [ 'id' => $id, @@ -579,9 +580,7 @@ public function editAsset($_, array $variables, AssetSourceContext $assetSourceC $this->systemLogger->error(sprintf('Asset %s could not be replace with the renamed copy', $asset->getIdentifier()), [$exception]); } - return [ - 'success' => $success, - ]; + return $success; } /** @@ -609,9 +608,15 @@ public function createAssetCollection($_, array $variables): AssetCollection { [ 'title' => $title, - ] = $variables; + 'parent' => $parent, + ] = $variables + ['parent' => null]; $newAssetCollection = new AssetCollection($title); + if ($parent) { + $parentCollection = $this->assetCollectionRepository->findByIdentifier($parent); + /** @var HierarchicalAssetCollectionInterface $newAssetCollection */ + $newAssetCollection->setParent($parentCollection); + } // FIXME: Multiple asset collections with the same title can exist, but do we want that? @@ -649,14 +654,15 @@ public function deleteAssetCollection($_, array $variables): array } /** + * @param array{id:string, title?: string, tagIds?: string[]} $variables * @throws Exception|IllegalObjectTypeException */ - public function updateAssetCollection($_, array $variables): ?AssetCollection + public function updateAssetCollection($_, array $variables): bool { [ 'id' => $id, 'title' => $title, - 'tagIds' => $tagIds + 'tagIds' => $tagIds, ] = $variables + ['title' => null, 'tagIds' => null]; /** @var AssetCollection $assetCollection */ @@ -666,8 +672,8 @@ public function updateAssetCollection($_, array $variables): ?AssetCollection throw new Exception('Asset collection not found', 1590659045); } - if ($title !== null) { - $assetCollection->setTitle($title); + if (is_string($title) && trim($title)) { + $assetCollection->setTitle(trim($title)); } if ($tagIds !== null) { @@ -684,7 +690,38 @@ public function updateAssetCollection($_, array $variables): ?AssetCollection $this->assetCollectionRepository->update($assetCollection); - return $assetCollection; + return true; + } + + /** + * @param array{id: string, parent: string} $variables + * @throws Exception|IllegalObjectTypeException + */ + public function setAssetCollectionParent($_, array $variables): bool + { + $id = $variables['id'] ?? null; + $parent = $variables['parent'] ?? null; + + /** @var AssetCollection $assetCollection */ + $assetCollection = $this->assetCollectionRepository->findByIdentifier($id); + + if (!$assetCollection) { + throw new Exception('Asset collection not found', 1681999816); + } + + /** @var HierarchicalAssetCollectionInterface $assetCollection */ + if ($parent) { + /** @var AssetCollection $parentCollection */ + $parentCollection = $this->assetCollectionRepository->findByIdentifier($parent); + if (!$parentCollection) { + throw new Exception('Parent asset collection not found', 1681999836); + } + $assetCollection->setParent($parentCollection); + } else { + $assetCollection->setParent(null); + } + $this->assetCollectionRepository->update($assetCollection); + return true; } /** diff --git a/Classes/GraphQL/Resolver/Type/QueryResolver.php b/Classes/GraphQL/Resolver/Type/QueryResolver.php index 2402a1b63..d7bc5202e 100644 --- a/Classes/GraphQL/Resolver/Type/QueryResolver.php +++ b/Classes/GraphQL/Resolver/Type/QueryResolver.php @@ -14,8 +14,8 @@ * source code. */ -use Doctrine\ORM\ORMException; use Flowpack\Media\Ui\Domain\Model\AssetProxyIteratorAggregate; +use Flowpack\Media\Ui\Domain\Model\AssetSource\NeosAssetProxyRepository; use Flowpack\Media\Ui\Domain\Model\SearchTerm; use Flowpack\Media\Ui\Exception as MediaUiException; use Flowpack\Media\Ui\GraphQL\Context\AssetSourceContext; @@ -136,6 +136,7 @@ protected function createAssetProxyIterator( 'tagId' => $tagId, 'assetCollectionId' => $assetCollectionId, 'mediaType' => $mediaType, + 'assetType' => $assetType, 'searchTerm' => $searchTerm, 'sortBy' => $sortBy, 'sortDirection' => $sortDirection @@ -144,6 +145,7 @@ protected function createAssetProxyIterator( 'tagId' => null, 'assetCollectionId' => null, 'mediaType' => null, + 'assetType' => null, 'searchTerm' => null, 'sortBy' => null, 'sortDirection' => null, @@ -153,18 +155,37 @@ protected function createAssetProxyIterator( if (!$activeAssetSource) { return null; } - $assetProxyRepository = $activeAssetSource->getAssetProxyRepository(); - if (is_string($mediaType) && !empty($mediaType)) { + // Use our custom patched repository for querying the Neos asset source + if ($activeAssetSource instanceof NeosAssetSource) { + $assetProxyRepository = new NeosAssetProxyRepository($activeAssetSource); + } else { + $assetProxyRepository = $activeAssetSource->getAssetProxyRepository(); + } + + if (is_string($assetType) && !empty($assetType)) { try { - $assetTypeFilter = new AssetTypeFilter(ucfirst($mediaType)); + $assetTypeFilter = new AssetTypeFilter(ucfirst($assetType)); $assetProxyRepository->filterByType($assetTypeFilter); } catch (\InvalidArgumentException $e) { - $this->systemLogger->warning('Ignoring invalid mediatype when filtering assets ' . $mediaType); + $this->systemLogger->warning('Ignoring invalid asset type when filtering assets ' . $assetType); + } + } + + if (is_string($mediaType) && !empty($mediaType) && $assetProxyRepository instanceof NeosAssetProxyRepository) { + try { + $assetProxyRepository->filterByMediaType($mediaType); + } catch (\InvalidArgumentException $e) { + $this->systemLogger->warning('Ignoring invalid media-type when filtering assets ' . $mediaType); } } if ($assetCollectionId && $assetProxyRepository instanceof SupportsCollectionsInterface) { + if ($assetProxyRepository instanceof NeosAssetProxyRepository && $assetCollectionId === 'UNASSIGNED') { + return AssetProxyQueryIterator::from( + $assetProxyRepository->findUnassigned()->getQuery() + ); + } /** @var AssetCollection $assetCollection */ $assetCollection = $this->assetCollectionRepository->findByIdentifier($assetCollectionId); if ($assetCollection) { @@ -177,6 +198,9 @@ protected function createAssetProxyIterator( case 'name': $assetProxyRepository->orderBy(['resource.filename' => $sortDirection]); break; + case 'size': + $assetProxyRepository->orderBy(['resource.fileSize' => $sortDirection]); + break; case 'lastModified': default: $assetProxyRepository->orderBy(['lastModified' => $sortDirection]); @@ -185,6 +209,12 @@ protected function createAssetProxyIterator( } if ($tagId && $assetProxyRepository instanceof SupportsTaggingInterface) { + if ($tagId === 'UNTAGGED') { + return AssetProxyQueryIterator::from( + $assetProxyRepository->findUntagged()->getQuery() + ); + } + /** @var Tag $tag */ $tag = $this->tagRepository->findByIdentifier($tagId); if ($tag) { @@ -409,7 +439,6 @@ public function asset($_, array $variables, AssetSourceContext $assetSourceConte /** * Retrieves the variants of an asset * @return AssetVariantInterface[] - * @throws ORMException */ public function assetVariants($_, array $variables, AssetSourceContext $assetSourceContext): array { diff --git a/Classes/Infrastructure/Neos/Media/AssetProxyListIterator.php b/Classes/Infrastructure/Neos/Media/AssetProxyListIterator.php index e5bb184a3..e76b1dc9b 100644 --- a/Classes/Infrastructure/Neos/Media/AssetProxyListIterator.php +++ b/Classes/Infrastructure/Neos/Media/AssetProxyListIterator.php @@ -15,9 +15,11 @@ */ use Flowpack\Media\Ui\Domain\Model\AssetProxyIteratorAggregate; +use Neos\Flow\Annotations as Flow; use Neos\Media\Domain\Model\AssetSource\AssetProxy\AssetProxyInterface; /** + * @Flow\Proxy(false) * @internal */ final class AssetProxyListIterator implements AssetProxyIteratorAggregate @@ -25,7 +27,7 @@ final class AssetProxyListIterator implements AssetProxyIteratorAggregate /** * @var AssetProxyInterface[] */ - private $assetProxies; + private array $assetProxies; private int $offset = 0; private ?int $limit = null; @@ -45,16 +47,8 @@ public function setOffset(int $offset): void $this->offset = $offset; } - /** - * @param null|integer $limit - * @return void - */ - public function setLimit($limit): void + public function setLimit(?int $limit): void { - // TODO: Replace this assertion with a proper type hint - // once support for PHP 7.0 is dropped - assert($limit === null || is_int($limit)); - $this->limit = $limit; } diff --git a/Classes/Infrastructure/Neos/Media/AssetProxyQueryIterator.php b/Classes/Infrastructure/Neos/Media/AssetProxyQueryIterator.php index 74e26215b..d0a31730d 100644 --- a/Classes/Infrastructure/Neos/Media/AssetProxyQueryIterator.php +++ b/Classes/Infrastructure/Neos/Media/AssetProxyQueryIterator.php @@ -15,10 +15,12 @@ */ use Flowpack\Media\Ui\Domain\Model\AssetProxyIteratorAggregate; +use Neos\Flow\Annotations as Flow; use Neos\Media\Domain\Model\AssetSource\AssetProxy\AssetProxyInterface; use Neos\Media\Domain\Model\AssetSource\AssetProxyQueryInterface; /** + * @Flow\Proxy(false) * @internal */ final class AssetProxyQueryIterator implements AssetProxyIteratorAggregate @@ -40,21 +42,13 @@ public static function from(AssetProxyQueryInterface $assetProxyQuery): self public function setOffset(int $offset): void { - try { - // TODO: Check if it's an issue to execute the query a second time just to get the correct number of results? - $offset = $offset < $this->assetProxyQuery->execute()->count() ? $offset : 0; - } catch (\Exception $e) { - // TODO: Handle that not every asset source implements the count method => Introduce countable interface? - } - - $this->assetProxyQuery->setOffset($offset); + $this->assetProxyQuery->setOffset($offset >= $this->count() ? 0 : $offset); } /** - * @param null|integer $limit - * @return void + * @throws \RuntimeException */ - public function setLimit($limit): void + public function setLimit(?int $limit): void { // Unfortunately, AssetProxyQueryInterface::setLimit does not accept // `null` as a value, so we must filter it first. @@ -62,7 +56,7 @@ public function setLimit($limit): void // TODO: This check can be removed, once the following issue has been solved: // https://github.com/neos/neos-development-collection/issues/3962 if ($limit === null) { - throw new \Exception( + throw new \RuntimeException( 'Not supported: AssetProxyQueryInterface::setLimit does not accept `null`.', 1669221347 ); @@ -73,12 +67,7 @@ public function setLimit($limit): void public function count(): int { - try { - return $this->assetProxyQuery->execute()->count(); - } catch (\Exception $e) { - // TODO: Handle that not every asset source implements the count method => Introduce countable interface? - return 0; - } + return $this->assetProxyQuery->count(); } /** diff --git a/Classes/Service/AssetChangeLog.php b/Classes/Service/AssetChangeLog.php index eae9a9658..a234ee504 100644 --- a/Classes/Service/AssetChangeLog.php +++ b/Classes/Service/AssetChangeLog.php @@ -45,11 +45,7 @@ public function __construct(StringFrontend $cache, int $cacheLifetime) * Stores the asset id and the current timestamp in the cache. * The hash for the last change is also updated. * - * @param string $assetId - * @param \DateTimeInterface $lastModified - * @param string $type - * @throws CacheException - * @throws InvalidDataException + * @throws CacheException|InvalidDataException */ public function add(string $assetId, \DateTimeInterface $lastModified, string $type): void { @@ -57,11 +53,11 @@ public function add(string $assetId, \DateTimeInterface $lastModified, string $t 'assetId' => $assetId, 'lastModified' => $lastModified->format(DATE_W3C), 'type' => $type, - ]), ['changedAssets'], $this->cacheLifetime); + ], JSON_THROW_ON_ERROR), ['changedAssets'], $this->cacheLifetime); } /** - * @return array the assetId and timestamp for each change + * @return array[] the assetId and timestamp for each change */ public function getChanges(): array { diff --git a/Classes/Service/SimilarityService.php b/Classes/Service/SimilarityService.php index ecf51e45d..be768f6e0 100644 --- a/Classes/Service/SimilarityService.php +++ b/Classes/Service/SimilarityService.php @@ -39,7 +39,7 @@ class SimilarityService protected $objectManager; /** - * @return array + * @return AssetSimilarityStrategyInterface[] */ protected function getSimilarityStrategies(): array { @@ -52,8 +52,7 @@ protected function getSimilarityStrategies(): array } /** - * @param AssetInterface $asset - * @return array + * @return AssetInterface[] */ public function getSimilarAssets(AssetInterface $asset): array { diff --git a/Classes/Service/UsageDetailsService.php b/Classes/Service/UsageDetailsService.php index 96d2af6b9..d3d65c8bc 100644 --- a/Classes/Service/UsageDetailsService.php +++ b/Classes/Service/UsageDetailsService.php @@ -162,8 +162,10 @@ public function resolveUsagesForAsset(AssetInterface $asset): array // Should be solved via an interface in the future if (method_exists($strategy, 'getLabel')) { $usageByStrategy['label'] = $strategy->getLabel(); - } else if ($strategy instanceof AssetUsageInNodePropertiesStrategy) { - $usageByStrategy['label'] = $this->translateById('assetUsage.assetUsageInNodePropertiesStrategy.label'); + } else { + if ($strategy instanceof AssetUsageInNodePropertiesStrategy) { + $usageByStrategy['label'] = $this->translateById('assetUsage.assetUsageInNodePropertiesStrategy.label'); + } } if ($strategy instanceof UsageDetailsProviderInterface) { @@ -174,7 +176,8 @@ public function resolveUsagesForAsset(AssetInterface $asset): array try { $usageReferences = $strategy->getUsageReferences($asset); if (count($usageReferences) && $usageReferences[0] instanceof AssetUsageInNodeProperties) { - $usageByStrategy['metadataSchema'] = $this->getNodePropertiesUsageMetadataSchema($includeSites, $includeDimensions)->toArray(); + $usageByStrategy['metadataSchema'] = $this->getNodePropertiesUsageMetadataSchema($includeSites, + $includeDimensions)->toArray(); $usageByStrategy['usages'] = array_map(function (AssetUsageInNodeProperties $usage) use ( $includeSites, $includeDimensions @@ -192,21 +195,28 @@ public function resolveUsagesForAsset(AssetInterface $asset): array }); } - protected function getNodePropertiesUsageMetadataSchema(bool $includeSites, bool $includeDimensions): UsageMetadataSchema - { + protected function getNodePropertiesUsageMetadataSchema( + bool $includeSites, + bool $includeDimensions + ): UsageMetadataSchema { $schema = new UsageMetadataSchema(); if ($includeSites) { - $schema->withMetadata('site', $this->translateById('assetUsage.header.site'), UsageMetadataSchema::TYPE_TEXT); + $schema->withMetadata('site', $this->translateById('assetUsage.header.site'), + UsageMetadataSchema::TYPE_TEXT); } $schema - ->withMetadata('document', $this->translateById('assetUsage.header.document'), UsageMetadataSchema::TYPE_TEXT) - ->withMetadata('workspace', $this->translateById('assetUsage.header.workspace'), UsageMetadataSchema::TYPE_TEXT) - ->withMetadata('lastModified', $this->translateById('assetUsage.header.lastModified'), UsageMetadataSchema::TYPE_DATETIME); + ->withMetadata('document', $this->translateById('assetUsage.header.document'), + UsageMetadataSchema::TYPE_TEXT) + ->withMetadata('workspace', $this->translateById('assetUsage.header.workspace'), + UsageMetadataSchema::TYPE_TEXT) + ->withMetadata('lastModified', $this->translateById('assetUsage.header.lastModified'), + UsageMetadataSchema::TYPE_DATETIME); if ($includeDimensions) { - $schema->withMetadata('contentDimensions', $this->translateById('assetUsage.header.contentDimensions'), UsageMetadataSchema::TYPE_JSON); + $schema->withMetadata('contentDimensions', $this->translateById('assetUsage.header.contentDimensions'), + UsageMetadataSchema::TYPE_JSON); } return $schema; } diff --git a/Configuration/Settings.Features.yaml b/Configuration/Settings.Features.yaml index 5ab521609..0c77c98b9 100644 --- a/Configuration/Settings.Features.yaml +++ b/Configuration/Settings.Features.yaml @@ -3,14 +3,43 @@ Neos: Ui: frontendConfiguration: Flowpack.Media.Ui: - useNewMediaSelection: 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 + limitToSingleAssetCollectionPerAsset: false + # Set the number of assets and links to be displayed per page pagination: assetsPerPage: 20 maximumLinks: 5 + # Background polling for changes by other users + pollForChanges: true + # Settings for the property editor propertyEditor: collapsed: false + # Query the usage of each asset when loading them, note: this requires a more performant implementation of the asset usage than the Neos default provides + queryAssetUsage: false + # Show similar assets, note: this requires an additional package to be installed + showSimilarAssets: false + # Show variants and the editor to modify them + showVariantsEditor: false + # Use the new media selection UI in the content module + useNewMediaSelection: true + # Additional filter options + mediaTypeFilterOptions: + image: + 'image/svg+xml': 'SVG' + 'image/png': 'PNG' + 'image/jpeg': 'JPEG' + 'image/gif': 'GIF' + 'image/webp': 'WEBP' + document: + 'application/pdf': 'PDF' + audio: + 'audio/mpeg': 'MP3' + 'audio/ogg': 'OGG' + 'audio/wav': 'WAV' + 'audio/webm': 'WEBM' + video: + 'video/mp4': 'MP4' + 'video/ogg': 'OGG' + 'video/webm': 'WEBM' diff --git a/Configuration/Settings.Neos.yaml b/Configuration/Settings.Neos.yaml index c526984ea..80b0cf1f6 100644 --- a/Configuration/Settings.Neos.yaml +++ b/Configuration/Settings.Neos.yaml @@ -9,7 +9,7 @@ Neos: description: 'Flowpack.Media.Ui:Main:module.description' icon: 'fas fa-image' privilegeTarget: 'Flowpack.Media.Ui:ManageAssets' - mainStylesheet: 'Minimal' + mainStylesheet: 'Lite' position: 'before media' additionalResources: javaScripts: diff --git a/Migrations/Mysql/Version20230309030454.php b/Migrations/Mysql/Version20230309030454.php new file mode 100644 index 000000000..2a166eba4 --- /dev/null +++ b/Migrations/Mysql/Version20230309030454.php @@ -0,0 +1,35 @@ +abortIf(!$this->connection->getDatabasePlatform() instanceof MySqlPlatform, 'Migration can only be executed safely on "mysql".'); + + $this->addSql('ALTER TABLE neos_media_domain_model_assetcollection ADD parent VARCHAR(40) DEFAULT NULL'); + $this->addSql('ALTER TABLE neos_media_domain_model_assetcollection ADD CONSTRAINT FK_74C770A3D8E604F FOREIGN KEY (parent) REFERENCES neos_media_domain_model_assetcollection (persistence_object_identifier) ON DELETE SET NULL'); + $this->addSql('CREATE INDEX IDX_74C770A3D8E604F ON neos_media_domain_model_assetcollection (parent)'); + } + + public function down(Schema $schema): void + { + $this->abortIf(!$this->connection->getDatabasePlatform() instanceof MySqlPlatform, 'Migration can only be executed safely on "mysql".'); + + $this->addSql('ALTER TABLE neos_media_domain_model_assetcollection DROP FOREIGN KEY FK_74C770A3D8E604F'); + $this->addSql('DROP INDEX IDX_74C770A3D8E604F ON neos_media_domain_model_assetcollection'); + $this->addSql('ALTER TABLE neos_media_domain_model_assetcollection DROP parent'); + } +} diff --git a/Migrations/Postgresql/Version20230401171612.php b/Migrations/Postgresql/Version20230401171612.php new file mode 100644 index 000000000..8f2071b23 --- /dev/null +++ b/Migrations/Postgresql/Version20230401171612.php @@ -0,0 +1,35 @@ +abortIf(!$this->connection->getDatabasePlatform() instanceof PostgreSQL100Platform, 'Migration can only be executed safely on "postgresql".'); + + $this->addSql('ALTER TABLE neos_media_domain_model_assetcollection ADD parent VARCHAR(40) DEFAULT NULL'); + $this->addSql('ALTER TABLE neos_media_domain_model_assetcollection ADD CONSTRAINT FK_74C770A3D8E604F FOREIGN KEY (parent) REFERENCES neos_media_domain_model_assetcollection (persistence_object_identifier) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_74C770A3D8E604F ON neos_media_domain_model_assetcollection (parent)'); + } + + public function down(Schema $schema): void + { + $this->abortIf(!$this->connection->getDatabasePlatform()->getName() instanceof PostgreSQL100Platform, 'Migration can only be executed safely on "postgresql".'); + + $this->addSql('ALTER TABLE neos_media_domain_model_assetcollection DROP CONSTRAINT FK_74C770A3D8E604F'); + $this->addSql('DROP INDEX IDX_74C770A3D8E604F'); + $this->addSql('ALTER TABLE neos_media_domain_model_assetcollection DROP parent'); + } +} diff --git a/Readme.md b/Readme.md index d505514c4..14dfea9f2 100644 --- a/Readme.md +++ b/Readme.md @@ -36,8 +36,30 @@ Neos: useNewMediaSelection: false ``` +#### Hierarchical asset collections + +This package will enable a hierarchical asset collection structure via AOP (until the feature is in the Neos core). +With this feature you can add a collection in another collection or assign existing ones to another and +this way create a structure comparable with folders in your computers filesystem. + +It is recommended to enable the feature flag `limitToSingleAssetCollectionPerAsset` (see below) for a better experience. + ## Optional features +### Limit assets to be only assigned to one AssetCollection + +By limiting assets to only be in one collection you can enforce a more folder like experience: + +```yaml +Neos: + Neos: + Ui: + frontendConfiguration: + Flowpack.Media.Ui: + # Only allow a single asset collection selection per asset to treat collection like folders + limitToSingleAssetCollectionPerAsset: true +``` + ### Fast asset usage calculation & unused assets view The default asset usage in Neos is very slow as it is calculated when required. For that it checks @@ -223,8 +245,15 @@ First start the dev server via `yarn dev` and the run the following command to e yarn e2e ``` -The test configuration is defined in `.testcaferc.json`. Change the options there if you want to use -a different browser or make some other changes. +The test configuration is defined in `.testcaferc.json`. + +To use a different browser you can define it when running the tests: + +```console +yarn test firefox +``` + +Checkout the [Testcafe documentation](https://testcafe.io/documentation/402828/guides/intermediate-guides/browsers#browser-support) for more information and supported browsers. ### Run phpstan for codestyle checks @@ -233,13 +262,27 @@ First make sure you have [phpstan](https://phpstan.org) installed. When the package is installed in a Neos distribution: ```console -phpstan analyse --autoload-file ../../Libraries/autoload.php +composer run codestyle ``` When the package is standalone ```console -composer run codestyle +composer run codestyle:ci +``` + +### Run PHPUnit for unit tests + +When the package is installed in a Neos distribution: + +```console +composer run test +``` + +When the package is standalone + +```console +composer run test:ci ``` ### Other development hints diff --git a/Resources/Private/GraphQL/schema.root.graphql b/Resources/Private/GraphQL/schema.root.graphql index 2a7b4368c..a6fe80155 100644 --- a/Resources/Private/GraphQL/schema.root.graphql +++ b/Resources/Private/GraphQL/schema.root.graphql @@ -11,6 +11,7 @@ type Query { assetSourceId: AssetSourceId tagId: TagId assetCollectionId: AssetCollectionId + assetType: AssetType mediaType: MediaType searchTerm: String limit: Int @@ -26,6 +27,7 @@ type Query { assetSourceId: AssetSourceId tagId: TagId assetCollectionId: AssetCollectionId + assetType: AssetType mediaType: MediaType searchTerm: String ): Int! @@ -124,7 +126,7 @@ type Mutation { assetSourceId: AssetSourceId! filename: Filename! options: AssetEditOptionsInput! - ): EditAssetResult! + ): Boolean! tagAsset(id: AssetId!, assetSourceId: AssetSourceId!, tagId: TagId!): Asset! @@ -132,7 +134,7 @@ type Mutation { setAssetTags(id: AssetId!, assetSourceId: AssetSourceId!, tagIds: [TagId!]!): Asset! - setAssetCollections(id: AssetId!, assetSourceId: AssetSourceId!, assetCollectionIds: [AssetCollectionId!]!): Asset! + setAssetCollections(id: AssetId!, assetSourceId: AssetSourceId!, assetCollectionIds: [AssetCollectionId!]!): Boolean! deleteTag(id: TagId!): Boolean! @@ -144,11 +146,13 @@ type Mutation { importAsset(id: AssetId!, assetSourceId: AssetSourceId!): Asset! - createAssetCollection(title: String!): AssetCollection! + createAssetCollection(title: String!, parent: AssetCollectionId): AssetCollection! - deleteAssetCollection(id: AssetCollectionId!): DeleteAssetCollectionResult! + deleteAssetCollection(id: AssetCollectionId!): Boolean! - updateAssetCollection(id: AssetCollectionId!, title: String, tagIds: [TagId]): AssetCollection! + updateAssetCollection(id: AssetCollectionId!, title: String, tagIds: [TagId]): Boolean! + + setAssetCollectionParent(id: AssetCollectionId!, parent: AssetCollectionId): Boolean! updateTag(id: TagId!, label: String): Tag! } @@ -268,7 +272,9 @@ type AssetCollection { id: AssetCollectionId title: AssetCollectionTitle! assets: [Asset!]! + parent: AssetCollection tags: [Tag!]! + assetCount: Int! } """ @@ -326,20 +332,6 @@ type FileUploadResult { result: String! } -""" -The result of a single file upload -""" -type EditAssetResult { - success: Boolean! -} - -""" -The result of asset collection deletion -""" -type DeleteAssetCollectionResult { - success: Boolean! -} - """ The result of the changed assets query containing the hash of the last change and all changed asset ids """ @@ -400,13 +392,17 @@ Fields to sort assets by """ enum SortBy { """ - The ressource file name + The resource file name """ name """ Last modification date """ lastModified + """ + The resource file size + """ + size } """ @@ -447,6 +443,11 @@ IANA media type of an Asset (e.g. "image/jpeg") """ scalar MediaType +""" +Neos type of an Asset, can be "All", "Image", "Document", "Video" or "Audio" (see `Neos\Media\Domain\Model\AssetSource\AssetTypeFilter`) +""" +scalar AssetType + """ A File extension (e.g. "pdf") """ diff --git a/Resources/Private/JavaScript/asset-collections/package.json b/Resources/Private/JavaScript/asset-collections/package.json new file mode 100644 index 000000000..420574b44 --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/package.json @@ -0,0 +1,10 @@ +{ + "name": "@media-ui/feature-asset-collections", + "version": "1.0.0", + "license": "GNU GPLv3", + "private": true, + "main": "src/index.ts", + "dependencies": { + "@media-ui/feature-asset-tags": "workspace:*" + } +} diff --git a/Resources/Private/JavaScript/asset-collections/src/components/AddAssetCollectionButton.module.css b/Resources/Private/JavaScript/asset-collections/src/components/AddAssetCollectionButton.module.css new file mode 100644 index 000000000..b5b9cf20d --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/components/AddAssetCollectionButton.module.css @@ -0,0 +1,5 @@ +.plusIcon { + top: 15px !important; + left: 13px !important; + width: 9px !important; +} diff --git a/Resources/Private/JavaScript/media-module/src/components/SideBarLeft/Tree/AddAssetCollectionButton.tsx b/Resources/Private/JavaScript/asset-collections/src/components/AddAssetCollectionButton.tsx similarity index 51% rename from Resources/Private/JavaScript/media-module/src/components/SideBarLeft/Tree/AddAssetCollectionButton.tsx rename to Resources/Private/JavaScript/asset-collections/src/components/AddAssetCollectionButton.tsx index 3f2edb1da..0ef58629b 100644 --- a/Resources/Private/JavaScript/media-module/src/components/SideBarLeft/Tree/AddAssetCollectionButton.tsx +++ b/Resources/Private/JavaScript/asset-collections/src/components/AddAssetCollectionButton.tsx @@ -1,29 +1,19 @@ -import * as React from 'react'; -import { useCallback } from 'react'; -import { useSetRecoilState } from 'recoil'; +import React from 'react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { Button, Icon } from '@neos-project/react-ui-components'; -import { useIntl, createUseMediaUiStyles } from '@media-ui/core/src'; +import { useIntl } from '@media-ui/core'; -import { createAssetCollectionDialogState } from '../../../state'; +import { createAssetCollectionDialogVisibleState } from '../state/createAssetCollectionDialogVisibleState'; +import { assetCollectionTreeViewState } from '../state/assetCollectionTreeViewState'; -const useStyles = createUseMediaUiStyles({ - plusIcon: { - top: '15px !important', - left: '13px !important', - width: '9px !important', - }, -}); +import classes from './AddAssetCollectionButton.module.css'; const AddAssetCollectionButton: React.FC = () => { - const classes = useStyles(); const { translate } = useIntl(); - const setCreateAssetCollectionDialogState = useSetRecoilState(createAssetCollectionDialogState); - - const onClickCreate = useCallback(() => { - setCreateAssetCollectionDialogState({ title: '', visible: true }); - }, [setCreateAssetCollectionDialogState]); + const setCreateAssetCollectionDialogState = useSetRecoilState(createAssetCollectionDialogVisibleState); + const assetCollectionTreeView = useRecoilValue(assetCollectionTreeViewState); return ( , + , + ]} + > +
+ + +
+ + ); +}; + +export default React.memo(CreateAssetCollectionDialog); diff --git a/Resources/Private/JavaScript/asset-collections/src/components/DeleteButton.tsx b/Resources/Private/JavaScript/asset-collections/src/components/DeleteButton.tsx new file mode 100644 index 000000000..ea65d90cb --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/components/DeleteButton.tsx @@ -0,0 +1,88 @@ +import React, { useCallback } from 'react'; +import { useSetRecoilState } from 'recoil'; + +import { IconButton } from '@neos-project/react-ui-components'; + +import { useIntl, useMediaUi, useNotify } from '@media-ui/core'; +import { selectedAssetCollectionAndTagState } from '@media-ui/core/src/state'; +import { useDeleteTag, useSelectedTag } from '@media-ui/feature-asset-tags'; + +import useDeleteAssetCollection from '../hooks/useDeleteAssetCollection'; +import useSelectedAssetCollection from '../hooks/useSelectedAssetCollection'; + +const DeleteButton: React.FC = () => { + const { translate } = useIntl(); + const Notify = useNotify(); + const { approvalAttainmentStrategy } = useMediaUi(); + const selectedAssetCollection = useSelectedAssetCollection(); + const selectedTag = useSelectedTag(); + const { deleteTag } = useDeleteTag(); + const { deleteAssetCollection } = useDeleteAssetCollection(); + const setSelectedAssetCollectionAndTag = useSetRecoilState(selectedAssetCollectionAndTagState); + + const onClickDelete = useCallback(async () => { + if (selectedTag) { + const canDeleteTag = await approvalAttainmentStrategy.obtainApprovalToDeleteTag({ + tag: selectedTag, + }); + if (!canDeleteTag) return; + // TODO: Implement `obtainApprovalToDeleteCollection` for deleting + const confirm = window.confirm( + translate('action.deleteTag.confirm', 'Do you really want to delete the tag ' + selectedTag.label, [ + selectedTag.label, + ]) + ); + if (!confirm) return; + deleteTag(selectedTag.id) + .then(() => { + Notify.ok(translate('action.deleteTag.success', 'The tag has been deleted')); + setSelectedAssetCollectionAndTag(({ assetCollectionId }) => ({ tagId: null, assetCollectionId })); + }) + .catch(({ message }) => { + Notify.error(translate('action.deleteTag.error', 'Error while trying to delete the tag'), message); + }); + } else if (selectedAssetCollection) { + const canDeleteAssetCollection = await approvalAttainmentStrategy.obtainApprovalToDeleteAssetCollection({ + assetCollection: selectedAssetCollection, + }); + if (!canDeleteAssetCollection) return; + + deleteAssetCollection(selectedAssetCollection.id) + .then(() => { + Notify.ok( + translate('assetCollectionActions.delete.success', 'Asset collection was successfully deleted') + ); + setSelectedAssetCollectionAndTag({ tagId: null, assetCollectionId: null }); + }) + .catch((error) => { + Notify.error( + translate('assetCollectionActions.delete.error', 'Failed to delete asset collection'), + error.message + ); + }); + } + }, [ + selectedTag, + selectedAssetCollection, + translate, + deleteTag, + Notify, + setSelectedAssetCollectionAndTag, + approvalAttainmentStrategy, + deleteAssetCollection, + ]); + + return ( + + ); +}; + +export default React.memo(DeleteButton); diff --git a/Resources/Private/JavaScript/asset-collections/src/components/FavouriteButton.tsx b/Resources/Private/JavaScript/asset-collections/src/components/FavouriteButton.tsx new file mode 100644 index 000000000..e4846d64c --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/components/FavouriteButton.tsx @@ -0,0 +1,33 @@ +import React, { useCallback } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; + +import { IconButton } from '@neos-project/react-ui-components'; + +import { useIntl } from '@media-ui/core'; + +import { selectedAssetCollectionIdState } from '../state/selectedAssetCollectionIdState'; +import { assetCollectionFavouriteState } from '../state/assetCollectionFavouritesState'; + +const FavouriteButton: React.FC = () => { + const { translate } = useIntl(); + const selectedAssetCollectionId = useRecoilValue(selectedAssetCollectionIdState); + const [isFavourite, setIsFavourite] = useRecoilState(assetCollectionFavouriteState(selectedAssetCollectionId)); + + const toggleFavourite = useCallback(() => { + setIsFavourite((prev) => !prev); + }, [setIsFavourite]); + + return ( + + ); +}; + +export default React.memo(FavouriteButton); diff --git a/Resources/Private/JavaScript/asset-collections/src/components/TagTreeNode.tsx b/Resources/Private/JavaScript/asset-collections/src/components/TagTreeNode.tsx new file mode 100644 index 000000000..86e24b8fd --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/components/TagTreeNode.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { selectorFamily, useRecoilValue, useSetRecoilState } from 'recoil'; + +import { Tree } from '@neos-project/react-ui-components'; + +import dndTypes from '@media-ui/core/src/constants/dndTypes'; +import { selectedAssetCollectionAndTagState } from '@media-ui/core/src/state'; +import { selectedAssetCollectionIdState } from '@media-ui/feature-asset-collections'; +import { selectedTagIdState } from '@media-ui/feature-asset-tags'; + +export interface TagTreeNodeProps extends TreeNodeProps { + tagId: string; + label: string; + assetCollectionId?: string; + level: number; + icon?: string; + customIconComponent?: React.ReactNode; +} + +// This state selector provides the focused state for each individual asset collection +const tagFocusedState = selectorFamily({ + key: 'TagFocusedState', + get: + ({ assetCollectionId, tagId }) => + ({ get }) => + get(selectedAssetCollectionIdState) === assetCollectionId && get(selectedTagIdState) === tagId, +}); + +const TagTreeNode: React.FC = ({ + tagId, + assetCollectionId, + label, + level, + icon = 'tag', + customIconComponent, +}: TagTreeNodeProps) => { + const selectAssetCollectionAndTag = useSetRecoilState(selectedAssetCollectionAndTagState); + const isFocused = useRecoilValue(tagFocusedState({ assetCollectionId, tagId })); + + return ( + + selectAssetCollectionAndTag({ tagId, assetCollectionId })} + hasChildren={false} + /> + + ); +}; + +export default React.memo(TagTreeNode); diff --git a/Resources/Private/JavaScript/core/src/fragments/assetCollection.ts b/Resources/Private/JavaScript/asset-collections/src/fragments/assetCollection.ts similarity index 60% rename from Resources/Private/JavaScript/core/src/fragments/assetCollection.ts rename to Resources/Private/JavaScript/asset-collections/src/fragments/assetCollection.ts index d3ff24e2f..19a0bdff3 100644 --- a/Resources/Private/JavaScript/core/src/fragments/assetCollection.ts +++ b/Resources/Private/JavaScript/asset-collections/src/fragments/assetCollection.ts @@ -1,13 +1,18 @@ import { gql } from '@apollo/client'; -import { TAG_FRAGMENT } from './tag'; +import { TAG_FRAGMENT } from '@media-ui/feature-asset-tags/src/fragments/tag'; export const ASSET_COLLECTION_FRAGMENT = gql` fragment AssetCollectionProps on AssetCollection { id title + parent { + id + title + } tags { ...TagProps } + assetCount } ${TAG_FRAGMENT} `; diff --git a/Resources/Private/JavaScript/asset-collections/src/helpers/collectionPath.ts b/Resources/Private/JavaScript/asset-collections/src/helpers/collectionPath.ts new file mode 100644 index 000000000..66eb9f5cc --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/helpers/collectionPath.ts @@ -0,0 +1,13 @@ +export function collectionPath(collection: AssetCollection, collections: AssetCollection[]) { + const path: { title: string; id: string }[] = []; + + // Build the absolute path from the given collection to the root + let parentCollection = collection; + while (parentCollection) { + path.push({ title: parentCollection.title, id: parentCollection.id }); + parentCollection = parentCollection.parent + ? collections.find(({ id }) => id === parentCollection.parent.id) + : null; + } + return path.reverse(); +} diff --git a/Resources/Private/JavaScript/asset-collections/src/hooks/useAssetCollectionQuery.ts b/Resources/Private/JavaScript/asset-collections/src/hooks/useAssetCollectionQuery.ts new file mode 100644 index 000000000..31a4a484b --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/hooks/useAssetCollectionQuery.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@apollo/client'; + +import { ASSET_COLLECTION } from '../queries/assetCollection'; + +interface AssetCollectionQueryResult { + assetCollection: AssetCollection; +} + +export const UNASSIGNED_COLLECTION_ID = 'UNASSIGNED'; + +export function useAssetCollectionQuery(assetCollectionId?: string) { + const { data, loading, refetch } = useQuery(ASSET_COLLECTION, { + variables: { id: assetCollectionId }, + skip: !assetCollectionId || assetCollectionId === UNASSIGNED_COLLECTION_ID, + }); + return { assetCollection: data?.assetCollection || null, loading, refetch }; +} diff --git a/Resources/Private/JavaScript/core/src/hooks/useAssetCollectionsQuery.ts b/Resources/Private/JavaScript/asset-collections/src/hooks/useAssetCollectionsQuery.ts similarity index 78% rename from Resources/Private/JavaScript/core/src/hooks/useAssetCollectionsQuery.ts rename to Resources/Private/JavaScript/asset-collections/src/hooks/useAssetCollectionsQuery.ts index ca53ce1cc..89cfe8a7e 100644 --- a/Resources/Private/JavaScript/core/src/hooks/useAssetCollectionsQuery.ts +++ b/Resources/Private/JavaScript/asset-collections/src/hooks/useAssetCollectionsQuery.ts @@ -1,7 +1,6 @@ import { useQuery } from '@apollo/client'; -import { AssetCollection } from '../interfaces'; -import { ASSET_COLLECTIONS } from '../queries'; +import { ASSET_COLLECTIONS } from '../queries/assetCollections'; interface AssetCollectionsQueryResult { assetCollections: AssetCollection[]; diff --git a/Resources/Private/JavaScript/core/src/hooks/useCreateAssetCollection.ts b/Resources/Private/JavaScript/asset-collections/src/hooks/useCreateAssetCollection.ts similarity index 75% rename from Resources/Private/JavaScript/core/src/hooks/useCreateAssetCollection.ts rename to Resources/Private/JavaScript/asset-collections/src/hooks/useCreateAssetCollection.ts index 275e05ffd..07b0264e3 100644 --- a/Resources/Private/JavaScript/core/src/hooks/useCreateAssetCollection.ts +++ b/Resources/Private/JavaScript/asset-collections/src/hooks/useCreateAssetCollection.ts @@ -1,11 +1,11 @@ import { useMutation } from '@apollo/client'; -import { ASSET_COLLECTIONS } from '../queries'; -import { AssetCollection } from '../interfaces'; -import { CREATE_ASSET_COLLECTION } from '../mutations'; +import { CREATE_ASSET_COLLECTION } from '../mutations/createAssetCollection'; +import { ASSET_COLLECTIONS } from '../queries/assetCollections'; interface CreateAssetCollectionVariables { title: string; + parent: string | null; } export default function useCreateAssetCollection() { @@ -14,10 +14,11 @@ export default function useCreateAssetCollection() { CreateAssetCollectionVariables >(CREATE_ASSET_COLLECTION); - const createAssetCollection = (title: string) => + const createAssetCollection = (title: string, parentCollectionId: string = null) => action({ variables: { title, + parent: parentCollectionId, }, update(cache, { data }) { const { assetCollections } = cache.readQuery<{ assetCollections: AssetCollection[] }>({ diff --git a/Resources/Private/JavaScript/core/src/hooks/useDeleteAssetCollection.ts b/Resources/Private/JavaScript/asset-collections/src/hooks/useDeleteAssetCollection.ts similarity index 57% rename from Resources/Private/JavaScript/core/src/hooks/useDeleteAssetCollection.ts rename to Resources/Private/JavaScript/asset-collections/src/hooks/useDeleteAssetCollection.ts index dfb39142e..74a46ba76 100644 --- a/Resources/Private/JavaScript/core/src/hooks/useDeleteAssetCollection.ts +++ b/Resources/Private/JavaScript/asset-collections/src/hooks/useDeleteAssetCollection.ts @@ -1,30 +1,23 @@ import { useMutation } from '@apollo/client'; -import { ASSET_COLLECTIONS } from '../queries'; -import { AssetCollection, DeleteAssetCollectionResult } from '../interfaces'; -import { DELETE_ASSET_COLLECTION } from '../mutations'; +import { ASSET_COLLECTIONS } from '../queries/assetCollections'; +import { DELETE_ASSET_COLLECTION } from '../mutations/deleteAssetCollection'; interface DeleteAssetCollectionVariables { id: string; } export default function useDeleteAssetCollection() { - const [action, { error, data, loading }] = useMutation< - { deleteAssetCollection: DeleteAssetCollectionResult }, - DeleteAssetCollectionVariables - >(DELETE_ASSET_COLLECTION); + const [action, { error, data, loading }] = useMutation( + DELETE_ASSET_COLLECTION + ); const deleteAssetCollection = (id: string) => action({ variables: { id, }, - optimisticResponse: { - deleteAssetCollection: { - success: true, - __typename: 'DeleteAssetCollectionResult', - }, - }, + optimisticResponse: true, update(cache) { const { assetCollections } = cache.readQuery<{ assetCollections: AssetCollection[] }>({ query: ASSET_COLLECTIONS, diff --git a/Resources/Private/JavaScript/core/src/hooks/useSelectedAssetCollection.ts b/Resources/Private/JavaScript/asset-collections/src/hooks/useSelectedAssetCollection.ts similarity index 79% rename from Resources/Private/JavaScript/core/src/hooks/useSelectedAssetCollection.ts rename to Resources/Private/JavaScript/asset-collections/src/hooks/useSelectedAssetCollection.ts index 64c4d24ad..6167a4a44 100644 --- a/Resources/Private/JavaScript/core/src/hooks/useSelectedAssetCollection.ts +++ b/Resources/Private/JavaScript/asset-collections/src/hooks/useSelectedAssetCollection.ts @@ -1,9 +1,8 @@ import { useRecoilValue } from 'recoil'; import { useQuery } from '@apollo/client'; -import { AssetCollection } from '../interfaces'; -import { selectedAssetCollectionIdState } from '../state'; -import { ASSET_COLLECTION } from '../queries'; +import { selectedAssetCollectionIdState } from '../state/selectedAssetCollectionIdState'; +import { ASSET_COLLECTION } from '../queries/assetCollection'; interface AssetCollectionQueryResult { assetCollection: AssetCollection; diff --git a/Resources/Private/JavaScript/asset-collections/src/hooks/useSetAssetCollectionParent.ts b/Resources/Private/JavaScript/asset-collections/src/hooks/useSetAssetCollectionParent.ts new file mode 100644 index 000000000..a1d5dd033 --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/hooks/useSetAssetCollectionParent.ts @@ -0,0 +1,55 @@ +import { useCallback } from 'react'; +import { useMutation } from '@apollo/client'; + +import { SET_ASSET_COLLECTION_PARENT } from '../mutations/setAssetCollectionParent'; + +interface SetAssetCollectionParentProps { + assetCollection: AssetCollection; + parent: AssetCollection | null; +} + +interface SetAssetCollectionParentVariables { + id: string; + parent?: string; +} + +export function useSetAssetCollectionParent() { + const [action, { error, data, loading }] = useMutation( + SET_ASSET_COLLECTION_PARENT + ); + + const setAssetCollectionParent = useCallback( + ({ assetCollection, parent }: SetAssetCollectionParentProps) => + action({ + variables: { + id: assetCollection.id, + parent: parent?.id, + }, + optimisticResponse: true, + update: (cache, { data }) => { + if (!data) return; + cache.modify({ + id: cache.identify({ + __typename: 'AssetCollection', + id: assetCollection.id, + }), + broadcast: false, + fields: { + parent: () => + parent + ? { + __ref: cache.identify({ + __typename: 'AssetCollection', + id: parent.id, + }), + } + : null, + }, + }); + }, + }), + [action] + ); + + return { setAssetCollectionParent, data, error, loading }; +} diff --git a/Resources/Private/JavaScript/asset-collections/src/hooks/useUpdateAssetCollection.ts b/Resources/Private/JavaScript/asset-collections/src/hooks/useUpdateAssetCollection.ts new file mode 100644 index 000000000..6de50cb5a --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/hooks/useUpdateAssetCollection.ts @@ -0,0 +1,62 @@ +import { useCallback } from 'react'; +import { useMutation } from '@apollo/client'; + +import { UPDATE_ASSET_COLLECTION } from '../mutations/updateAssetCollection'; + +interface UpdateAssetCollectionProps { + assetCollection: AssetCollection; + title?: string; + tags?: Tag[]; + parent?: AssetCollection; +} + +interface UpdateAssetCollectionVariables { + id: string; + title?: string; + tagIds?: string[]; + parent?: string; +} + +export default function useUpdateAssetCollection() { + const [action, { error, data, loading }] = useMutation< + { updateAssetCollection: AssetCollection }, + UpdateAssetCollectionVariables + >(UPDATE_ASSET_COLLECTION); + + const updateAssetCollection = useCallback( + ({ assetCollection, title, tags, parent }: UpdateAssetCollectionProps) => + action({ + variables: { + id: assetCollection.id, + title, + tagIds: tags?.map((tag) => tag.id), + parent: parent === null ? null : parent?.id, + }, + refetchQueries: ['ASSET_COLLECTIONS'], + optimisticResponse: { + updateAssetCollection: { + ...assetCollection, + title, + ...(title + ? { + title, + } + : {}), + ...(parent + ? { + ...parent, + } + : {}), + ...(tags + ? { + tags, + } + : {}), + }, + }, + }), + [action] + ); + + return { updateAssetCollection, data, error, loading }; +} diff --git a/Resources/Private/JavaScript/asset-collections/src/index.ts b/Resources/Private/JavaScript/asset-collections/src/index.ts new file mode 100644 index 000000000..c3d3091f7 --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/index.ts @@ -0,0 +1,33 @@ +export { useAssetCollectionQuery } from './hooks/useAssetCollectionQuery'; +export { default as useAssetCollectionsQuery } from './hooks/useAssetCollectionsQuery'; +export { default as useSelectedAssetCollection } from './hooks/useSelectedAssetCollection'; +export { default as useDeleteAssetCollection } from './hooks/useDeleteAssetCollection'; +export { default as useUpdateAssetCollection } from './hooks/useUpdateAssetCollection'; +export { default as useCreateAssetCollection } from './hooks/useCreateAssetCollection'; +export { useSetAssetCollectionParent } from './hooks/useSetAssetCollectionParent'; + +export { default as AssetCollectionTree } from './components/AssetCollectionTree'; +export { default as CreateAssetCollectionDialog } from './components/CreateAssetCollectionDialog'; + +export { ASSET_COLLECTION_FRAGMENT } from './fragments/assetCollection'; + +export { ASSET_COLLECTION } from './queries/assetCollection'; +export { ASSET_COLLECTIONS } from './queries/assetCollections'; + +export { CREATE_ASSET_COLLECTION } from './mutations/createAssetCollection'; +export { DELETE_ASSET_COLLECTION } from './mutations/deleteAssetCollection'; +export { UPDATE_ASSET_COLLECTION } from './mutations/updateAssetCollection'; +export { SET_ASSET_COLLECTION_PARENT } from './mutations/setAssetCollectionParent'; + +export { selectedAssetCollectionIdState } from './state/selectedAssetCollectionIdState'; +export { createAssetCollectionDialogVisibleState } from './state/createAssetCollectionDialogVisibleState'; +export { assetCollectionFavouritesState } from './state/assetCollectionFavouritesState'; +export { assetCollectionTreeViewState } from './state/assetCollectionTreeViewState'; +export { assetCollectionFocusedState } from './state/assetCollectionFocusedState'; +export { assetCollectionActiveState } from './state/assetCollectionActiveState'; +export { + assetCollectionTreeCollapsedState, + assetCollectionTreeCollapsedItemState, +} from './state/assetCollectionTreeCollapsedState'; + +export { collectionPath } from './helpers/collectionPath'; diff --git a/Resources/Private/JavaScript/core/src/mutations/createAssetCollection.ts b/Resources/Private/JavaScript/asset-collections/src/mutations/createAssetCollection.ts similarity index 51% rename from Resources/Private/JavaScript/core/src/mutations/createAssetCollection.ts rename to Resources/Private/JavaScript/asset-collections/src/mutations/createAssetCollection.ts index 8eba5e1c1..918e3a2ef 100644 --- a/Resources/Private/JavaScript/core/src/mutations/createAssetCollection.ts +++ b/Resources/Private/JavaScript/asset-collections/src/mutations/createAssetCollection.ts @@ -2,13 +2,11 @@ import { gql } from '@apollo/client'; import { ASSET_COLLECTION_FRAGMENT } from '../fragments/assetCollection'; -const CREATE_ASSET_COLLECTION = gql` - mutation CreateAssetCollection($title: String!) { - createAssetCollection(title: $title) { +export const CREATE_ASSET_COLLECTION = gql` + mutation CreateAssetCollection($title: String!, $parent: AssetCollectionId) { + createAssetCollection(title: $title, parent: $parent) { ...AssetCollectionProps } } ${ASSET_COLLECTION_FRAGMENT} `; - -export default CREATE_ASSET_COLLECTION; diff --git a/Resources/Private/JavaScript/asset-collections/src/mutations/deleteAssetCollection.ts b/Resources/Private/JavaScript/asset-collections/src/mutations/deleteAssetCollection.ts new file mode 100644 index 000000000..dc69311fc --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/mutations/deleteAssetCollection.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const DELETE_ASSET_COLLECTION = gql` + mutation DeleteAssetCollection($id: AssetCollectionId!) { + deleteAssetCollection(id: $id) + } +`; diff --git a/Resources/Private/JavaScript/asset-collections/src/mutations/setAssetCollectionParent.ts b/Resources/Private/JavaScript/asset-collections/src/mutations/setAssetCollectionParent.ts new file mode 100644 index 000000000..b60101a7c --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/mutations/setAssetCollectionParent.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const SET_ASSET_COLLECTION_PARENT = gql` + mutation SetAssetCollectionParent($id: AssetCollectionId!, $parent: AssetCollectionId) { + setAssetCollectionParent(id: $id, parent: $parent) + } +`; diff --git a/Resources/Private/JavaScript/asset-collections/src/mutations/updateAssetCollection.ts b/Resources/Private/JavaScript/asset-collections/src/mutations/updateAssetCollection.ts new file mode 100644 index 000000000..78e035dfd --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/mutations/updateAssetCollection.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_ASSET_COLLECTION = gql` + mutation UpdateAssetCollection($id: AssetCollectionId!, $title: String, $tagIds: [TagId]) { + updateAssetCollection(id: $id, title: $title, tagIds: $tagIds) + } +`; diff --git a/Resources/Private/JavaScript/core/src/queries/assetCollection.ts b/Resources/Private/JavaScript/asset-collections/src/queries/assetCollection.ts similarity index 81% rename from Resources/Private/JavaScript/core/src/queries/assetCollection.ts rename to Resources/Private/JavaScript/asset-collections/src/queries/assetCollection.ts index 29d606329..cddb9b42f 100644 --- a/Resources/Private/JavaScript/core/src/queries/assetCollection.ts +++ b/Resources/Private/JavaScript/asset-collections/src/queries/assetCollection.ts @@ -2,7 +2,7 @@ import { gql } from '@apollo/client'; import { ASSET_COLLECTION_FRAGMENT } from '../fragments/assetCollection'; -const ASSET_COLLECTION = gql` +export const ASSET_COLLECTION = gql` query ASSET_COLLECTION($id: AssetCollectionId!) { assetCollection(id: $id) { ...AssetCollectionProps @@ -10,5 +10,3 @@ const ASSET_COLLECTION = gql` } ${ASSET_COLLECTION_FRAGMENT} `; - -export default ASSET_COLLECTION; diff --git a/Resources/Private/JavaScript/core/src/queries/assetCollections.ts b/Resources/Private/JavaScript/asset-collections/src/queries/assetCollections.ts similarity index 79% rename from Resources/Private/JavaScript/core/src/queries/assetCollections.ts rename to Resources/Private/JavaScript/asset-collections/src/queries/assetCollections.ts index c5419e0f6..53e03351a 100644 --- a/Resources/Private/JavaScript/core/src/queries/assetCollections.ts +++ b/Resources/Private/JavaScript/asset-collections/src/queries/assetCollections.ts @@ -2,7 +2,7 @@ import { gql } from '@apollo/client'; import { ASSET_COLLECTION_FRAGMENT } from '../fragments/assetCollection'; -const ASSET_COLLECTIONS = gql` +export const ASSET_COLLECTIONS = gql` query ASSET_COLLECTIONS { assetCollections { ...AssetCollectionProps @@ -10,5 +10,3 @@ const ASSET_COLLECTIONS = gql` } ${ASSET_COLLECTION_FRAGMENT} `; - -export default ASSET_COLLECTIONS; diff --git a/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionActiveState.ts b/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionActiveState.ts new file mode 100644 index 000000000..90b2607c9 --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionActiveState.ts @@ -0,0 +1,16 @@ +import { selectorFamily } from 'recoil'; + +import { selectedAssetCollectionAndTagState } from '@media-ui/core/src/state'; + +// This state selector provides the active state for each individual asset collection based on its tags +export const assetCollectionActiveState = selectorFamily({ + key: 'AssetCollectionActiveState', + get: + (assetCollectionId) => + ({ get }) => { + const { assetCollectionId: selectedAssetCollectionId, tagId: selectedTagId } = get( + selectedAssetCollectionAndTagState + ); + return assetCollectionId === selectedAssetCollectionId && !!selectedTagId; + }, +}); diff --git a/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionFavouritesState.ts b/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionFavouritesState.ts new file mode 100644 index 000000000..374bb478f --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionFavouritesState.ts @@ -0,0 +1,30 @@ +import { atom, selectorFamily } from 'recoil'; + +import { localStorageEffect } from '@media-ui/core/src/state'; + +export const assetCollectionFavouritesState = atom>({ + key: 'AssetCollectionFavouritesState', + default: {}, + effects: [localStorageEffect('AssetCollectionFavouritesState')], +}); + +export const assetCollectionFavouriteState = selectorFamily({ + key: 'AssetCollectionFavouriteState', + get: + (assetCollectionId) => + ({ get }) => + !!get(assetCollectionFavouritesState)[assetCollectionId], + set: + (assetCollectionId) => + ({ set }, newValue: boolean) => + set(assetCollectionFavouritesState, (prevState) => { + const newState = { + ...prevState, + [assetCollectionId]: newValue, + }; + if (newState[assetCollectionId] === false) { + delete newState[assetCollectionId]; + } + return newState; + }), +}); diff --git a/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionFocusedState.ts b/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionFocusedState.ts new file mode 100644 index 000000000..55e69fb73 --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionFocusedState.ts @@ -0,0 +1,12 @@ +import { selectorFamily } from 'recoil'; + +import { selectedAssetCollectionIdState } from './selectedAssetCollectionIdState'; + +// This state selector provides the focused state for each individual asset collection +export const assetCollectionFocusedState = selectorFamily({ + key: 'AssetCollectionFocusedState', + get: + (assetCollectionId) => + ({ get }) => + get(selectedAssetCollectionIdState) === assetCollectionId, +}); diff --git a/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionTreeCollapsedState.ts b/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionTreeCollapsedState.ts new file mode 100644 index 000000000..2c5a09479 --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionTreeCollapsedState.ts @@ -0,0 +1,30 @@ +import { atom, selectorFamily } from 'recoil'; + +import { localStorageEffect } from '@media-ui/core/src/state'; + +export const assetCollectionTreeCollapsedState = atom>({ + key: 'AssetCollectionTreeState', + default: {}, + effects: [localStorageEffect('AssetCollectionTreeState')], +}); + +export const assetCollectionTreeCollapsedItemState = selectorFamily({ + key: 'AssetCollectionTreeCollapsedProxyState', + get: + (assetCollectionId) => + ({ get }) => + get(assetCollectionTreeCollapsedState)[assetCollectionId] ?? true, + set: + (assetCollectionId) => + ({ set }, newValue: boolean) => + set(assetCollectionTreeCollapsedState, (prevState) => { + const newState = { + ...prevState, + [assetCollectionId]: newValue, + }; + if (newState[assetCollectionId] === true) { + delete newState[assetCollectionId]; + } + return newState; + }), +}); diff --git a/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionTreeViewState.ts b/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionTreeViewState.ts new file mode 100644 index 000000000..65f1d7268 --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/state/assetCollectionTreeViewState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; +import { localStorageEffect } from '@media-ui/core/src/state'; + +export const assetCollectionTreeViewState = atom<'collections' | 'favourites'>({ + key: 'AssetCollectionTreeViewState', + default: 'collections', + effects: [localStorageEffect('AssetCollectionTreeViewState')], +}); diff --git a/Resources/Private/JavaScript/asset-collections/src/state/createAssetCollectionDialogVisibleState.ts b/Resources/Private/JavaScript/asset-collections/src/state/createAssetCollectionDialogVisibleState.ts new file mode 100644 index 000000000..8bf05f779 --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/state/createAssetCollectionDialogVisibleState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const createAssetCollectionDialogVisibleState = atom({ + key: 'createAssetCollectionDialogState', + default: false, +}); diff --git a/Resources/Private/JavaScript/asset-collections/src/state/selectedAssetCollectionIdState.ts b/Resources/Private/JavaScript/asset-collections/src/state/selectedAssetCollectionIdState.ts new file mode 100644 index 000000000..f9359e0f6 --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/src/state/selectedAssetCollectionIdState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; +import { localStorageEffect } from '@media-ui/core/src/state'; + +export const selectedAssetCollectionIdState = atom({ + key: 'SelectedAssetCollectionIdState', + default: null, + effects: [localStorageEffect('SelectedAssetCollectionIdState')], +}); diff --git a/Resources/Private/JavaScript/asset-collections/tsconfig.json b/Resources/Private/JavaScript/asset-collections/tsconfig.json new file mode 100644 index 000000000..9995a1feb --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react" + } +} diff --git a/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts b/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts new file mode 100644 index 000000000..62ec7bd50 --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/typings/AssetCollection.ts @@ -0,0 +1,13 @@ +type AssetCollectionType = 'AssetCollection'; + +interface AssetCollection extends GraphQlEntity { + __typename: AssetCollectionType; + readonly id: string; + readonly title: string; + parent: { + readonly id: string; + readonly title: string; + } | null; + tags?: Tag[]; + assetCount: number; +} diff --git a/Resources/Private/JavaScript/asset-collections/typings/TreeNodeProps.ts b/Resources/Private/JavaScript/asset-collections/typings/TreeNodeProps.ts new file mode 100644 index 000000000..795a91ec8 --- /dev/null +++ b/Resources/Private/JavaScript/asset-collections/typings/TreeNodeProps.ts @@ -0,0 +1,6 @@ +interface TreeNodeProps { + title?: string; + label?: string; + level: number; + collapsedByDefault?: boolean; +} diff --git a/Resources/Private/JavaScript/asset-editing/src/components/EditAssetDialog.module.css b/Resources/Private/JavaScript/asset-editing/src/components/EditAssetDialog.module.css new file mode 100644 index 000000000..30e87c998 --- /dev/null +++ b/Resources/Private/JavaScript/asset-editing/src/components/EditAssetDialog.module.css @@ -0,0 +1,18 @@ +.editArea { + padding: var(--theme-spacing-Full); +} + +.filenameInput { + flex: 1 1 100%; +} + +.label { + display: flex !important; + flex-wrap: wrap; + align-items: baseline; + gap: var(--theme-spacing-Half) 0; +} + +.label + label { + margin-top: var(--theme-spacing-Full); +} diff --git a/Resources/Private/JavaScript/asset-editing/src/components/EditAssetDialog.tsx b/Resources/Private/JavaScript/asset-editing/src/components/EditAssetDialog.tsx index 42f0385c6..0aa110471 100644 --- a/Resources/Private/JavaScript/asset-editing/src/components/EditAssetDialog.tsx +++ b/Resources/Private/JavaScript/asset-editing/src/components/EditAssetDialog.tsx @@ -3,41 +3,24 @@ import { useRecoilState } from 'recoil'; import { Button, CheckBox, Label } from '@neos-project/react-ui-components'; -import { createUseMediaUiStyles, MediaUiTheme, useIntl, useMediaUi, useNotify } from '@media-ui/core/src'; +import { useIntl, useMediaUi, useNotify } from '@media-ui/core'; import { Dialog } from '@media-ui/core/src/components'; -import { useSelectedAsset } from '@media-ui/core/src/hooks'; +import { useAssetsQuery, useSelectedAsset } from '@media-ui/core/src/hooks'; import editAssetDialogState from '../state/editAssetDialogState'; import useEditAsset, { AssetEditOptions } from '../hooks/useEditAsset'; -const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - editArea: { - padding: theme.spacing.full, - }, - filenameInput: { - flex: '1 1 100%', - }, - label: { - display: 'flex', - flexWrap: 'wrap', - alignItems: 'baseline', - gap: `${theme.spacing.half} 0 `, - '& + label': { - marginTop: theme.spacing.full, - }, - }, -})); +import classes from './EditAssetDialog.module.css'; const EditAssetDialog: React.FC = () => { - const classes = useStyles(); const { translate } = useIntl(); const Notify = useNotify(); const [dialogVisible, setDialogVisible] = useRecoilState(editAssetDialogState); const { editAsset, loading } = useEditAsset(); const { - refetchAssets, approvalAttainmentStrategy: { obtainApprovalToEditAsset }, } = useMediaUi(); + const { refetch } = useAssetsQuery(); const inputRef = useRef(null); const selectedAsset = useSelectedAsset(); const [editOptions, setEditOptions] = React.useState({ @@ -59,7 +42,7 @@ const EditAssetDialog: React.FC = () => { Notify.ok(translate('EditAssetDialog.updateFinished', 'Update finished')); closeDialog(); - void refetchAssets(); + void refetch(); } catch (error) { Notify.error(translate('EditAssetDialog.updateError', 'Update failed'), error); } @@ -69,7 +52,7 @@ const EditAssetDialog: React.FC = () => { Notify, translate, editOptions, - refetchAssets, + refetch, selectedAsset, closeDialog, obtainApprovalToEditAsset, @@ -83,7 +66,7 @@ const EditAssetDialog: React.FC = () => { setDialogVisible} + onRequestClose={() => setDialogVisible(false)} actions={[ + ))} + + ); +}; + +export default React.memo(AssetSourceList); diff --git a/Resources/Private/JavaScript/core/src/fragments/assetSource.ts b/Resources/Private/JavaScript/asset-sources/src/fragments/assetSource.ts similarity index 100% rename from Resources/Private/JavaScript/core/src/fragments/assetSource.ts rename to Resources/Private/JavaScript/asset-sources/src/fragments/assetSource.ts diff --git a/Resources/Private/JavaScript/core/src/hooks/useAssetSourcesQuery.ts b/Resources/Private/JavaScript/asset-sources/src/hooks/useAssetSourcesQuery.ts similarity index 69% rename from Resources/Private/JavaScript/core/src/hooks/useAssetSourcesQuery.ts rename to Resources/Private/JavaScript/asset-sources/src/hooks/useAssetSourcesQuery.ts index 54de92f6e..d49cddd34 100644 --- a/Resources/Private/JavaScript/core/src/hooks/useAssetSourcesQuery.ts +++ b/Resources/Private/JavaScript/asset-sources/src/hooks/useAssetSourcesQuery.ts @@ -1,22 +1,23 @@ import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; import { useQuery } from '@apollo/client'; -import { ASSET_SOURCES } from '../queries'; -import { AssetSource } from '../interfaces'; -import { useMediaUi } from '../provider'; +import { constraintsState } from '@media-ui/core/src/state'; + +import { ASSET_SOURCES } from '../queries/assetSources'; interface AssetSourcesQueryResult { assetSources: AssetSource[]; } -export default function useAssetSourcesQuery() { - const { constraints } = useMediaUi(); +export function useAssetSourcesQuery() { const { data, loading } = useQuery(ASSET_SOURCES); + const constraints = useRecoilValue(constraintsState); // Filter out sources that don't match the constraints const assetSources = useMemo(() => { const assetSources = data?.assetSources || []; - return constraints.assetSources + return constraints.assetSources?.length > 0 ? assetSources.filter((source) => { return constraints.assetSources.includes(source.id); }) diff --git a/Resources/Private/JavaScript/asset-sources/src/hooks/useSelectedAssetSource.ts b/Resources/Private/JavaScript/asset-sources/src/hooks/useSelectedAssetSource.ts new file mode 100644 index 000000000..c7b6f0a95 --- /dev/null +++ b/Resources/Private/JavaScript/asset-sources/src/hooks/useSelectedAssetSource.ts @@ -0,0 +1,14 @@ +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { useAssetSourcesQuery } from './useAssetSourcesQuery'; +import { selectedAssetSourceState } from '../state/selectedAssetSourceState'; + +export const useSelectedAssetSource = (): AssetSource => { + const selectedAssetSourceId = useRecoilValue(selectedAssetSourceState); + const { assetSources } = useAssetSourcesQuery(); + return useMemo( + () => assetSources.find((assetSource) => assetSource.id === selectedAssetSourceId), + [assetSources, selectedAssetSourceId] + ); +}; diff --git a/Resources/Private/JavaScript/asset-sources/src/index.ts b/Resources/Private/JavaScript/asset-sources/src/index.ts new file mode 100644 index 000000000..447fb58e0 --- /dev/null +++ b/Resources/Private/JavaScript/asset-sources/src/index.ts @@ -0,0 +1,6 @@ +export { default as AssetSourceDescription } from './components/AssetSourceDescription'; +export { default as AssetSourceList } from './components/AssetSourceList'; +export { ASSET_SOURCE_FRAGMENT } from './fragments/assetSource'; +export { useAssetSourcesQuery } from './hooks/useAssetSourcesQuery'; +export { useSelectedAssetSource } from './hooks/useSelectedAssetSource'; +export { selectedAssetSourceState, NEOS_ASSET_SOURCE } from './state/selectedAssetSourceState'; diff --git a/Resources/Private/JavaScript/core/src/queries/assetSources.ts b/Resources/Private/JavaScript/asset-sources/src/queries/assetSources.ts similarity index 80% rename from Resources/Private/JavaScript/core/src/queries/assetSources.ts rename to Resources/Private/JavaScript/asset-sources/src/queries/assetSources.ts index ffbb39e82..df8c00d07 100644 --- a/Resources/Private/JavaScript/core/src/queries/assetSources.ts +++ b/Resources/Private/JavaScript/asset-sources/src/queries/assetSources.ts @@ -2,7 +2,7 @@ import { gql } from '@apollo/client'; import { ASSET_SOURCE_FRAGMENT } from '../fragments/assetSource'; -const ASSET_SOURCES = gql` +export const ASSET_SOURCES = gql` query ASSET_SOURCES { assetSources { ...AssetSourceProps @@ -10,5 +10,3 @@ const ASSET_SOURCES = gql` } ${ASSET_SOURCE_FRAGMENT} `; - -export default ASSET_SOURCES; diff --git a/Resources/Private/JavaScript/asset-sources/src/state/selectedAssetSourceState.ts b/Resources/Private/JavaScript/asset-sources/src/state/selectedAssetSourceState.ts new file mode 100644 index 000000000..9fc1f7e11 --- /dev/null +++ b/Resources/Private/JavaScript/asset-sources/src/state/selectedAssetSourceState.ts @@ -0,0 +1,33 @@ +import { atom, selector } from 'recoil'; + +import { constraintsState, currentPageState, localStorageEffect } from '@media-ui/core/src/state'; +import { clipboardVisibleState } from '@media-ui/feature-clipboard'; + +export const NEOS_ASSET_SOURCE = 'neos'; + +// TODO: Make sure that constraints are respected when restoring the state +const selectedAssetSourceIdState = atom({ + key: 'SelectedAssetSourceIdState', + default: NEOS_ASSET_SOURCE, + effects: [localStorageEffect('SelectedAssetSourceIdState')], +}); + +export const selectedAssetSourceState = selector({ + key: 'SelectedAssetSourceState', + get: ({ get }) => { + const selectedAssetSourceId = get(selectedAssetSourceIdState); + const constraints = get(constraintsState); + + if (constraints.assetSources?.length > 0 && !constraints.assetSources.includes(selectedAssetSourceId)) { + return constraints.assetSources[0]; + } + return selectedAssetSourceId; + }, + set: ({ set }, newValue) => { + set(selectedAssetSourceIdState, newValue); + // Reset the current page to 1 when switching asset sources + set(currentPageState, 1); + // Hide the clipboard when switching asset sources + set(clipboardVisibleState, false); + }, +}); diff --git a/Resources/Private/JavaScript/asset-sources/tsconfig.json b/Resources/Private/JavaScript/asset-sources/tsconfig.json new file mode 100644 index 000000000..9995a1feb --- /dev/null +++ b/Resources/Private/JavaScript/asset-sources/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react" + } +} diff --git a/Resources/Private/JavaScript/core/src/interfaces/AssetSource.ts b/Resources/Private/JavaScript/asset-sources/typings/AssetSource.ts similarity index 74% rename from Resources/Private/JavaScript/core/src/interfaces/AssetSource.ts rename to Resources/Private/JavaScript/asset-sources/typings/AssetSource.ts index 1a5782521..d0d75329c 100644 --- a/Resources/Private/JavaScript/core/src/interfaces/AssetSource.ts +++ b/Resources/Private/JavaScript/asset-sources/typings/AssetSource.ts @@ -1,8 +1,6 @@ -import GraphQlEntity from './GraphQLEntity'; - type AssetSourceType = 'AssetSource'; -export default interface AssetSource extends GraphQlEntity { +interface AssetSource extends GraphQlEntity { __typename: AssetSourceType; readonly id: string; readonly label: string; diff --git a/Resources/Private/JavaScript/asset-tags/package.json b/Resources/Private/JavaScript/asset-tags/package.json new file mode 100644 index 000000000..775935bf5 --- /dev/null +++ b/Resources/Private/JavaScript/asset-tags/package.json @@ -0,0 +1,10 @@ +{ + "name": "@media-ui/feature-asset-tags", + "version": "1.0.0", + "license": "GNU GPLv3", + "private": true, + "main": "src/index.ts", + "dependencies": { + "@media-ui/feature-asset-collections": "workspace:*" + } +} diff --git a/Resources/Private/JavaScript/asset-tags/src/components/CreateTagDialog.module.css b/Resources/Private/JavaScript/asset-tags/src/components/CreateTagDialog.module.css new file mode 100644 index 000000000..c0ce8646f --- /dev/null +++ b/Resources/Private/JavaScript/asset-tags/src/components/CreateTagDialog.module.css @@ -0,0 +1,3 @@ +.formBody { + padding: var(--theme-spacing-Full); +} diff --git a/Resources/Private/JavaScript/media-module/src/components/Dialogs/CreateTagDialog.tsx b/Resources/Private/JavaScript/asset-tags/src/components/CreateTagDialog.tsx similarity index 88% rename from Resources/Private/JavaScript/media-module/src/components/Dialogs/CreateTagDialog.tsx rename to Resources/Private/JavaScript/asset-tags/src/components/CreateTagDialog.tsx index 0f8ed6eb8..6fc01f3df 100644 --- a/Resources/Private/JavaScript/media-module/src/components/Dialogs/CreateTagDialog.tsx +++ b/Resources/Private/JavaScript/asset-tags/src/components/CreateTagDialog.tsx @@ -4,20 +4,16 @@ import { useRecoilState } from 'recoil'; import { Button, Label, TextInput } from '@neos-project/react-ui-components'; -import { useIntl, createUseMediaUiStyles, useNotify } from '@media-ui/core/src'; -import { useCreateTag, useSelectedAssetCollection } from '@media-ui/core/src/hooks'; +import { useIntl, useNotify } from '@media-ui/core'; +import { useSelectedAssetCollection } from '@media-ui/feature-asset-collections'; +import { useCreateTag } from '@media-ui/feature-asset-tags'; import { Dialog } from '@media-ui/core/src/components'; -import { createTagDialogState } from '../../state'; +import createTagDialogState from '../state/createTagDialogState'; -const useStyles = createUseMediaUiStyles(() => ({ - formBody: { - padding: 16, - }, -})); +import classes from './CreateTagDialog.module.css'; const CreateTagDialog: React.FC = () => { - const classes = useStyles(); const { translate } = useIntl(); const Notify = useNotify(); const selectedAssetCollection = useSelectedAssetCollection(); diff --git a/Resources/Private/JavaScript/core/src/fragments/tag.ts b/Resources/Private/JavaScript/asset-tags/src/fragments/tag.ts similarity index 100% rename from Resources/Private/JavaScript/core/src/fragments/tag.ts rename to Resources/Private/JavaScript/asset-tags/src/fragments/tag.ts diff --git a/Resources/Private/JavaScript/core/src/hooks/useCreateTag.ts b/Resources/Private/JavaScript/asset-tags/src/hooks/useCreateTag.ts similarity index 92% rename from Resources/Private/JavaScript/core/src/hooks/useCreateTag.ts rename to Resources/Private/JavaScript/asset-tags/src/hooks/useCreateTag.ts index 8bf21ef04..08593664a 100644 --- a/Resources/Private/JavaScript/core/src/hooks/useCreateTag.ts +++ b/Resources/Private/JavaScript/asset-tags/src/hooks/useCreateTag.ts @@ -1,8 +1,9 @@ import { useMutation } from '@apollo/client'; -import { AssetCollection, Tag } from '../interfaces'; -import { ASSET_COLLECTIONS, TAGS } from '../queries'; -import { CREATE_TAG } from '../mutations'; +import { ASSET_COLLECTIONS } from '@media-ui/feature-asset-collections'; + +import TAGS from '../queries/tags'; +import CREATE_TAG from '../mutations/createTag'; interface CreateTagVariables { label: string; diff --git a/Resources/Private/JavaScript/core/src/hooks/useDeleteTag.ts b/Resources/Private/JavaScript/asset-tags/src/hooks/useDeleteTag.ts similarity index 89% rename from Resources/Private/JavaScript/core/src/hooks/useDeleteTag.ts rename to Resources/Private/JavaScript/asset-tags/src/hooks/useDeleteTag.ts index e4bfc5d04..f3a090aba 100644 --- a/Resources/Private/JavaScript/core/src/hooks/useDeleteTag.ts +++ b/Resources/Private/JavaScript/asset-tags/src/hooks/useDeleteTag.ts @@ -1,10 +1,11 @@ import { useMutation } from '@apollo/client'; import { useRecoilState } from 'recoil'; -import { selectedTagIdState } from '../state'; -import { AssetCollection, Tag } from '../interfaces'; -import { ASSET_COLLECTIONS, TAGS } from '../queries'; -import { DELETE_TAG } from '../mutations'; +import { ASSET_COLLECTIONS } from '@media-ui/feature-asset-collections'; + +import selectedTagIdState from '../state/selectedTagIdState'; +import TAGS from '../queries/tags'; +import DELETE_TAG from '../mutations/deleteTag'; interface DeleteTagVariables { id: string; diff --git a/Resources/Private/JavaScript/core/src/hooks/useSelectedTag.ts b/Resources/Private/JavaScript/asset-tags/src/hooks/useSelectedTag.ts similarity index 78% rename from Resources/Private/JavaScript/core/src/hooks/useSelectedTag.ts rename to Resources/Private/JavaScript/asset-tags/src/hooks/useSelectedTag.ts index 250ba2a14..7d0127837 100644 --- a/Resources/Private/JavaScript/core/src/hooks/useSelectedTag.ts +++ b/Resources/Private/JavaScript/asset-tags/src/hooks/useSelectedTag.ts @@ -1,9 +1,8 @@ import { useRecoilValue } from 'recoil'; import { useQuery } from '@apollo/client'; -import { Tag } from '../interfaces'; -import { selectedTagIdState } from '../state'; -import { TAG } from '../queries'; +import selectedTagIdState from '../state/selectedTagIdState'; +import TAG from '../queries/tag'; interface TagQueryResult { tag: Tag; diff --git a/Resources/Private/JavaScript/core/src/hooks/useTagsQuery.ts b/Resources/Private/JavaScript/asset-tags/src/hooks/useTagsQuery.ts similarity index 77% rename from Resources/Private/JavaScript/core/src/hooks/useTagsQuery.ts rename to Resources/Private/JavaScript/asset-tags/src/hooks/useTagsQuery.ts index bcece553b..6551e83df 100644 --- a/Resources/Private/JavaScript/core/src/hooks/useTagsQuery.ts +++ b/Resources/Private/JavaScript/asset-tags/src/hooks/useTagsQuery.ts @@ -1,7 +1,6 @@ import { useQuery } from '@apollo/client'; -import { TAGS } from '../queries'; -import { Tag } from '../interfaces'; +import TAGS from '../queries/tags'; interface TagsQueryResult { tags: Tag[]; diff --git a/Resources/Private/JavaScript/core/src/hooks/useUpdateTag.ts b/Resources/Private/JavaScript/asset-tags/src/hooks/useUpdateTag.ts similarity index 91% rename from Resources/Private/JavaScript/core/src/hooks/useUpdateTag.ts rename to Resources/Private/JavaScript/asset-tags/src/hooks/useUpdateTag.ts index 8977dc474..92d7f5bee 100644 --- a/Resources/Private/JavaScript/core/src/hooks/useUpdateTag.ts +++ b/Resources/Private/JavaScript/asset-tags/src/hooks/useUpdateTag.ts @@ -1,7 +1,6 @@ import { useMutation } from '@apollo/client'; -import { UPDATE_TAG } from '../mutations'; -import { Tag } from '../interfaces'; +import UPDATE_TAG from '../mutations/updateTag'; interface UpdateTagProps { tag: Tag; diff --git a/Resources/Private/JavaScript/asset-tags/src/index.ts b/Resources/Private/JavaScript/asset-tags/src/index.ts new file mode 100644 index 000000000..c87d4be0c --- /dev/null +++ b/Resources/Private/JavaScript/asset-tags/src/index.ts @@ -0,0 +1,10 @@ +export { default as useCreateTag } from './hooks/useCreateTag'; +export { default as useDeleteTag } from './hooks/useDeleteTag'; +export { default as useSelectedTag } from './hooks/useSelectedTag'; +export { default as useTagsQuery } from './hooks/useTagsQuery'; +export { default as useUpdateTag } from './hooks/useUpdateTag'; + +export { default as CreateTagDialog } from './components/CreateTagDialog'; + +export { default as selectedTagIdState } from './state/selectedTagIdState'; +export { default as createTagDialogState } from './state/createTagDialogState'; diff --git a/Resources/Private/JavaScript/core/src/mutations/createTag.ts b/Resources/Private/JavaScript/asset-tags/src/mutations/createTag.ts similarity index 100% rename from Resources/Private/JavaScript/core/src/mutations/createTag.ts rename to Resources/Private/JavaScript/asset-tags/src/mutations/createTag.ts diff --git a/Resources/Private/JavaScript/core/src/mutations/deleteTag.ts b/Resources/Private/JavaScript/asset-tags/src/mutations/deleteTag.ts similarity index 100% rename from Resources/Private/JavaScript/core/src/mutations/deleteTag.ts rename to Resources/Private/JavaScript/asset-tags/src/mutations/deleteTag.ts diff --git a/Resources/Private/JavaScript/core/src/mutations/updateTag.ts b/Resources/Private/JavaScript/asset-tags/src/mutations/updateTag.ts similarity index 100% rename from Resources/Private/JavaScript/core/src/mutations/updateTag.ts rename to Resources/Private/JavaScript/asset-tags/src/mutations/updateTag.ts diff --git a/Resources/Private/JavaScript/core/src/queries/tag.ts b/Resources/Private/JavaScript/asset-tags/src/queries/tag.ts similarity index 100% rename from Resources/Private/JavaScript/core/src/queries/tag.ts rename to Resources/Private/JavaScript/asset-tags/src/queries/tag.ts diff --git a/Resources/Private/JavaScript/core/src/queries/tags.ts b/Resources/Private/JavaScript/asset-tags/src/queries/tags.ts similarity index 100% rename from Resources/Private/JavaScript/core/src/queries/tags.ts rename to Resources/Private/JavaScript/asset-tags/src/queries/tags.ts diff --git a/Resources/Private/JavaScript/media-module/src/state/createTagDialogState.ts b/Resources/Private/JavaScript/asset-tags/src/state/createTagDialogState.ts similarity index 100% rename from Resources/Private/JavaScript/media-module/src/state/createTagDialogState.ts rename to Resources/Private/JavaScript/asset-tags/src/state/createTagDialogState.ts diff --git a/Resources/Private/JavaScript/asset-tags/src/state/selectedTagIdState.ts b/Resources/Private/JavaScript/asset-tags/src/state/selectedTagIdState.ts new file mode 100644 index 000000000..39b8ddbbd --- /dev/null +++ b/Resources/Private/JavaScript/asset-tags/src/state/selectedTagIdState.ts @@ -0,0 +1,10 @@ +import { atom } from 'recoil'; +import { localStorageEffect } from '@media-ui/core/src/state'; + +const selectedTagIdState = atom({ + key: 'SelectedTagIdState', + default: null, + effects: [localStorageEffect('SelectedTagIdState')], +}); + +export default selectedTagIdState; diff --git a/Resources/Private/JavaScript/asset-tags/tsconfig.json b/Resources/Private/JavaScript/asset-tags/tsconfig.json new file mode 100644 index 000000000..9995a1feb --- /dev/null +++ b/Resources/Private/JavaScript/asset-tags/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react" + } +} diff --git a/Resources/Private/JavaScript/asset-tags/typings/Tag.ts b/Resources/Private/JavaScript/asset-tags/typings/Tag.ts new file mode 100644 index 000000000..3a7d1f862 --- /dev/null +++ b/Resources/Private/JavaScript/asset-tags/typings/Tag.ts @@ -0,0 +1,7 @@ +type TagType = 'Tag'; + +interface Tag extends GraphQlEntity { + __typename: TagType; + id: string; + label: string; +} diff --git a/Resources/Private/JavaScript/asset-upload/src/components/AssetReplacementButton.tsx b/Resources/Private/JavaScript/asset-upload/src/components/AssetReplacementButton.tsx index 6d2f281db..1cc377d21 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/AssetReplacementButton.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/AssetReplacementButton.tsx @@ -3,13 +3,12 @@ import { useRecoilState } from 'recoil'; import { Button, Icon } from '@neos-project/react-ui-components'; -import { useIntl } from '@media-ui/core/src'; +import { useIntl } from '@media-ui/core'; -import { uploadDialogVisibleState } from '../state'; -import { UPLOAD_TYPE } from '../state/uploadDialogVisibleState'; +import { UPLOAD_TYPE, uploadDialogState } from '../state/uploadDialogState'; const AssetReplacementButton: React.FC = () => { - const [dialogState, setDialogState] = useRecoilState(uploadDialogVisibleState); + const [dialogState, setDialogState] = useRecoilState(uploadDialogState); const { translate } = useIntl(); return ( diff --git a/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/NewAssetDialog.module.css b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/NewAssetDialog.module.css new file mode 100644 index 000000000..5e5e5d4d6 --- /dev/null +++ b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/NewAssetDialog.module.css @@ -0,0 +1,3 @@ +.uploadArea { + padding: var(--theme-spacing-Full); +} 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 7bd8b2e19..aa19cd192 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/NewAssetDialog.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/NewAssetDialog.tsx @@ -1,32 +1,25 @@ -import * as React from 'react'; -import { useCallback } from 'react'; +import React, { useCallback } from 'react'; import { Button } from '@neos-project/react-ui-components'; -import { createUseMediaUiStyles, MediaUiTheme, useIntl, useMediaUi, useNotify } from '@media-ui/core/src'; +import { useIntl, useNotify } from '@media-ui/core'; import { Dialog } from '@media-ui/core/src/components'; import UploadSection from '../UploadSection'; import PreviewSection from '../PreviewSection'; import { useUploadDialogState, useUploadFiles } from '../../hooks'; -import { FilesUploadState, UploadedFile } from '../../interfaces'; +import { useAssetsQuery } from '@media-ui/core/src/hooks'; -const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - uploadArea: { - padding: theme.spacing.full, - }, -})); +import classes from './NewAssetDialog.module.css'; const NewAssetDialog: React.FC = () => { const { translate } = useIntl(); const Notify = useNotify(); const { uploadFiles, uploadState, loading } = useUploadFiles(); const { state: dialogState, closeDialog, setFiles } = useUploadDialogState(); - const { refetchAssets } = useMediaUi(); + const { refetch } = useAssetsQuery(); const uploadPossible = !loading && dialogState.files.selected.length > 0; - const classes = useStyles(); - const handleUpload = useCallback(() => { uploadFiles(dialogState.files.selected) .then(({ data: { uploadFiles } }) => { @@ -59,13 +52,13 @@ const NewAssetDialog: React.FC = () => { // Refresh list of files if any file was uploaded if (uploadFiles.some((result) => result.success)) { - void refetchAssets(); + void refetch(); } }) .catch((error) => { Notify.error(translate('fileUpload.error', 'Upload failed'), error); }); - }, [uploadFiles, dialogState.files.selected, setFiles, Notify, translate, refetchAssets]); + }, [uploadFiles, dialogState.files.selected, setFiles, Notify, translate, refetch]); const handleSetFiles = useCallback( (files: UploadedFile[]) => { diff --git a/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/ReplaceAssetDialog.module.css b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/ReplaceAssetDialog.module.css new file mode 100644 index 000000000..f573ea1a7 --- /dev/null +++ b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/ReplaceAssetDialog.module.css @@ -0,0 +1,17 @@ +.uploadArea { + padding: var(--theme-spacing-Full); +} + +.optionSection { + margin-top: var(--theme-spacing-Full); + margin-bottom: var(--theme-spacing-Full); +} + +.option { + margin-top: var(--theme-spacing-Half); + margin-bottom: var(--theme-spacing-Half); +} + +.label { + display: flex; +} 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 3c89981b0..e6a9e89fc 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/ReplaceAssetDialog.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/ReplaceAssetDialog.tsx @@ -1,58 +1,42 @@ -import * as React from 'react'; -import { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; import { Button, CheckBox, Label } from '@neos-project/react-ui-components'; -import { createUseMediaUiStyles, MediaUiTheme, useIntl, useMediaUi, useNotify } from '@media-ui/core/src'; -import { useSelectedAsset } from '@media-ui/core/src/hooks'; +import { useIntl, useMediaUi, useNotify } from '@media-ui/core'; +import { useAssetsQuery, useSelectedAsset } from '@media-ui/core/src/hooks'; import { Dialog } from '@media-ui/core/src/components'; +import { featureFlagsState } from '@media-ui/core/src/state'; import UploadSection from '../UploadSection'; import PreviewSection from '../PreviewSection'; import { useUploadDialogState } from '../../hooks'; import useReplaceAsset, { AssetReplacementOptions } from '../../hooks/useReplaceAsset'; -import { UploadedFile } from '../../interfaces'; -const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - uploadArea: { - padding: theme.spacing.full, - }, - optionSection: { - marginTop: theme.spacing.full, - marginBottom: theme.spacing.full, - }, - option: { - marginTop: theme.spacing.half, - marginBottom: theme.spacing.half, - }, - label: { - display: 'flex', - }, -})); +import classes from './ReplaceAssetDialog.module.css'; const ReplaceAssetDialog: React.FC = () => { const { translate } = useIntl(); const Notify = useNotify(); const selectedAsset = useSelectedAsset(); const { replaceAsset, uploadState, loading } = useReplaceAsset(); + const { refetch } = useAssetsQuery(); const { - refetchAssets, - featureFlags, approvalAttainmentStrategy: { obtainApprovalToReplaceAsset }, } = useMediaUi(); + 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 classes = useStyles(); const acceptedFileTypes = useMemo(() => { // TODO: Extract this into a helper function const completeMediaType = selectedAsset?.file.mediaType; const regex = /^(?(?:[.!#%&'`^~$*+\-|\w]+))\//; const mainType = completeMediaType.match(regex)?.groups?.type; - return mainType ? mainType + '/*' : ''; + return mainType ? (`${mainType}/*` as MediaType) : ''; }, [selectedAsset]); const handleUpload = useCallback(async () => { @@ -70,7 +54,7 @@ const ReplaceAssetDialog: React.FC = () => { Notify.ok(translate('uploadDialog.replacementFinished', 'Replacement finished')); closeDialog(); - void refetchAssets(); + void refetch(); } catch (error) { Notify.error(translate('assetReplacement.error', 'Replacement failed'), error); } @@ -81,7 +65,7 @@ const ReplaceAssetDialog: React.FC = () => { translate, dialogState, replacementOptions, - refetchAssets, + refetch, selectedAsset, closeDialog, obtainApprovalToReplaceAsset, diff --git a/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/UploadDialog.tsx b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/UploadDialog.tsx index 7b8988248..b735d4332 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/UploadDialog.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/Dialogs/UploadDialog.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; import { useRecoilValue } from 'recoil'; -import { uploadDialogVisibleState } from '../../state'; -import { UPLOAD_TYPE } from '../../state/uploadDialogVisibleState'; + import NewAssetDialog from './NewAssetDialog'; import ReplaceAssetDialog from './ReplaceAssetDialog'; +import { UPLOAD_TYPE, uploadDialogState } from '../../state/uploadDialogState'; const UploadDialog: React.FC = () => { - const { visible, uploadType } = useRecoilValue(uploadDialogVisibleState); + const { visible, uploadType } = useRecoilValue(uploadDialogState); return (visible && (uploadType === UPLOAD_TYPE.update ? : )) || null; }; diff --git a/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.module.css b/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.module.css new file mode 100644 index 000000000..7b27b4bb0 --- /dev/null +++ b/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.module.css @@ -0,0 +1,92 @@ +.fileList { + margin-top: var(--theme-spacing-Full); + 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); +} + +.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); + box-sizing: border-box; +} + +.thumbInner { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.thumbInner span { + margin-left: var(--theme-spacing-Half); + user-select: none; +} + +.img { + position: absolute; + display: block; + width: 100%; + height: 100%; + object-fit: cover; + left: 0; + top: 0; + z-index: -1; +} + +.thumbInner:after { + display: none; + position: absolute; + content: ""; + left: 0; + top: 0; + right: 0; + bottom: 0; + background-color: var(--theme-colors-alternatingBackground); + opacity: 0.3; + z-index: -1; +} + +.loading { + border-color: var(--theme-colors-border); +} + +.loading .thumbInner:after { + display: block; +} + +.success { + border-color: var(--theme-colors-Success); +} + +.success .thumbInner:after { + display: block; + background-color: var(--theme-colors-Success); +} + +.error { + border-color: var(--theme-colors-Error); +} + +.error .thumbInner:after { + display: block; + background-color: var(--theme-colors-Error); +} + +.warning { + color: var(--theme-colors-Warn); +} diff --git a/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.tsx b/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.tsx index 5d924fcca..7533aea7e 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/FilePreview.tsx @@ -1,91 +1,9 @@ -import * as React from 'react'; +import React from 'react'; +import cx from 'classnames'; import { Icon } from '@neos-project/react-ui-components'; -import { createUseMediaUiStyles, MediaUiTheme } from '@media-ui/core/src'; - -import { FileUploadResult, UploadedFile } from '../interfaces'; - -const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - fileList: { - marginTop: theme.spacing.full, - display: 'flex', - flexDirection: 'row', - flexWrap: 'wrap', - }, - fileListHeader: { - flex: '1 1 100%', - marginBottom: theme.spacing.full, - fontSize: theme.fontSize, - }, - thumb: { - display: 'inline-flex', - borderRadius: 2, - border: '1px solid #eaeaea', - marginBottom: theme.spacing.half, - marginRight: theme.spacing.half, - width: 100, - height: 100, - padding: theme.spacing.quarter, - boxSizing: 'border-box', - }, - thumbInner: { - position: 'relative', - width: '100%', - height: '100%', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - '& span': { - marginLeft: theme.spacing.half, - userSelect: 'none', - }, - }, - img: { - position: 'absolute', - display: 'block', - width: '100%', - height: '100%', - objectFit: 'cover', - left: 0, - top: 0, - zIndex: -1, - }, - stateOverlay: { - position: 'absolute', - content: '""', - left: 0, - top: 0, - right: 0, - bottom: 0, - backgroundColor: theme.colors.alternatingBackground, - opacity: 0.3, - zIndex: -1, - }, - loading: { - borderColor: theme.colors.border, - '& $thumbInner:after': { - extend: 'stateOverlay', - }, - }, - success: { - borderColor: theme.colors.success, - '& $thumbInner:after': { - extend: 'stateOverlay', - backgroundColor: theme.colors.success, - }, - }, - error: { - borderColor: theme.colors.error, - '& $thumbInner:after': { - extend: 'stateOverlay', - backgroundColor: theme.colors.error, - }, - }, - warning: { - color: theme.colors.warn, - }, -})); +import classes from './FilePreview.module.css'; interface FilePreviewProps { file: UploadedFile; @@ -94,18 +12,18 @@ interface FilePreviewProps { } const FilePreview: React.FC = ({ file, loading = false, fileState }: FilePreviewProps) => { - const classes = useStyles(); const success = fileState?.success; const error = fileState && !success; - let stateClassName; - - if (error) stateClassName = classes.error; - else if (success) stateClassName = classes.success; - else if (loading) stateClassName = classes.loading; // TODO: Output helpful localised messages for results 'EXISTS', 'ADDED', 'ERROR' return ( -
+
{file.name} {loading && } diff --git a/Resources/Private/JavaScript/asset-upload/src/components/PreviewSection.module.css b/Resources/Private/JavaScript/asset-upload/src/components/PreviewSection.module.css new file mode 100644 index 000000000..b5ffea535 --- /dev/null +++ b/Resources/Private/JavaScript/asset-upload/src/components/PreviewSection.module.css @@ -0,0 +1,12 @@ +.fileList { + margin-top: var(--theme-spacing-Full); + 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); +} diff --git a/Resources/Private/JavaScript/asset-upload/src/components/PreviewSection.tsx b/Resources/Private/JavaScript/asset-upload/src/components/PreviewSection.tsx index 3296a72cc..74701590c 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/PreviewSection.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/PreviewSection.tsx @@ -1,23 +1,10 @@ -import * as React from 'react'; +import React from 'react'; -import { useIntl, createUseMediaUiStyles, MediaUiTheme } from '@media-ui/core/src'; +import { useIntl } from '@media-ui/core'; -import { FilesUploadState, FileUploadResult } from '../interfaces'; import FilePreview from './FilePreview'; -const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - fileList: { - marginTop: theme.spacing.full, - display: 'flex', - flexDirection: 'row', - flexWrap: 'wrap', - }, - fileListHeader: { - flex: '1 1 100%', - marginBottom: theme.spacing.full, - fontSize: theme.fontSize, - }, -})); +import classes from './PreviewSection.module.css'; interface PreviewSectionProps { files: FilesUploadState; @@ -27,7 +14,6 @@ interface PreviewSectionProps { const PreviewSection: React.FC = ({ files, loading, uploadState }: PreviewSectionProps) => { const { translate } = useIntl(); - const classes = useStyles(); // FIXME: Mapping the uploadState to the files name is not the best solution as the same filename might be used multiple times diff --git a/Resources/Private/JavaScript/asset-upload/src/components/UploadButton.tsx b/Resources/Private/JavaScript/asset-upload/src/components/UploadButton.tsx index 5e20bcd4d..75a2360e4 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/UploadButton.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/UploadButton.tsx @@ -3,14 +3,13 @@ import { useSetRecoilState } from 'recoil'; import { Button, Icon } from '@neos-project/react-ui-components'; -import { useIntl } from '@media-ui/core/src'; +import { useIntl } from '@media-ui/core'; -import { uploadDialogVisibleState } from '../state'; -import { UPLOAD_TYPE } from '../state/uploadDialogVisibleState'; +import { UPLOAD_TYPE, uploadDialogState } from '../state/uploadDialogState'; export default function UploadButton() { const { translate } = useIntl(); - const setUploadDialogState = useSetRecoilState(uploadDialogVisibleState); + const setUploadDialogState = useSetRecoilState(uploadDialogState); return (
diff --git a/Resources/Private/JavaScript/asset-upload/src/components/UploadSection.module.css b/Resources/Private/JavaScript/asset-upload/src/components/UploadSection.module.css new file mode 100644 index 000000000..1dbf7604d --- /dev/null +++ b/Resources/Private/JavaScript/asset-upload/src/components/UploadSection.module.css @@ -0,0 +1,33 @@ +.dropzone { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--theme-spacing-GoldenUnit); + border-width: 2px; + border-radius: 2px; + border-color: var(--theme-colors-disabled); + border-style: dashed; + background-color: var(--theme-colors-alternatingBackground); + color: var(--theme-colors-text); + outline: none; + cursor: pointer; + user-select: none; + transition: border .24s ease-in-out; +} + +.dropzone p { + margin: 0; + line-height: 1.6; +} + +.dropzone--active { + border-color: var(--theme-colors-PrimaryBlue); +} + +.dropzone--accept { + border-color: var(--theme-colors-Success); +} + +.dropzone--reject { + border-color: var(--theme-colors-Error); +} diff --git a/Resources/Private/JavaScript/asset-upload/src/components/UploadSection.tsx b/Resources/Private/JavaScript/asset-upload/src/components/UploadSection.tsx index 2239eb5a3..bf884e789 100644 --- a/Resources/Private/JavaScript/asset-upload/src/components/UploadSection.tsx +++ b/Resources/Private/JavaScript/asset-upload/src/components/UploadSection.tsx @@ -1,45 +1,19 @@ -import * as React from 'react'; +import React from 'react'; import { useDropzone } from 'react-dropzone'; +import cx from 'classnames'; -import { useIntl, createUseMediaUiStyles, MediaUiTheme, useMediaUi, useNotify } from '@media-ui/core/src'; +import { useIntl, useMediaUi, useNotify } from '@media-ui/core'; import { useConfigQuery } from '@media-ui/core/src/hooks'; import { humanFileSize } from '@media-ui/core/src/helper'; -import { UploadedFile } from '../interfaces'; -const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - dropzone: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - padding: theme.spacing.goldenUnit, - borderWidth: 2, - borderRadius: 2, - borderColor: ({ isDragAccept, isDragActive, isDragReject }) => { - if (isDragAccept) return theme.colors.success; - if (isDragReject) return theme.colors.error; - if (isDragActive) return theme.colors.primary; - return theme.colors.disabled; - }, - borderStyle: 'dashed', - backgroundColor: theme.colors.alternatingBackground, - color: theme.colors.text, - outline: 'none', - cursor: 'pointer', - userSelect: 'none', - transition: 'border .24s ease-in-out', - '& p': { - margin: 0, - lineHeight: 1.6, - }, - }, -})); +import classes from './UploadSection.module.css'; interface UploadSectionProps { files: UploadedFile[]; loading: boolean; onSetFiles: (files: UploadedFile[]) => void; maxFiles?: number; - acceptedFileTypes?: string | string[]; + acceptedFileTypes?: MediaType | MediaType[] | ''; } const UploadSection: React.FC = ({ @@ -110,11 +84,19 @@ const UploadSection: React.FC = ({ preventDropOnDocument: true, accept: acceptedFileTypes, }); - const classes = useStyles({ isDragAccept, isDragActive, isDragReject }); return (
-
+

{translate( diff --git a/Resources/Private/JavaScript/asset-upload/src/hooks/useReplaceAsset.ts b/Resources/Private/JavaScript/asset-upload/src/hooks/useReplaceAsset.ts index 10819c3dd..f0e80bdf3 100644 --- a/Resources/Private/JavaScript/asset-upload/src/hooks/useReplaceAsset.ts +++ b/Resources/Private/JavaScript/asset-upload/src/hooks/useReplaceAsset.ts @@ -1,9 +1,6 @@ import { useMutation } from '@apollo/client'; -import { Asset } from '@media-ui/core/src/interfaces'; - import { REPLACE_ASSET } from '../mutations'; -import { FileUploadResult } from '../interfaces'; export interface AssetReplacementOptions { generateRedirects: boolean; diff --git a/Resources/Private/JavaScript/asset-upload/src/hooks/useUploadDialogState.ts b/Resources/Private/JavaScript/asset-upload/src/hooks/useUploadDialogState.ts index 1b53153b4..b48a3f544 100644 --- a/Resources/Private/JavaScript/asset-upload/src/hooks/useUploadDialogState.ts +++ b/Resources/Private/JavaScript/asset-upload/src/hooks/useUploadDialogState.ts @@ -1,16 +1,16 @@ import { Dispatch, SetStateAction, useCallback, useState } from 'react'; import { useRecoilState } from 'recoil'; -import { uploadDialogVisibleState } from '../state'; -import { UploadDialogVisibleState, UPLOAD_TYPE } from '../state/uploadDialogVisibleState'; -import { FilesUploadState } from '../interfaces'; +import { uploadDialogState } from '../state'; +import type { UploadDialogState } from '../state/uploadDialogState'; +import { UPLOAD_TYPE } from '../state/uploadDialogState'; -interface UploadDialogState extends UploadDialogVisibleState { +interface UploadDialogStateWithFiles extends UploadDialogState { files: FilesUploadState; } const useUploadDialogState = (): { - state: UploadDialogState; + state: UploadDialogStateWithFiles; closeDialog(): void; setFiles: Dispatch>; } => { @@ -20,7 +20,7 @@ const useUploadDialogState = (): { finished: [], rejected: [], }); - const [dialogState, setDialogState] = useRecoilState(uploadDialogVisibleState); + const [dialogState, setDialogState] = useRecoilState(uploadDialogState); const handleClose = useCallback(() => { // Make sure to revoke the data uris to avoid memory leaks diff --git a/Resources/Private/JavaScript/asset-upload/src/hooks/useUploadFile.ts b/Resources/Private/JavaScript/asset-upload/src/hooks/useUploadFile.ts index 26859f632..1f6b7504c 100644 --- a/Resources/Private/JavaScript/asset-upload/src/hooks/useUploadFile.ts +++ b/Resources/Private/JavaScript/asset-upload/src/hooks/useUploadFile.ts @@ -1,10 +1,10 @@ import { useMutation } from '@apollo/client'; import { useRecoilValue } from 'recoil'; -import { selectedAssetCollectionIdState, selectedTagIdState } from '@media-ui/core/src/state'; +import { selectedTagIdState } from '@media-ui/feature-asset-tags'; +import { selectedAssetCollectionIdState } from '@media-ui/feature-asset-collections'; import { UPLOAD_FILE } from '../mutations'; -import { FileUploadResult } from '../interfaces'; export default function useUploadFile() { const [action, { error, data, loading }] = useMutation<{ uploadFile: FileUploadResult }>(UPLOAD_FILE); diff --git a/Resources/Private/JavaScript/asset-upload/src/hooks/useUploadFiles.ts b/Resources/Private/JavaScript/asset-upload/src/hooks/useUploadFiles.ts index 21a7a8de7..e065bf732 100644 --- a/Resources/Private/JavaScript/asset-upload/src/hooks/useUploadFiles.ts +++ b/Resources/Private/JavaScript/asset-upload/src/hooks/useUploadFiles.ts @@ -1,10 +1,10 @@ import { useMutation } from '@apollo/client'; import { useRecoilValue } from 'recoil'; -import { selectedAssetCollectionIdState, selectedTagIdState } from '@media-ui/core/src/state'; +import { selectedTagIdState } from '@media-ui/feature-asset-tags'; +import { selectedAssetCollectionIdState } from '@media-ui/feature-asset-collections'; import { UPLOAD_FILES } from '../mutations'; -import { FileUploadResult } from '../interfaces'; export default function useUploadFiles() { const [action, { error, data, loading }] = useMutation<{ uploadFiles: FileUploadResult[] }>(UPLOAD_FILES); diff --git a/Resources/Private/JavaScript/asset-upload/src/interfaces/index.ts b/Resources/Private/JavaScript/asset-upload/src/interfaces/index.ts deleted file mode 100644 index b227dd51a..000000000 --- a/Resources/Private/JavaScript/asset-upload/src/interfaces/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import FileUploadResult from './FileUploadResult'; -import FilesUploadState from './FilesUploadState'; -import UploadedFile from './UploadedFile'; - -export { FileUploadResult, FilesUploadState, UploadedFile }; diff --git a/Resources/Private/JavaScript/asset-upload/src/state/index.ts b/Resources/Private/JavaScript/asset-upload/src/state/index.ts index 50567ea5c..cfd9067ed 100644 --- a/Resources/Private/JavaScript/asset-upload/src/state/index.ts +++ b/Resources/Private/JavaScript/asset-upload/src/state/index.ts @@ -1,3 +1 @@ -import uploadDialogVisibleState from './uploadDialogVisibleState'; - -export { uploadDialogVisibleState }; +export { uploadDialogState } from './uploadDialogState'; diff --git a/Resources/Private/JavaScript/asset-upload/src/state/uploadDialogVisibleState.ts b/Resources/Private/JavaScript/asset-upload/src/state/uploadDialogState.ts similarity index 64% rename from Resources/Private/JavaScript/asset-upload/src/state/uploadDialogVisibleState.ts rename to Resources/Private/JavaScript/asset-upload/src/state/uploadDialogState.ts index 5a9133817..93a554dba 100644 --- a/Resources/Private/JavaScript/asset-upload/src/state/uploadDialogVisibleState.ts +++ b/Resources/Private/JavaScript/asset-upload/src/state/uploadDialogState.ts @@ -5,16 +5,15 @@ export enum UPLOAD_TYPE { update = 'update', } -export interface UploadDialogVisibleState { +export interface UploadDialogState { visible: boolean; uploadType: UPLOAD_TYPE; } -const uploadDialogVisibleState = atom({ + +export const uploadDialogState = atom({ key: 'uploadDialogState', default: { visible: false, uploadType: UPLOAD_TYPE.new, }, }); - -export default uploadDialogVisibleState; diff --git a/Resources/Private/JavaScript/asset-upload/src/interfaces/FileUploadResult.ts b/Resources/Private/JavaScript/asset-upload/typings/FileUploadResult.ts similarity index 60% rename from Resources/Private/JavaScript/asset-upload/src/interfaces/FileUploadResult.ts rename to Resources/Private/JavaScript/asset-upload/typings/FileUploadResult.ts index ff78d08c4..b9184a383 100644 --- a/Resources/Private/JavaScript/asset-upload/src/interfaces/FileUploadResult.ts +++ b/Resources/Private/JavaScript/asset-upload/typings/FileUploadResult.ts @@ -1,4 +1,4 @@ -export default interface FileUploadResult { +interface FileUploadResult { filename: string; success: boolean; result: string; diff --git a/Resources/Private/JavaScript/asset-upload/src/interfaces/FilesUploadState.ts b/Resources/Private/JavaScript/asset-upload/typings/FilesUploadState.ts similarity index 51% rename from Resources/Private/JavaScript/asset-upload/src/interfaces/FilesUploadState.ts rename to Resources/Private/JavaScript/asset-upload/typings/FilesUploadState.ts index d81e76dc3..063a108c1 100644 --- a/Resources/Private/JavaScript/asset-upload/src/interfaces/FilesUploadState.ts +++ b/Resources/Private/JavaScript/asset-upload/typings/FilesUploadState.ts @@ -1,6 +1,4 @@ -import UploadedFile from './UploadedFile'; - -export default interface FilesUploadState { +interface FilesUploadState { selected: UploadedFile[]; finished: UploadedFile[]; rejected: UploadedFile[]; diff --git a/Resources/Private/JavaScript/asset-upload/src/interfaces/UploadedFile.ts b/Resources/Private/JavaScript/asset-upload/typings/UploadedFile.ts similarity index 76% rename from Resources/Private/JavaScript/asset-upload/src/interfaces/UploadedFile.ts rename to Resources/Private/JavaScript/asset-upload/typings/UploadedFile.ts index f108a42e6..8b8c3c5e7 100644 --- a/Resources/Private/JavaScript/asset-upload/src/interfaces/UploadedFile.ts +++ b/Resources/Private/JavaScript/asset-upload/typings/UploadedFile.ts @@ -1,4 +1,4 @@ -export default interface UploadedFile extends File { +interface UploadedFile extends File { id?: string; path?: string; preview?: string; diff --git a/Resources/Private/JavaScript/asset-usage/schema.graphql b/Resources/Private/JavaScript/asset-usage/schema.graphql deleted file mode 100644 index e7c8272c9..000000000 --- a/Resources/Private/JavaScript/asset-usage/schema.graphql +++ /dev/null @@ -1,7 +0,0 @@ -extend type Query { - includeUsage: Boolean! -} - -extend type Mutation { - includeUsage: Boolean! -} diff --git a/Resources/Private/JavaScript/asset-usage/src/components/AssetUsageSection.module.css b/Resources/Private/JavaScript/asset-usage/src/components/AssetUsageSection.module.css new file mode 100644 index 000000000..2ac309ed9 --- /dev/null +++ b/Resources/Private/JavaScript/asset-usage/src/components/AssetUsageSection.module.css @@ -0,0 +1,54 @@ +.usageSection h2 { + font-size: var(--theme-fontSize-base); + font-weight: bold; + margin: 0; + padding: 0; +} + +.usageTable { + width: 100%; + margin-top: var(--theme-spacing-Full); + line-height: 1.5; +} + +.usageTable th { + font-weight: bold; + text-align: left; +} + +.usageTable td, +.usageTable th { + padding: var(--theme-spacing-Quarter); +} + +.usageTable td:first-child, +.usageTable th:first-child { + padding-left: 0; +} + +.usageTable td:last-child, +.usageTable th:last-child { + padding-right: 0; +} + +/* This is for specificity; otherwise `.neos.neos-module a` would override this link style in the backend module */ +.usageTable.usageTable a { + color: var(--theme-colors-PrimaryBlue); +} + +.usageTable.usageTable a:hover { + color: var(--theme-colors-PrimaryBlueHover); + text-decoration: underline; +} + +.usageTable li { + list-style-type: disc; +} + +.usageTable li ul { + padding-left: 1rem; +} + +.usageTable li ul li { + display: list-item; +} diff --git a/Resources/Private/JavaScript/asset-usage/src/components/AssetUsageSection.tsx b/Resources/Private/JavaScript/asset-usage/src/components/AssetUsageSection.tsx index 08901b38a..8561317ba 100644 --- a/Resources/Private/JavaScript/asset-usage/src/components/AssetUsageSection.tsx +++ b/Resources/Private/JavaScript/asset-usage/src/components/AssetUsageSection.tsx @@ -1,56 +1,8 @@ import * as React from 'react'; -import { useIntl, createUseMediaUiStyles, MediaUiTheme } from '@media-ui/core/src'; +import { useIntl } from '@media-ui/core'; -import { UsageDetailsGroup } from '../interfaces/UsageDetails'; - -const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - usageSection: { - '& h2': { - fontSize: theme.fontSize.base, - fontWeight: 'bold', - margin: 0, - padding: 0, - }, - }, - usageTable: { - width: '100%', - marginTop: theme.spacing.full, - lineHeight: 1.5, - '& th': { - fontWeight: 'bold', - textAlign: 'left', - }, - '& td, & th': { - padding: theme.spacing.quarter, - verticalAlign: 'baseline', - '&:first-child': { - paddingLeft: 0, - }, - '&:last-child': { - paddingRight: 0, - }, - }, - // `&&` is for specificity, otherwise `.neos.neos-module a` would override - // this link style in the backend module - '&& a': { - color: theme.colors.primary, - '&:hover': { - color: theme.colors.primary, - textDecoration: 'underline', - }, - }, - '& li': { - listStyleType: 'disc', - '& ul': { - paddingLeft: '1rem', - '& li': { - display: 'list-item', - }, - }, - }, - }, -})); +import classes from './AssetUsageSection.module.css'; interface AssetUsageSectionProps { usageDetailsGroup: UsageDetailsGroup; @@ -80,7 +32,6 @@ function renderObject(data: Record) { } const AssetUsageSection: React.FC = ({ usageDetailsGroup }: AssetUsageSectionProps) => { - const classes = useStyles(); const { translate } = useIntl(); const { label, usages, metadataSchema } = usageDetailsGroup; diff --git a/Resources/Private/JavaScript/asset-usage/src/components/AssetUsagesModal.module.css b/Resources/Private/JavaScript/asset-usage/src/components/AssetUsagesModal.module.css new file mode 100644 index 000000000..f26cac4b4 --- /dev/null +++ b/Resources/Private/JavaScript/asset-usage/src/components/AssetUsagesModal.module.css @@ -0,0 +1,41 @@ +.assetUsage { + padding: var(--theme-spacing-Full); + line-height: 1em; +} + +.assetUsage section + section { + margin-top: var(--theme-spacing-Full); +} + +.usageTable { + width: 100%; +} + +.usageTable th { + font-weight: bold; + text-align: left; +} + +.usageTable td, +.usageTable th { + padding: var(--theme-spacing-Quarter); +} + +.usageTable td:first-child, +.usageTable th:first-child { + padding-left: 0; +} + +.usageTable td:last-child, +.usageTable th:last-child { + padding-right: 0; +} + +.neos .usageTable a { + color: var(--theme-colors-PrimaryBlue); +} + +.neos .usageTable a:hover { + color: var(--theme-colors-PrimaryBlue); + text-decoration: underline; +} diff --git a/Resources/Private/JavaScript/asset-usage/src/components/AssetUsagesModal.tsx b/Resources/Private/JavaScript/asset-usage/src/components/AssetUsagesModal.tsx index d44971513..ba1416d86 100644 --- a/Resources/Private/JavaScript/asset-usage/src/components/AssetUsagesModal.tsx +++ b/Resources/Private/JavaScript/asset-usage/src/components/AssetUsagesModal.tsx @@ -2,50 +2,19 @@ import * as React from 'react'; import { useCallback } from 'react'; import { useRecoilState } from 'recoil'; -import { Button, Dialog } from '@neos-project/react-ui-components'; +import { Button } from '@neos-project/react-ui-components'; -import { useIntl, createUseMediaUiStyles, MediaUiTheme } from '@media-ui/core'; +import { useIntl } from '@media-ui/core'; import { useSelectedAsset } from '@media-ui/core/src/hooks'; +import { Dialog } from '@media-ui/core/src/components'; import assetUsageDetailsModalState from '../state/assetUsageDetailsModalState'; import useAssetUsagesQuery from '../hooks/useAssetUsages'; import AssetUsageSection from './AssetUsageSection'; -const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - assetUsage: { - padding: theme.spacing.full, - lineHeight: '1em', - '& section + section': { - marginTop: theme.spacing.full, - }, - }, - usageTable: { - width: '100%', - '& th': { - fontWeight: 'bold', - textAlign: 'left', - }, - '& td, & th': { - padding: theme.spacing.quarter, - '&:first-child': { - paddingLeft: 0, - }, - '&:last-child': { - paddingRight: 0, - }, - }, - '.neos & a': { - color: theme.colors.primary, - '&:hover': { - color: theme.colors.primary, - textDecoration: 'underline', - }, - }, - }, -})); +import classes from './AssetUsagesModal.module.css'; const AssetUsagesModal: React.FC = () => { - const classes = useStyles(); const { translate } = useIntl(); const [isOpen, setIsOpen] = useRecoilState(assetUsageDetailsModalState); const asset = useSelectedAsset(); diff --git a/Resources/Private/JavaScript/asset-usage/src/components/AssetUsagesToggleButton.tsx b/Resources/Private/JavaScript/asset-usage/src/components/AssetUsagesToggleButton.tsx index e9c097280..b7c7a7d28 100644 --- a/Resources/Private/JavaScript/asset-usage/src/components/AssetUsagesToggleButton.tsx +++ b/Resources/Private/JavaScript/asset-usage/src/components/AssetUsagesToggleButton.tsx @@ -1,21 +1,21 @@ -import * as React from 'react'; +import React from 'react'; import { useRecoilState } from 'recoil'; import { Button, Icon } from '@neos-project/react-ui-components'; -import { useIntl } from '@media-ui/core/src'; +import { useIntl } from '@media-ui/core'; +import { useSelectedAsset } from '@media-ui/core/src/hooks'; import assetUsageDetailsModalState from '../state/assetUsageDetailsModalState'; const AssetUsagesToggleButton: React.FC = () => { + const { isInUse } = useSelectedAsset(); const [assetUsagesModalOpen, setAssetUsagesModalOpen] = useRecoilState(assetUsageDetailsModalState); const { translate } = useIntl(); - // TODO: Resolve actual usage when calculation is fast enough or data has been preloaded - const usage = 1; return ( ); }; diff --git a/Resources/Private/JavaScript/clipboard/src/components/ClipboardToggle.module.css b/Resources/Private/JavaScript/clipboard/src/components/ClipboardToggle.module.css new file mode 100644 index 000000000..ebaf29941 --- /dev/null +++ b/Resources/Private/JavaScript/clipboard/src/components/ClipboardToggle.module.css @@ -0,0 +1,13 @@ +.clipboardToggle { + height: 100%; + align-self: flex-end; + display: flex; + justify-content: center; + align-items: center; + user-select: none; + margin: 0 -.3rem; +} + +.clipboardToggle > * { + margin: 0 .3rem; +} diff --git a/Resources/Private/JavaScript/clipboard/src/components/ClipboardToggle.tsx b/Resources/Private/JavaScript/clipboard/src/components/ClipboardToggle.tsx index 8c3fbd7f5..b5c319a56 100644 --- a/Resources/Private/JavaScript/clipboard/src/components/ClipboardToggle.tsx +++ b/Resources/Private/JavaScript/clipboard/src/components/ClipboardToggle.tsx @@ -1,36 +1,20 @@ -import * as React from 'react'; -import { useCallback } from 'react'; +import React, { useCallback } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { Button } from '@neos-project/react-ui-components'; -import { AssetIdentity } from '@media-ui/core/src/interfaces'; -import { useIntl, createUseMediaUiStyles } from '@media-ui/core/src'; +import { useIntl } from '@media-ui/core'; import { initialLoadCompleteState } from '@media-ui/core/src/state'; import ClipboardItem from './ClipboardItem'; -import useClipboard from '../hooks/useClipboard'; -import clipboardVisibleState from '../state/clipboardVisibleState'; +import { clipboardState } from '../state/clipboardState'; +import { clipboardVisibleState } from '../state/clipboardVisibleState'; -const useStyles = createUseMediaUiStyles({ - clipboard: { - height: '100%', - alignSelf: 'flex-end', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - userSelect: 'none', - margin: '0 -.3rem', - '& > *': { - margin: '0 .3rem', - }, - }, -}); +import classes from './ClipboardToggle.module.css'; const ClipboardToggle: React.FC = () => { - const classes = useStyles(); const { translate } = useIntl(); - const { clipboard } = useClipboard(); + const clipboard = useRecoilValue(clipboardState); const [clipboardVisible, setClipboardVisible] = useRecoilState(clipboardVisibleState); const initialLoadComplete = useRecoilValue(initialLoadCompleteState); @@ -41,7 +25,7 @@ const ClipboardToggle: React.FC = () => { if (!initialLoadComplete) return null; return ( -

+
, , ]} diff --git a/Resources/Private/JavaScript/core/src/provider/Intl.tsx b/Resources/Private/JavaScript/core/src/provider/Intl.tsx index 1ebfbfb96..bb750c7c5 100644 --- a/Resources/Private/JavaScript/core/src/provider/Intl.tsx +++ b/Resources/Private/JavaScript/core/src/provider/Intl.tsx @@ -1,17 +1,6 @@ import * as React from 'react'; import { createContext, useContext } from 'react'; -// TODO: This is a copy of the interface in Neos.Ui and should preferably be made available to plugins -export interface I18nRegistry { - translate: ( - id?: string, - fallback?: string, - params?: Record | string[], - packageKey?: string, - sourceName?: string - ) => string; -} - interface ProviderProps extends I18nRegistry { children: React.ReactElement; translate: ( diff --git a/Resources/Private/JavaScript/core/src/provider/MediaUiProvider.tsx b/Resources/Private/JavaScript/core/src/provider/MediaUiProvider.tsx index 67a0cde81..cee999e77 100644 --- a/Resources/Private/JavaScript/core/src/provider/MediaUiProvider.tsx +++ b/Resources/Private/JavaScript/core/src/provider/MediaUiProvider.tsx @@ -1,17 +1,13 @@ -import * as React from 'react'; -import { createContext, useCallback, useContext, useEffect, useMemo } from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useMemo } from 'react'; import { useApolloClient, gql } from '@apollo/client'; import { useSetRecoilState } from 'recoil'; import { isMatch } from 'matcher'; -import { useIntl } from '@media-ui/core/src'; - -import { Asset, AssetIdentity, FeatureFlags, SelectionConstraints } from '../interfaces'; -import { useAssetsQuery, useDeleteAsset, useImportAsset } from '../hooks'; +import { useImportAsset } from '../hooks'; import { useNotify } from './Notify'; +import { useIntl } from './Intl'; import { useInteraction } from './Interaction'; -import { selectedMediaTypeState } from '../state'; -import { AssetMediaType } from '../state/selectedMediaTypeState'; +import { constraintsState, featureFlagsState, selectedAssetTypeState, selectedMediaTypeState } from '../state'; import { ASSET_FRAGMENT } from '../fragments/asset'; import { ApprovalAttainmentStrategy, @@ -29,23 +25,19 @@ interface MediaUiProviderProps { onAssetSelection?: (localAssetIdentifier: string) => void; featureFlags: FeatureFlags; constraints?: SelectionConstraints; - assetType?: AssetMediaType; + assetType?: AssetType; approvalAttainmentStrategyFactory?: ApprovalAttainmentStrategyFactory; } interface MediaUiProviderValues { containerRef: React.RefObject; dummyImage: string; - handleDeleteAsset: (asset: Asset) => Promise; handleSelectAsset: (assetIdentity: AssetIdentity) => void; + // TODO: Turn view variants into a single view Enum selectionMode: boolean; isInNodeCreationDialog: boolean; isInMediaDetailsScreen: boolean; - assets: Asset[]; - refetchAssets: () => Promise; - featureFlags: FeatureFlags; - constraints: SelectionConstraints; - assetType: AssetMediaType; + assetType: AssetType; isAssetSelectable: (asset: Asset) => boolean; approvalAttainmentStrategy: ApprovalAttainmentStrategy; } @@ -70,10 +62,11 @@ export function MediaUiProvider({ const Notify = useNotify(); const Interaction = useInteraction(); const client = useApolloClient(); - const { deleteAsset } = useDeleteAsset(); const { importAsset } = useImportAsset(); - const { assets, refetch: refetchAssets } = useAssetsQuery(featureFlags.pagination); + const setConstraints = useSetRecoilState(constraintsState); + const setSelectedAssetType = useSetRecoilState(selectedAssetTypeState); const setSelectedMediaType = useSetRecoilState(selectedMediaTypeState); + const setFeatureFlags = useSetRecoilState(featureFlagsState); const approvalAttainmentStrategy = useMemo( () => approvalAttainmentStrategyFactory({ @@ -85,37 +78,19 @@ export function MediaUiProvider({ // Set initial media type state useEffect(() => { - if (assetType) { - setSelectedMediaType(assetType); + setConstraints(constraints); + setFeatureFlags(featureFlags); + // If only one media type is allowed by the constraints, preselect the filter + if (constraints.mediaTypes?.length === 1 && constraints.mediaTypes[0].startsWith('image')) { + setSelectedAssetType('image'); + setSelectedMediaType(constraints.mediaTypes[0]); + } else if (assetType !== 'all') { + setSelectedAssetType(assetType); } - }, [assetType, setSelectedMediaType]); - - const handleDeleteAsset = useCallback( - async (asset: Asset): Promise => { - const canDeleteAsset = await approvalAttainmentStrategy.obtainApprovalToDeleteAsset({ - asset, - }); - - if (canDeleteAsset) { - try { - await deleteAsset({ assetId: asset.id, assetSourceId: asset.assetSource.id }); - - Notify.ok(translate('action.deleteAsset.success', 'The asset has been deleted')); - - return true; - } catch ({ message }) { - Notify.error( - translate('action.deleteAsset.error', 'Error while trying to delete the asset'), - message - ); - } - } - - return false; - }, - [Notify, translate, deleteAsset, approvalAttainmentStrategy] - ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // TODO: This can properly be optimised by turning it into a recoil readonly selector family const isAssetSelectable = useCallback( (asset: Asset) => { if (constraints.mediaTypes?.length > 0) { @@ -133,7 +108,7 @@ export function MediaUiProvider({ [constraints] ); - // Handle selection mode for the secondary Neos UI inspector + // TODO: Move into select asset hook, as it's the only place using this method const handleSelectAsset = useCallback( (assetIdentity: AssetIdentity) => { if (!onAssetSelection || !assetIdentity) { @@ -185,15 +160,10 @@ export function MediaUiProvider({ value={{ containerRef, dummyImage, - handleDeleteAsset, handleSelectAsset, selectionMode, isInNodeCreationDialog, isInMediaDetailsScreen, - assets, - refetchAssets, - featureFlags, - constraints, assetType, isAssetSelectable, approvalAttainmentStrategy, diff --git a/Resources/Private/JavaScript/core/src/provider/MediaUiTheme.tsx b/Resources/Private/JavaScript/core/src/provider/MediaUiTheme.tsx deleted file mode 100644 index dcd0fdfe6..000000000 --- a/Resources/Private/JavaScript/core/src/provider/MediaUiTheme.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import * as React from 'react'; -import { createTheming, createUseStyles } from 'react-jss'; -import jss from 'jss'; -import jssPluginCache from 'jss-plugin-cache'; - -import { config } from '@neos-project/build-essentials/src/styles/styleConstants'; - -const ThemeContext = React.createContext({} as MediaUiTheme); -const theming = createTheming(ThemeContext); -const { ThemeProvider, useTheme } = theming; - -jss.use(jssPluginCache()); - -export interface MediaUiTheme { - spacing: { - goldenUnit: string; - full: string; - half: string; - quarter: string; - }; - fontSize: { - large: string; - base: string; - small: string; - }; - size: { - sidebarWidth: string; - scrollbarSize: string; - }; - colors: { - success: string; - warn: string; - error: string; - primary: string; - text: string; - mainBackground: string; - alternatingBackground: string; - border: string; - inactive: string; - generated: string; - warning: string; - deleted: string; - disabled: string; - moduleBackground: string; - assetBackground: string; - captionBackground: string; - scrollbarBackground: string; - scrollbarForeground: string; - }; - transition: { - fast: string; - default: string; - slow: string; - }; - paginationZIndex: number; - loadingIndicatorZIndex: number; - lightboxZIndex: number; -} - -// Extend theme config from Neos.Ui package -const mediaUiTheme: MediaUiTheme = { - spacing: config.spacing, - fontSize: { - ...config.fontSize, - large: '18px', - }, - size: { - ...config.size, - sidebarWidth: '250px', - scrollbarSize: '4px', - }, - colors: { - primary: config.colors.primaryBlue, - text: config.colors.contrastBright, - mainBackground: config.colors.contrastNeutral, - alternatingBackground: config.colors.contrastDark, - border: config.colors.contrastDark, - inactive: config.colors.contrastBright, - success: config.colors.success, - warn: config.colors.warn, - error: config.colors.error, - generated: config.colors.success, - warning: config.colors.warn, - deleted: config.colors.error, - disabled: config.colors.contrastDark, - assetBackground: config.colors.contrastDarkest, - captionBackground: config.colors.contrastNeutral, - moduleBackground: config.colors.contrastDarker, - scrollbarBackground: 'transparent', - scrollbarForeground: config.colors.contrastBright, - }, - transition: config.transition, - loadingIndicatorZIndex: 10024, - paginationZIndex: 10022, - lightboxZIndex: 10023, -}; - -export const useMediaUiTheme = useTheme; - -export const createUseMediaUiStyles = (styles) => - createUseStyles, MediaUiTheme>(styles, { theming }); - -export function MediaUiThemeProvider({ children }: { children: React.ReactElement }) { - return {children}; -} diff --git a/Resources/Private/JavaScript/core/src/provider/Notify.tsx b/Resources/Private/JavaScript/core/src/provider/Notify.tsx index 507ca7c1e..eaf9aa3b4 100644 --- a/Resources/Private/JavaScript/core/src/provider/Notify.tsx +++ b/Resources/Private/JavaScript/core/src/provider/Notify.tsx @@ -1,21 +1,13 @@ import * as React from 'react'; import { createContext, useContext } from 'react'; -export interface Notify { - notice: (title: string) => void; - ok: (title: string) => void; - error: (title: string, message?: string) => void; - warning: (title: string, message?: string) => void; - info: (title: string) => void; -} - interface ProviderProps { - notificationApi: Notify; + notificationApi: NeosNotification; children: React.ReactElement; } export const NotifyContext = createContext(null); -export const useNotify = (): Notify => useContext(NotifyContext); +export const useNotify = (): NeosNotification => useContext(NotifyContext); export function NotifyProvider({ children, notificationApi }: ProviderProps) { const error = (title: string, message = '') => notificationApi['error'](title, message); diff --git a/Resources/Private/JavaScript/core/src/provider/index.ts b/Resources/Private/JavaScript/core/src/provider/index.ts index 4a3406348..15f43a34a 100644 --- a/Resources/Private/JavaScript/core/src/provider/index.ts +++ b/Resources/Private/JavaScript/core/src/provider/index.ts @@ -1,24 +1,17 @@ -import { NotifyProvider, useNotify, Notify } from './Notify'; +import { NotifyProvider, useNotify } from './Notify'; import { MediaUiProvider, useMediaUi } from './MediaUiProvider'; -import { IntlProvider, useIntl, I18nRegistry } from './Intl'; -import { MediaUiThemeProvider, useMediaUiTheme, MediaUiTheme, createUseMediaUiStyles } from './MediaUiTheme'; +import { IntlProvider, useIntl } from './Intl'; import { Interaction, InteractionProvider, InteractionDialogRenderer, useInteraction } from './Interaction'; export { - I18nRegistry, IntlProvider, Interaction, InteractionProvider, InteractionDialogRenderer, MediaUiProvider, - MediaUiTheme, - MediaUiThemeProvider, - Notify, NotifyProvider, - createUseMediaUiStyles, useIntl, useInteraction, useMediaUi, - useMediaUiTheme, useNotify, }; diff --git a/Resources/Private/JavaScript/core/src/queries/assetCount.ts b/Resources/Private/JavaScript/core/src/queries/assetCount.ts index bece3fcfc..30918fd40 100644 --- a/Resources/Private/JavaScript/core/src/queries/assetCount.ts +++ b/Resources/Private/JavaScript/core/src/queries/assetCount.ts @@ -6,14 +6,15 @@ const ASSET_COUNT = gql` $assetSourceId: AssetSourceId $assetCollectionId: AssetCollectionId $mediaType: MediaType + $assetType: AssetType $tagId: TagId ) { - selectedAssetSourceId @client(always: true) @export(as: "assetSourceId") assetCount( searchTerm: $searchTerm assetSourceId: $assetSourceId assetCollectionId: $assetCollectionId mediaType: $mediaType + assetType: $assetType tagId: $tagId ) } diff --git a/Resources/Private/JavaScript/core/src/queries/assets.ts b/Resources/Private/JavaScript/core/src/queries/assets.ts index 04195559c..6a6b17d7b 100644 --- a/Resources/Private/JavaScript/core/src/queries/assets.ts +++ b/Resources/Private/JavaScript/core/src/queries/assets.ts @@ -8,6 +8,7 @@ const ASSETS = gql` $assetSourceId: AssetSourceId $assetCollectionId: AssetCollectionId $mediaType: MediaType + $assetType: AssetType $tagId: TagId $limit: Int $offset: Int @@ -15,13 +16,13 @@ const ASSETS = gql` $sortDirection: SortDirection $includeUsage: Boolean = false ) { - selectedAssetSourceId @client(always: true) @export(as: "assetSourceId") includeUsage @client(always: true) @export(as: "includeUsage") assets( searchTerm: $searchTerm assetSourceId: $assetSourceId assetCollectionId: $assetCollectionId mediaType: $mediaType + assetType: $assetType tagId: $tagId limit: $limit offset: $offset diff --git a/Resources/Private/JavaScript/core/src/queries/index.ts b/Resources/Private/JavaScript/core/src/queries/index.ts index 32853c819..3d2ed988e 100644 --- a/Resources/Private/JavaScript/core/src/queries/index.ts +++ b/Resources/Private/JavaScript/core/src/queries/index.ts @@ -1,24 +1,4 @@ -import ASSET from './asset'; -import ASSETS from './assets'; -import ASSET_COLLECTION from './assetCollection'; -import ASSET_COLLECTIONS from './assetCollections'; -import ASSET_COUNT from './assetCount'; -import ASSET_SOURCES from './assetSources'; -import CONFIG from './config'; -import TAG from './tag'; -import TAGS from './tags'; -import { SELECTED_ASSET_SOURCE_ID, SET_SELECTED_ASSET_SOURCE_ID } from './selectedAssetSource'; - -export { - ASSET, - ASSETS, - ASSET_COLLECTION, - ASSET_COLLECTIONS, - ASSET_COUNT, - ASSET_SOURCES, - CONFIG, - SELECTED_ASSET_SOURCE_ID, - SET_SELECTED_ASSET_SOURCE_ID, - TAG, - TAGS, -}; +export { default as ASSET } from './asset'; +export { default as ASSETS } from './assets'; +export { default as ASSET_COUNT } from './assetCount'; +export { default as CONFIG } from './config'; diff --git a/Resources/Private/JavaScript/core/src/queries/selectedAssetSource.ts b/Resources/Private/JavaScript/core/src/queries/selectedAssetSource.ts deleted file mode 100644 index a17297b1a..000000000 --- a/Resources/Private/JavaScript/core/src/queries/selectedAssetSource.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { gql } from '@apollo/client'; - -export const SELECTED_ASSET_SOURCE_ID = gql` - query SelectedAssetSourceId { - selectedAssetSourceId @client(always: true) - } -`; - -export const SET_SELECTED_ASSET_SOURCE_ID = gql` - mutation SetSelectedAssetSourceId($selectedAssetSourceId: String) { - setSelectedAssetSourceId(selectedAssetSourceId: $selectedAssetSourceId) @client - } -`; diff --git a/Resources/Private/JavaScript/core/src/state/availableAssetsState.ts b/Resources/Private/JavaScript/core/src/state/availableAssetsState.ts new file mode 100644 index 000000000..3a66fc046 --- /dev/null +++ b/Resources/Private/JavaScript/core/src/state/availableAssetsState.ts @@ -0,0 +1,18 @@ +import { atom, selector } from 'recoil'; + +const availableAssetsState = atom({ + key: 'AvailableAssetsState', + default: [], +}); + +const availableAssetIdentitiesState = selector({ + key: 'AvailableAssetIdentitiesState', + get: ({ get }) => { + return get(availableAssetsState).map((asset) => ({ + assetId: asset.id, + assetSourceId: asset.assetSource.id, + })); + }, +}); + +export { availableAssetsState, availableAssetIdentitiesState }; diff --git a/Resources/Private/JavaScript/core/src/state/constraintsState.ts b/Resources/Private/JavaScript/core/src/state/constraintsState.ts new file mode 100644 index 000000000..7424e311f --- /dev/null +++ b/Resources/Private/JavaScript/core/src/state/constraintsState.ts @@ -0,0 +1,9 @@ +import { atom } from 'recoil'; + +export const constraintsState = atom({ + key: 'ConstraintsState', + default: { + assetSources: [], + mediaTypes: [], + }, +}); diff --git a/Resources/Private/JavaScript/core/src/state/currentPageState.ts b/Resources/Private/JavaScript/core/src/state/currentPageState.ts index b8afb7db7..b8301c559 100644 --- a/Resources/Private/JavaScript/core/src/state/currentPageState.ts +++ b/Resources/Private/JavaScript/core/src/state/currentPageState.ts @@ -1,8 +1,9 @@ import { atom } from 'recoil'; -const currentPageState = atom({ +import { localStorageEffect } from './localStorageEffect'; + +export const currentPageState = atom({ key: 'currentPageState', default: 1, + effects: [localStorageEffect('currentPageState', (v) => (isNaN(v) ? 1 : v))], }); - -export default currentPageState; diff --git a/Resources/Private/JavaScript/core/src/state/featureFlagsState.ts b/Resources/Private/JavaScript/core/src/state/featureFlagsState.ts new file mode 100644 index 000000000..d28a42072 --- /dev/null +++ b/Resources/Private/JavaScript/core/src/state/featureFlagsState.ts @@ -0,0 +1,28 @@ +import { atom } from 'recoil'; + +export const featureFlagsState = atom({ + key: 'FeatureFlagsState', + default: { + useNewMediaSelection: true, + queryAssetUsage: false, + pollForChanges: true, + showSimilarAssets: false, + showVariantsEditor: false, + createAssetRedirectsOption: true, + pagination: { + assetsPerPage: 20, + maximumLinks: 5, + }, + propertyEditor: { + collapsed: false, + }, + limitToSingleAssetCollectionPerAsset: true, + mediaTypeFilterOptions: { + all: {}, + image: {}, + video: {}, + document: {}, + audio: {}, + }, + }, +}); diff --git a/Resources/Private/JavaScript/core/src/state/index.ts b/Resources/Private/JavaScript/core/src/state/index.ts index d60e22662..ce625216e 100644 --- a/Resources/Private/JavaScript/core/src/state/index.ts +++ b/Resources/Private/JavaScript/core/src/state/index.ts @@ -1,23 +1,14 @@ -import currentPageState from './currentPageState'; -import initialLoadCompleteState from './initialLoadComplete'; -import loadingState from './loadingState'; -import searchTermState from './searchTermState'; -import selectedAssetCollectionIdState from './selectedAssetCollectionIdState'; -import selectedAssetIdState from './selectedAssetIdState'; -import selectedInspectorViewState from './selectedInspectorViewState'; -import selectedMediaTypeState from './selectedMediaTypeState'; -import selectedSortOrderState from './selectedSortOrderState'; -import selectedTagIdState from './selectedTagIdState'; - -export { - currentPageState, - initialLoadCompleteState, - loadingState, - searchTermState, - selectedAssetCollectionIdState, - selectedAssetIdState, - selectedInspectorViewState, - selectedMediaTypeState, - selectedSortOrderState, - selectedTagIdState, -}; +export { availableAssetsState, availableAssetIdentitiesState } from './availableAssetsState'; +export { constraintsState } from './constraintsState'; +export { currentPageState } from './currentPageState'; +export { featureFlagsState } from './featureFlagsState'; +export { initialLoadCompleteState } from './initialLoadCompleteState'; +export { loadingState } from './loadingState'; +export { localStorageEffect } from './localStorageEffect'; +export { searchTermState } from './searchTermState'; +export { selectedAssetCollectionAndTagState } from './selectedAssetCollectionAndTagState'; +export { selectedAssetIdState } from './selectedAssetIdState'; +export { selectedInspectorViewState } from './selectedInspectorViewState'; +export { selectedMediaTypeState } from './selectedMediaTypeState'; +export { selectedAssetTypeState } from './selectedAssetTypeState'; +export { selectedSortOrderState } from './selectedSortOrderState'; diff --git a/Resources/Private/JavaScript/core/src/state/initialLoadComplete.ts b/Resources/Private/JavaScript/core/src/state/initialLoadComplete.ts deleted file mode 100644 index 990c3d1f7..000000000 --- a/Resources/Private/JavaScript/core/src/state/initialLoadComplete.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { atom } from 'recoil'; - -const initialLoadCompleteState = atom({ - key: 'initialLoadComplete', - default: false, -}); - -export default initialLoadCompleteState; diff --git a/Resources/Private/JavaScript/core/src/state/initialLoadCompleteState.ts b/Resources/Private/JavaScript/core/src/state/initialLoadCompleteState.ts new file mode 100644 index 000000000..d81631d2b --- /dev/null +++ b/Resources/Private/JavaScript/core/src/state/initialLoadCompleteState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const initialLoadCompleteState = atom({ + key: 'InitialLoadCompleteState', + default: false, +}); diff --git a/Resources/Private/JavaScript/core/src/state/loadingState.ts b/Resources/Private/JavaScript/core/src/state/loadingState.ts index d41a9581f..eb5b1106f 100644 --- a/Resources/Private/JavaScript/core/src/state/loadingState.ts +++ b/Resources/Private/JavaScript/core/src/state/loadingState.ts @@ -1,8 +1,6 @@ import { atom } from 'recoil'; -const loadingState = atom({ +export const loadingState = atom({ key: 'loadingState', default: false, }); - -export default loadingState; diff --git a/Resources/Private/JavaScript/core/src/state/localStorageEffect.ts b/Resources/Private/JavaScript/core/src/state/localStorageEffect.ts new file mode 100644 index 000000000..749405aa7 --- /dev/null +++ b/Resources/Private/JavaScript/core/src/state/localStorageEffect.ts @@ -0,0 +1,23 @@ +/** + * This is a custom recoil storage effect that allows us to persist state in local storage. + * It can be added to any atom as effect. + */ +const STORAGE_PREFIX = 'flowpack.mediaui'; + +// TODO: Listen to storage events to allow syncing two tabs +export function localStorageEffect(key: string, validate?: (value: T | undefined) => T) { + return ({ setSelf, onSet }) => { + const fullKey = `${STORAGE_PREFIX}.${key}`; + const savedValueJSON = localStorage.getItem(fullKey); + if (savedValueJSON != null) { + let savedValue = JSON.parse(savedValueJSON); + if (validate) { + savedValue = validate(savedValue); + } + setSelf(savedValue); + } + onSet((newValue, previousValue: T | undefined, isReset) => { + isReset ? localStorage.removeItem(fullKey) : localStorage.setItem(fullKey, JSON.stringify(newValue)); + }); + }; +} diff --git a/Resources/Private/JavaScript/core/src/state/searchTermState.ts b/Resources/Private/JavaScript/core/src/state/searchTermState.ts index f97dfbf24..20a92a460 100644 --- a/Resources/Private/JavaScript/core/src/state/searchTermState.ts +++ b/Resources/Private/JavaScript/core/src/state/searchTermState.ts @@ -1,11 +1,11 @@ import { atom } from 'recoil'; import { SearchTerm } from '../domain/SearchTerm'; +import { localStorageEffect } from './localStorageEffect'; -const searchTermState = atom({ +export const searchTermState = atom({ key: 'searchTermState', default: typeof window === 'undefined' ? SearchTerm.fromString('') : SearchTerm.fromUrl(new URL(window.location.href)), + effects: [localStorageEffect('searchTermState', ({ value }) => SearchTerm.fromString(value))], }); - -export default searchTermState; diff --git a/Resources/Private/JavaScript/core/src/state/selectedAssetCollectionAndTagState.ts b/Resources/Private/JavaScript/core/src/state/selectedAssetCollectionAndTagState.ts new file mode 100644 index 000000000..63c98abc3 --- /dev/null +++ b/Resources/Private/JavaScript/core/src/state/selectedAssetCollectionAndTagState.ts @@ -0,0 +1,27 @@ +import { selector } from 'recoil'; + +import { selectedTagIdState } from '@media-ui/feature-asset-tags'; +import { selectedAssetCollectionIdState } from '@media-ui/feature-asset-collections'; +import { clipboardVisibleState } from '@media-ui/feature-clipboard'; + +import { currentPageState } from './currentPageState'; +import { selectedInspectorViewState } from './selectedInspectorViewState'; +import { selectedAssetIdState } from './selectedAssetIdState'; + +// This is a proxy for setting the selected tag id, which also executes side effects to update other state +// By setting the other state in a selector, we can ensure that the state is updated all at once +export const selectedAssetCollectionAndTagState = selector<{ tagId: string | null; assetCollectionId: string | null }>({ + key: 'SelectedTagIdProxySelector', + get: ({ get }) => ({ + tagId: get(selectedTagIdState), + assetCollectionId: get(selectedAssetCollectionIdState), + }), + set: ({ set }, props: { tagId: string | null; assetCollectionId: string | null }) => { + set(selectedInspectorViewState, props.tagId ? 'tag' : 'assetCollection'); + set(selectedTagIdState, props.tagId); + set(selectedAssetIdState, null); + set(currentPageState, 1); + set(selectedAssetCollectionIdState, props.assetCollectionId); + set(clipboardVisibleState, false); + }, +}); diff --git a/Resources/Private/JavaScript/core/src/state/selectedAssetCollectionIdState.ts b/Resources/Private/JavaScript/core/src/state/selectedAssetCollectionIdState.ts deleted file mode 100644 index 82b242706..000000000 --- a/Resources/Private/JavaScript/core/src/state/selectedAssetCollectionIdState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { atom } from 'recoil'; - -const selectedAssetCollectionIdState = atom({ - key: 'selectedAssetCollectionIdState', - default: null, -}); - -export default selectedAssetCollectionIdState; diff --git a/Resources/Private/JavaScript/core/src/state/selectedAssetIdState.ts b/Resources/Private/JavaScript/core/src/state/selectedAssetIdState.ts index 15cd09ee3..0f7b58b1f 100644 --- a/Resources/Private/JavaScript/core/src/state/selectedAssetIdState.ts +++ b/Resources/Private/JavaScript/core/src/state/selectedAssetIdState.ts @@ -1,10 +1,9 @@ import { atom } from 'recoil'; -import AssetIdentity from '../interfaces/AssetIdentity'; +import { localStorageEffect } from './localStorageEffect'; -const selectedAssetIdState = atom({ +export const selectedAssetIdState = atom({ key: 'selectedAssetIdState', default: null, + effects: [localStorageEffect('selectedAssetIdState')], }); - -export default selectedAssetIdState; diff --git a/Resources/Private/JavaScript/core/src/state/selectedAssetTypeState.ts b/Resources/Private/JavaScript/core/src/state/selectedAssetTypeState.ts new file mode 100644 index 000000000..55eec92d2 --- /dev/null +++ b/Resources/Private/JavaScript/core/src/state/selectedAssetTypeState.ts @@ -0,0 +1,9 @@ +import { atom } from 'recoil'; + +import { localStorageEffect } from './localStorageEffect'; + +export const selectedAssetTypeState = atom({ + key: 'selectedAssetTypeState', + default: '', + effects: [localStorageEffect('selectedAssetTypeState')], +}); diff --git a/Resources/Private/JavaScript/core/src/state/selectedInspectorViewState.ts b/Resources/Private/JavaScript/core/src/state/selectedInspectorViewState.ts index 55034e006..5e2b40b74 100644 --- a/Resources/Private/JavaScript/core/src/state/selectedInspectorViewState.ts +++ b/Resources/Private/JavaScript/core/src/state/selectedInspectorViewState.ts @@ -1,8 +1,9 @@ import { atom } from 'recoil'; +import { localStorageEffect } from './localStorageEffect'; -const selectedInspectorViewState = atom({ +export const selectedInspectorViewState = atom({ key: 'selectedInspectorViewState', default: null, + // TODO: Add validator to make sure we can display the selected inspector view + effects: [localStorageEffect('selectedInspectorViewState')], }); - -export default selectedInspectorViewState; diff --git a/Resources/Private/JavaScript/core/src/state/selectedMediaTypeState.ts b/Resources/Private/JavaScript/core/src/state/selectedMediaTypeState.ts index 2c150e3ca..b19a69eff 100644 --- a/Resources/Private/JavaScript/core/src/state/selectedMediaTypeState.ts +++ b/Resources/Private/JavaScript/core/src/state/selectedMediaTypeState.ts @@ -1,10 +1,9 @@ import { atom } from 'recoil'; -export type AssetMediaType = 'image' | 'video' | 'audio' | 'document' | 'all'; +import { localStorageEffect } from './localStorageEffect'; -const selectedMediaTypeState = atom({ +export const selectedMediaTypeState = atom({ key: 'selectedMediaTypeState', - default: 'all', + default: '', + effects: [localStorageEffect('selectedMediaTypeState')], }); - -export default selectedMediaTypeState; diff --git a/Resources/Private/JavaScript/core/src/state/selectedSortOrderState.ts b/Resources/Private/JavaScript/core/src/state/selectedSortOrderState.ts index 3227bce20..6f29670b3 100644 --- a/Resources/Private/JavaScript/core/src/state/selectedSortOrderState.ts +++ b/Resources/Private/JavaScript/core/src/state/selectedSortOrderState.ts @@ -1,9 +1,13 @@ import { atom } from 'recoil'; +import { localStorageEffect } from './localStorageEffect'; + export enum SORT_BY { Name = 'name', LastModified = 'lastModified', + Size = 'size', } + export enum SORT_DIRECTION { Asc = 'ASC', Desc = 'DESC', @@ -13,12 +17,12 @@ export interface SortOrder { sortBy: SORT_BY; sortDirection: SORT_DIRECTION; } -const selectedSortOrderState = atom({ + +export const selectedSortOrderState = atom({ key: 'selectedSortOrderState', default: { sortBy: SORT_BY.LastModified, sortDirection: SORT_DIRECTION.Desc, }, + effects: [localStorageEffect('selectedSortOrderState')], }); - -export default selectedSortOrderState; diff --git a/Resources/Private/JavaScript/core/src/state/selectedTagIdState.ts b/Resources/Private/JavaScript/core/src/state/selectedTagIdState.ts deleted file mode 100644 index be9cb1bed..000000000 --- a/Resources/Private/JavaScript/core/src/state/selectedTagIdState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { atom } from 'recoil'; - -const selectedTagIdState = atom({ - key: 'selectedTagIdState', - default: null, -}); - -export default selectedTagIdState; diff --git a/Resources/Private/JavaScript/core/src/strategy/ApprovalAttainmentStrategy.ts b/Resources/Private/JavaScript/core/src/strategy/ApprovalAttainmentStrategy.ts index d8815794f..f55824a2b 100644 --- a/Resources/Private/JavaScript/core/src/strategy/ApprovalAttainmentStrategy.ts +++ b/Resources/Private/JavaScript/core/src/strategy/ApprovalAttainmentStrategy.ts @@ -1,6 +1,6 @@ -import { Asset, AssetCollection, Tag } from '../interfaces'; -import { I18nRegistry, Interaction } from '../provider'; +import { Interaction } from '../provider'; +// TODO: Feature packages should be able to extend the ApprovalAttainmentStrategy with their own methods. export interface ApprovalAttainmentStrategy { obtainApprovalToUpdateAsset: (given: { asset: Asset }) => Promise; obtainApprovalToSetAssetTags: (given: { asset: Asset; newTags: Tag[] }) => Promise; @@ -9,8 +9,12 @@ export interface ApprovalAttainmentStrategy { newAssetCollections: AssetCollection[]; }) => Promise; obtainApprovalToDeleteAsset: (given: { asset: Asset }) => Promise; + obtainApprovalToDeleteAssets: (given: { assets: AssetIdentity[] }) => Promise; + obtainApprovalToDeleteAssetCollection: (given: { assetCollection: AssetCollection }) => Promise; + obtainApprovalToDeleteTag: (given: { tag: Tag }) => Promise; obtainApprovalToReplaceAsset: (given: { asset: Asset }) => Promise; obtainApprovalToEditAsset: (given: { asset: Asset }) => Promise; + obtainApprovalToFlushClipboard: () => Promise; } const assumeApproval = () => Promise.resolve(true); @@ -20,8 +24,12 @@ export const AssumeApprovalForEveryAction: ApprovalAttainmentStrategy = { obtainApprovalToSetAssetTags: assumeApproval, obtainApprovalToSetAssetCollections: assumeApproval, obtainApprovalToDeleteAsset: assumeApproval, + obtainApprovalToDeleteAssets: assumeApproval, + obtainApprovalToDeleteAssetCollection: assumeApproval, + obtainApprovalToDeleteTag: assumeApproval, obtainApprovalToReplaceAsset: assumeApproval, obtainApprovalToEditAsset: assumeApproval, + obtainApprovalToFlushClipboard: assumeApproval, }; export interface ApprovalAttainmentStrategyFactory { @@ -35,7 +43,7 @@ export const DefaultApprovalAttainmentStrategyFactory: ApprovalAttainmentStrateg title: deps.intl.translate('actions.deleteAsset.confirm.title', 'Delete Asset', [asset.label]), message: deps.intl.translate( 'action.deleteAsset.confirm.message', - 'Do you really want to delete the asset ' + asset.label, + `Do you really want to delete the asset "${asset.label}"`, [asset.label] ), buttonLabel: deps.intl.translate( @@ -44,4 +52,60 @@ export const DefaultApprovalAttainmentStrategyFactory: ApprovalAttainmentStrateg [asset.label] ), }), + obtainApprovalToDeleteAssets: ({ assets }) => + deps.interaction.confirm({ + title: deps.intl.translate('actions.deleteAssets.confirm.title', 'Delete Assets', [assets.length]), + message: deps.intl.translate( + 'action.deleteAssets.confirm.message', + `Do you really want to delete ${assets.length} assets`, + [assets.length] + ), + buttonLabel: deps.intl.translate( + 'actions.deleteAssets.confirm.buttonLabel', + 'Yes, proceed with deleting the assets', + [assets.length] + ), + }), + obtainApprovalToDeleteAssetCollection: ({ assetCollection }) => + deps.interaction.confirm({ + title: deps.intl.translate('actions.deleteAssetCollection.confirm.title', 'Delete collection', [ + assetCollection.title, + ]), + message: deps.intl.translate( + 'action.deleteAssetCollection.confirm.message', + `Do you really want to delete the collection "${assetCollection.title}"`, + [assetCollection.title] + ), + buttonLabel: deps.intl.translate( + 'actions.deleteAssetCollection.confirm.buttonLabel', + 'Yes, proceed with deleting the collection', + [assetCollection.title] + ), + }), + obtainApprovalToDeleteTag: ({ tag }) => + deps.interaction.confirm({ + title: deps.intl.translate('actions.deleteTag.confirm.title', 'Delete tag', [tag.label]), + message: deps.intl.translate( + 'action.deleteTag.confirm.message', + `Do you really want to delete the tag "${tag.label}"`, + [tag.label] + ), + buttonLabel: deps.intl.translate( + 'actions.deleteTag.confirm.buttonLabel', + 'Yes, proceed with deleting the tag', + [tag.label] + ), + }), + obtainApprovalToFlushClipboard: () => + deps.interaction.confirm({ + title: deps.intl.translate('actions.flushClipboard.confirm.title', 'Flush clipboard'), + message: deps.intl.translate( + 'action.flushClipboard.confirm.message', + `Do you really want to remove all assets from the clipboard?` + ), + buttonLabel: deps.intl.translate( + 'actions.flushClipboard.confirm.buttonLabel', + 'Yes, proceed with flushing the clipboard' + ), + }), }); diff --git a/Resources/Private/JavaScript/core/src/typeDefs.ts b/Resources/Private/JavaScript/core/src/typeDefs.ts new file mode 100644 index 000000000..5133ae85a --- /dev/null +++ b/Resources/Private/JavaScript/core/src/typeDefs.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const typeDefs = gql` + # Define the apollo specific directives here to prevent schema warnings in the IDE + directive @client(always: Boolean!) on FIELD + directive @export(as: String!) on FIELD + + # TODO: Can this type be removed or moved to the root schema? + type AssetIdentity { + id: AssetId! + assetSourceId: AssetSourceId! + } +`; diff --git a/Resources/Private/JavaScript/core/src/interfaces/Asset.ts b/Resources/Private/JavaScript/core/typings/Asset.ts similarity index 55% rename from Resources/Private/JavaScript/core/src/interfaces/Asset.ts rename to Resources/Private/JavaScript/core/typings/Asset.ts index ce3414fc2..3818ff5e6 100644 --- a/Resources/Private/JavaScript/core/src/interfaces/Asset.ts +++ b/Resources/Private/JavaScript/core/typings/Asset.ts @@ -1,14 +1,7 @@ -import GraphQlEntity from './GraphQLEntity'; -import AssetSource from './AssetSource'; -import Tag from './Tag'; -import AssetCollection from './AssetCollection'; -import IptcProperty from './IptcProperty'; -import AssetFile from './AssetFile'; +type AssetEntityType = 'Asset'; -type AssetType = 'Asset'; - -export default interface Asset extends GraphQlEntity { - __typename: AssetType; +interface Asset extends GraphQlEntity { + __typename: AssetEntityType; readonly id: string; readonly localId?: string; assetSource: AssetSource; @@ -34,7 +27,4 @@ export default interface Asset extends GraphQlEntity { file: AssetFile; thumbnailUrl?: string; previewUrl?: string; - - // TODO: Somehow extend from clipboard feature package - isInClipboard?: boolean; } diff --git a/Resources/Private/JavaScript/core/typings/AssetFile.ts b/Resources/Private/JavaScript/core/typings/AssetFile.ts new file mode 100644 index 000000000..38d594b49 --- /dev/null +++ b/Resources/Private/JavaScript/core/typings/AssetFile.ts @@ -0,0 +1,10 @@ +type AssetFileType = 'AssetFile'; + +interface AssetFile extends GraphQlEntity { + __typename: AssetFileType; + extension: string; + mediaType: MediaType; + typeIcon: Image; + size?: number; + url: string; +} diff --git a/Resources/Private/JavaScript/core/src/interfaces/AssetIdentity.ts b/Resources/Private/JavaScript/core/typings/AssetIdentity.ts similarity index 54% rename from Resources/Private/JavaScript/core/src/interfaces/AssetIdentity.ts rename to Resources/Private/JavaScript/core/typings/AssetIdentity.ts index 510b02e86..fda12e6bb 100644 --- a/Resources/Private/JavaScript/core/src/interfaces/AssetIdentity.ts +++ b/Resources/Private/JavaScript/core/typings/AssetIdentity.ts @@ -1,4 +1,4 @@ -export default interface AssetIdentity { +interface AssetIdentity { assetId: string; assetSourceId: string; } diff --git a/Resources/Private/JavaScript/core/src/interfaces/FeatureFlags.ts b/Resources/Private/JavaScript/core/typings/FeatureFlags.ts similarity index 63% rename from Resources/Private/JavaScript/core/src/interfaces/FeatureFlags.ts rename to Resources/Private/JavaScript/core/typings/FeatureFlags.ts index 6783dc890..636bdc0fa 100644 --- a/Resources/Private/JavaScript/core/src/interfaces/FeatureFlags.ts +++ b/Resources/Private/JavaScript/core/typings/FeatureFlags.ts @@ -1,12 +1,16 @@ -export default interface FeatureFlags { - queryAssetUsage: boolean; - pollForChanges: boolean; - useNewMediaSelection: boolean; - showSimilarAssets: boolean; - showVariantsEditor: boolean; +interface FeatureFlags { createAssetRedirectsOption: boolean; + limitToSingleAssetCollectionPerAsset: boolean; pagination: PaginationConfig; + pollForChanges: boolean; propertyEditor: { collapsed: boolean; }; + queryAssetUsage: boolean; + showSimilarAssets: boolean; + showVariantsEditor: boolean; + useNewMediaSelection: boolean; + mediaTypeFilterOptions: { + [key in AssetType]: Record; + }; } diff --git a/Resources/Private/JavaScript/core/typings/GraphQlEntity.ts b/Resources/Private/JavaScript/core/typings/GraphQlEntity.ts new file mode 100644 index 000000000..a455950ce --- /dev/null +++ b/Resources/Private/JavaScript/core/typings/GraphQlEntity.ts @@ -0,0 +1,3 @@ +interface GraphQlEntity { + __typename: string; +} diff --git a/Resources/Private/JavaScript/core/src/interfaces/Image.ts b/Resources/Private/JavaScript/core/typings/Image.ts similarity index 56% rename from Resources/Private/JavaScript/core/src/interfaces/Image.ts rename to Resources/Private/JavaScript/core/typings/Image.ts index 78177a12a..98e83323c 100644 --- a/Resources/Private/JavaScript/core/src/interfaces/Image.ts +++ b/Resources/Private/JavaScript/core/typings/Image.ts @@ -1,8 +1,6 @@ -import GraphQlEntity from './GraphQLEntity'; - type ImageType = 'Image'; -export default interface Image extends GraphQlEntity { +interface Image extends GraphQlEntity { __typename: ImageType; width: number; height: number; diff --git a/Resources/Private/JavaScript/core/src/interfaces/IptcProperty.ts b/Resources/Private/JavaScript/core/typings/IptcProperty.ts similarity index 53% rename from Resources/Private/JavaScript/core/src/interfaces/IptcProperty.ts rename to Resources/Private/JavaScript/core/typings/IptcProperty.ts index 09ee03fa1..527ab7d1a 100644 --- a/Resources/Private/JavaScript/core/src/interfaces/IptcProperty.ts +++ b/Resources/Private/JavaScript/core/typings/IptcProperty.ts @@ -1,8 +1,6 @@ -import GraphQlEntity from './GraphQLEntity'; - type IptcPropertyType = 'IptcProperty'; -export default interface IptcProperty extends GraphQlEntity { +interface IptcProperty extends GraphQlEntity { __typename: IptcPropertyType; propertyName: string; value: string; diff --git a/Resources/Private/JavaScript/core/typings/MediaEvent.ts b/Resources/Private/JavaScript/core/typings/MediaEvent.ts new file mode 100644 index 000000000..725946d2d --- /dev/null +++ b/Resources/Private/JavaScript/core/typings/MediaEvent.ts @@ -0,0 +1,10 @@ +type Topic = string; +type Token = string; +type Subscriber = (topic: Topic, payload: A) => void; + +interface MediaEvent { + (payload: E): E; + subscribe(subscriber: Subscriber): Token; + unsubscribe(subscriber: Subscriber | Token): void; + publish(payload: E): boolean; +} diff --git a/Resources/Private/JavaScript/core/typings/SelectionConstraints.ts b/Resources/Private/JavaScript/core/typings/SelectionConstraints.ts new file mode 100644 index 000000000..139411aa7 --- /dev/null +++ b/Resources/Private/JavaScript/core/typings/SelectionConstraints.ts @@ -0,0 +1,4 @@ +interface SelectionConstraints { + assetSources?: string[]; + mediaTypes?: MediaType[]; +} diff --git a/Resources/Private/JavaScript/core/typings/global.d.ts b/Resources/Private/JavaScript/core/typings/global.d.ts index f49168a7c..44774c4f0 100644 --- a/Resources/Private/JavaScript/core/typings/global.d.ts +++ b/Resources/Private/JavaScript/core/typings/global.d.ts @@ -1,4 +1,8 @@ -declare module '*.module.css'; +declare module '*.module.css' { + const classes: { [key: string]: string }; + export = classes; + export default classes; +} interface NeosI18n { translate: ( @@ -11,6 +15,17 @@ interface NeosI18n { initialized: boolean; } +// TODO: This is a copy of the interface in Neos.Ui and should preferably be made available to plugins +interface I18nRegistry { + translate: ( + id?: string, + fallback?: string, + params?: Record | (string | number)[], + packageKey?: string, + sourceName?: string + ) => string; +} + interface NeosNotification { notice: (title: string) => void; ok: (title: string) => void; @@ -30,3 +45,6 @@ type PaginationConfig = { assetsPerPage: number; maximumLinks: number; }; + +type AssetType = 'image' | 'video' | 'audio' | 'document' | 'all'; +type MediaType = `${string}/${string}`; diff --git a/Resources/Private/JavaScript/dev-server/package.json b/Resources/Private/JavaScript/dev-server/package.json index 4b4d72757..33cd24dfb 100644 --- a/Resources/Private/JavaScript/dev-server/package.json +++ b/Resources/Private/JavaScript/dev-server/package.json @@ -3,14 +3,14 @@ "version": "1.0.0", "license": "GNU GPLv3", "dependencies": { - "@media-ui/feature-asset-usage": "workspace:*", - "@media-ui/feature-concurrent-editing": "workspace:*", "@media-ui/media-module": "workspace:*" }, + "type": "module", "devDependencies": { - "@types/node": "^16.18.3", + "@types/node": "^16.18.34", "express": "^4.18.2", - "nodemon": "^2.0.21" + "http-proxy-middleware": "^2.0.6", + "nodemon": "^2.0.22" }, "scripts": { "dev": "nodemon --watch '*.ts' src/server.ts" diff --git a/Resources/Private/JavaScript/dev-server/src/fixtures.ts b/Resources/Private/JavaScript/dev-server/src/fixtures/index.ts similarity index 85% rename from Resources/Private/JavaScript/dev-server/src/fixtures.ts rename to Resources/Private/JavaScript/dev-server/src/fixtures/index.ts index 87dc15fe9..f13c2e1a7 100644 --- a/Resources/Private/JavaScript/dev-server/src/fixtures.ts +++ b/Resources/Private/JavaScript/dev-server/src/fixtures/index.ts @@ -1,16 +1,12 @@ -const cloneDeep = require('lodash.clonedeep'); - -// FIXME: type annotations are missing as they couldn't be included anymore while making the devserver work again -// import { Asset, AssetCollection, AssetSource, Image, IptcProperty, Tag } from '@media-ui/core/src/interfaces'; -// import { UsageDetailsGroup } from '@media-ui/feature-asset-usage/src'; +import cloneDeep from 'lodash.clonedeep'; const exampleImages = ['example1.jpg', 'example2.jpg', 'example3.jpg']; const range = (length: number) => [...Array(length)].map((val, i) => i); const getExampleFilename = (seed = 0) => exampleImages[seed % exampleImages.length]; -const getExampleImagePath = (filename) => `Assets/${filename}`; +const getExampleImagePath = (filename: string) => `Assets/${filename}`; -const getIptcProperties = (seed: number) => [ +const getIptcProperties = (seed: number): IptcProperty[] => [ { __typename: 'IptcProperty', propertyName: 'Camera', @@ -38,7 +34,7 @@ const typeIcons = { }, }; -const assetSources = [ +const assetSources: AssetSource[] = [ { __typename: 'AssetSource', id: 'neos', @@ -61,22 +57,29 @@ const assetSources = [ }, ]; -const tags = range(10).map((index) => ({ +const tags: Tag[] = range(10).map((index) => ({ __typename: 'Tag', id: `index ${index + 1}`, label: `Example tag ${index + 1}`, - parent: null, - children: [], })); -const assetCollections = range(3).map((index) => ({ +const assetCollections: AssetCollection[] = range(3).map((index) => ({ __typename: 'AssetCollection', id: `someId_${index}`, title: `Example collection ${index + 1}`, tags: range(index % 3).map((i) => tags[(i * 3 + index) % tags.length]), + parent: + index == 1 + ? { + id: `someId_0`, + title: `Example collection 1`, + } + : null, + // TODO: Recalculate assetCount of assetCollections after generation of assets + assetCount: 0, })); -const getUsageDetailsForAsset = (assetId: string) => { +const getUsageDetailsForAsset = (assetId: string): UsageDetailsGroup[] => { const usageCount = (parseInt(assetId) || 0) % 5; return [ @@ -186,12 +189,11 @@ const assets = range(150).map((index) => { const loadFixtures = () => { return { - assets: cloneDeep(assets), - assetCollections: cloneDeep(assetCollections), - assetSources: cloneDeep(assetSources), - tags: cloneDeep(tags), + assets: cloneDeep(assets) as Asset[], + assetCollections: cloneDeep(assetCollections) as AssetCollection[], + assetSources: cloneDeep(assetSources) as AssetSource[], + tags: cloneDeep(tags) as Tag[], }; }; -exports.loadFixtures = loadFixtures; -exports.getUsageDetailsForAsset = getUsageDetailsForAsset; +export { loadFixtures, getUsageDetailsForAsset }; diff --git a/Resources/Private/JavaScript/dev-server/src/index.html b/Resources/Private/JavaScript/dev-server/src/index.html index 89a3cab9e..0bf93db79 100644 --- a/Resources/Private/JavaScript/dev-server/src/index.html +++ b/Resources/Private/JavaScript/dev-server/src/index.html @@ -24,7 +24,7 @@ - - + + diff --git a/Resources/Private/JavaScript/dev-server/src/index.ts b/Resources/Private/JavaScript/dev-server/src/index.ts index 5ce2e3746..4aa65a9d7 100644 --- a/Resources/Private/JavaScript/dev-server/src/index.ts +++ b/Resources/Private/JavaScript/dev-server/src/index.ts @@ -1,5 +1,3 @@ -import { FeatureFlags } from '@media-ui/core/src/interfaces'; - setTimeout(() => { console.info('Started Media Module dev server script'); @@ -28,10 +26,36 @@ setTimeout(() => { showSimilarAssets: true, showAssetUsage: true, showVariantsEditor: true, + limitToSingleAssetCollectionPerAsset: true, + mediaTypeFilterOptions: { + all: {}, + image: { + 'image/svg+xml': 'SVG', + 'image/png': 'PNG', + 'image/jpeg': 'JPEG', + 'image/gif': 'GIF', + 'image/webp': 'WEBP', + }, + document: { + 'application/pdf': 'PDF', + }, + audio: { + 'audio/mpeg': 'MP3', + 'audio/ogg': 'OGG', + 'audio/wav': 'WAV', + 'audio/webm': 'WEBM', + }, + video: { + 'video/mp4': 'MP4', + 'video/ogg': 'OGG', + 'video/webm': 'WEBM', + }, + }, } as FeatureFlags) ); app.setAttribute('data-dummy-image', '/dummy-image.svg'); + // @ts-ignore document.getElementById('content').appendChild(app); // Apply mock @@ -40,17 +64,17 @@ setTimeout(() => { I18n: { initialized: true, // eslint-disable-next-line @typescript-eslint/no-unused-vars,no-unused-vars - translate: (id, fallback: string, packageKey = null, source = null, args = []) => { + translate: (id: string, fallback: string, packageKey = null, source = null, args = []) => { Object.keys(args).forEach((key) => (fallback = fallback.replace(`{${key}}`, args[key]))); return fallback; }, }, Notification: { - notice: (title) => console.log(title), - ok: (title) => console.log(title), - error: (title, message) => console.error(message, title), - warning: (title, message) => console.warn(message, title), - info: (title) => console.info(title), + notice: (title: string) => console.log(title), + ok: (title: string) => console.log(title), + error: (title: string, message: string) => console.error(message, title), + warning: (title: string, message: string) => console.warn(message, title), + info: (title: string) => console.info(title), }, }; }, 0); diff --git a/Resources/Private/JavaScript/dev-server/src/server.ts b/Resources/Private/JavaScript/dev-server/src/server.ts index f6c7fe493..a3b581a27 100644 --- a/Resources/Private/JavaScript/dev-server/src/server.ts +++ b/Resources/Private/JavaScript/dev-server/src/server.ts @@ -1,21 +1,44 @@ -const fs = require('fs'); -const path = require('path'); -const Bundler = require('parcel-bundler'); -const { gql } = require('@apollo/client'); -const { ApolloServer } = require('apollo-server-express'); -const express = require('express'); -const Fixtures = require('./fixtures'); +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { Parcel } from '@parcel/core'; +import { gql } from '@apollo/client'; +import { ApolloServer } from 'apollo-server-express'; +import express from 'express'; +import { createProxyMiddleware } from 'http-proxy-middleware'; + +import * as Fixtures from './fixtures/index'; // FIXME: type annotations are missing as they couldn't be included anymore while making the devserver work again -// import { Tag } from '@media-ui/core/src/interfaces'; // import { AssetChange, AssetChangeQueryResult, AssetChangeType } from '@media-ui/feature-concurrent-editing/src'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + (async () => { - const PORT = 8000; + const bundlerPort = 8001; + const frontendPort = 8000; - const bundler = new Bundler(path.resolve(__dirname, './index.html'), { - outDir: path.resolve(__dirname, '../dist'), + const bundler = new Parcel({ + defaultConfig: '@parcel/config-default', + entries: path.resolve(__dirname, './index.html'), + defaultTargetOptions: { + distDir: path.resolve(__dirname, '../dist'), + publicUrl: '/', + }, + mode: 'development', + // cache: false, + logLevel: 'info', + serveOptions: { + publicUrl: '/', + port: bundlerPort, + host: 'localhost', + }, + hmrOptions: { + port: bundlerPort, + host: 'localhost', + }, }); + bundler.watch(); let { assets, assetCollections, assetSources, tags } = Fixtures.loadFixtures(); @@ -31,13 +54,13 @@ const Fixtures = require('./fixtures'); }); }; - const sortAssets = (assets, sortBy, sortDirection) => { + const sortAssets = (assets: Asset[], sortBy, sortDirection) => { const sorted = assets.sort((a, b) => { if (sortBy === 'name') { // Using the label here since teh filename is the same in every fixture - return a['label'].localeCompare(b['label']); + return a.label.localeCompare(b.label); } - return new Date(a['lastModified']).getTime() - new Date(b['lastModified']).getTime(); + return new Date(a.lastModified).getTime() - new Date(b.lastModified).getTime(); }); return sortDirection === 'DESC' ? sorted.reverse() : sorted; @@ -73,7 +96,7 @@ const Fixtures = require('./fixtures'); sortBy = 'lastModified', sortDirection = 'DESC', } - ) => + ): Asset[] => sortAssets( filterAssets(assetSourceId, tagId, assetCollectionId, mediaType, searchTerm).slice( offset, @@ -82,9 +105,9 @@ const Fixtures = require('./fixtures'); sortBy, sortDirection ), - unusedAssets: ($_, { limit = 20, offset = 0 }) => + unusedAssets: ($_, { limit = 20, offset = 0 }): Asset[] => assets.filter(({ isInUse }) => !isInUse).slice(offset, offset + limit), - unusedAssetCount: () => assets.filter(({ isInUse }) => !isInUse).length, + unusedAssetCount: (): number => assets.filter(({ isInUse }) => !isInUse).length, changedAssets: ($_, { since }) => { const { lastModified, changes } = changedAssetsResponse.changedAssets; since = since ? new Date(since) : null; @@ -94,26 +117,33 @@ const Fixtures = require('./fixtures'); changes: since ? changes.filter((change) => change.lastModified > since) : changes, }; }, + similarAssets: ($_, { id, assetSourceId }) => { + throw new Error('Not implemented'); + }, // eslint-disable-next-line @typescript-eslint/no-unused-vars assetCount: ( $_, { assetSourceId = 'neos', tagId = null, assetCollectionId = null, mediaType = '', searchTerm = '' } - ) => { + ): number => { return filterAssets(assetSourceId, tagId, assetCollectionId, mediaType, searchTerm).length; }, - assetUsageDetails: ($_, { id }) => { + assetUsageDetails: ($_, { id }): UsageDetailsGroup[] => { return Fixtures.getUsageDetailsForAsset(id); }, + assetUsageCount: ($_, { id, assetSourceId }): number => { + throw new Error('Not implemented'); + }, // eslint-disable-next-line @typescript-eslint/no-unused-vars - assetVariants: ($_, { id }) => { + assetVariants: ($_, { id }): AssetVariant[] => { // TODO: Implement assetVariants return []; }, - assetSources: () => assetSources, - assetCollections: () => assetCollections, - assetCollection: ($_, { id }) => assetCollections.find((assetCollection) => assetCollection.id === id), - tags: () => tags, - tag: ($_, { id }) => tags.find((tag) => tag.id === id), + assetSources: (): AssetSource[] => assetSources, + assetCollections: (): AssetCollection[] => assetCollections, + assetCollection: ($_, { id }): AssetCollection => + assetCollections.find((assetCollection) => assetCollection.id === id), + tags: (): Tag[] => tags, + tag: ($_, { id }): Tag => tags.find((tag) => tag.id === id), config: () => ({ uploadMaxFileSize: 1024 * 1024, uploadMaxFileUploadLimit: 2, @@ -121,7 +151,7 @@ const Fixtures = require('./fixtures'); }), }, Mutation: { - updateAsset: ($_, { id, assetSourceId, label, caption, copyrightNotice }) => { + updateAsset: ($_, { id, assetSourceId, label, caption, copyrightNotice }): Asset => { const asset = assets.find((asset) => asset.id === id && asset.assetSource.id === assetSourceId); asset.label = label; asset.caption = caption; @@ -134,10 +164,67 @@ const Fixtures = require('./fixtures'); }); return asset; }, + setAssetCollectionParent: ($_, { id, parent }: { id: string; parent: string }): boolean => { + const assetCollection = assetCollections.find((assetCollection) => assetCollection.id === id); + const parentCollection = assetCollections.find((assetCollection) => assetCollection.id === parent); + if (!assetCollection || !parentCollection) return false; + + // Check if there would be a recursion + let tmpParent = parentCollection; + while (tmpParent) { + tmpParent = tmpParent.parent as AssetCollection; + if (tmpParent.id === parentCollection.id) { + return false; + } + } + + assetCollection.parent = parentCollection; + return true; + }, + updateAssetCollection: ( + $_, + { id, title, tagIds }: { id: string; title: string; tagIds: string[] } + ): boolean => { + const assetCollection = assetCollections.find((assetCollection) => assetCollection.id === id); + if (title) { + // @ts-ignore we intentionally overwrite the readonly property here + assetCollection.title = title; + } + if (Array.isArray(tagIds)) { + assetCollection.tags = tags.filter((tag) => tagIds.includes(tag.id)); + } + return true; + }, + deleteAssetCollection: ($_, { id }: { id: string }): boolean => { + const assetCollection = assetCollections.find((assetCollection) => assetCollection.id === id); + if (!assetCollection) return false; + assetCollections = assetCollections.filter((assetCollection) => assetCollection.id !== id); + return true; + }, + createAssetCollection: ($_, { title, parent }: { title: string; parent: string }): AssetCollection => { + const parentCollection = parent + ? assetCollections.find((assetCollection) => assetCollection.id === parent) + : null; + const newCollection: AssetCollection = { + __typename: 'AssetCollection', + id: `someId_${Date.now()}`, + title, + parent: parentCollection + ? { + id: parentCollection.id, + title: parentCollection.title, + } + : null, + tags: [], + assetCount: 0, + }; + assetCollections.push(newCollection); + return newCollection; + }, setAssetTags: ( $_, { id, assetSourceId, tagIds }: { id: string; assetSourceId: string; tagIds: string[] } - ) => { + ): Asset => { const asset = assets.find((asset) => asset.id === id && asset.assetSource.id === assetSourceId); asset.tags = tags.filter((tag) => tagIds.includes(tag.id)); addAssetChange({ @@ -154,7 +241,7 @@ const Fixtures = require('./fixtures'); assetSourceId, assetCollectionIds: newAssetCollectionIds, }: { id: string; assetSourceId: string; assetCollectionIds: string[] } - ) => { + ): boolean => { const asset = assets.find((asset) => asset.id === id && asset.assetSource.id === assetSourceId); asset.collections = assetCollections.filter((collection) => newAssetCollectionIds.includes(collection.id) @@ -164,17 +251,20 @@ const Fixtures = require('./fixtures'); assetId: id, type: 'ASSET_UPDATED', }); - return asset; + return true; }, - deleteTag: ($_, { id }) => { + deleteTag: ($_, { id }): boolean => { tags.splice( tags.findIndex((tag) => tag.id === id), 1 ); - // TODO: Remove tag from assets + // Remove tag from assets + assets.forEach((asset) => { + asset.tags = asset.tags.filter((tag) => tag.id !== id); + }); return true; }, - deleteAsset: ($_, { id: id, assetSourceId }) => { + deleteAsset: ($_, { id: id, assetSourceId }): boolean => { const inUse = Fixtures.getUsageDetailsForAsset(id).reduce( (prev, { usages }) => prev && usages.length > 0, false @@ -196,13 +286,37 @@ const Fixtures = require('./fixtures'); } return false; }, - createTag: ($_, { tag: newTag }: { tag }) => { + createTag: ($_, { tag: newTag }: { tag: Tag }): Tag => { if (!tags.find((tag) => tag === newTag)) { tags.push(newTag); return newTag; } return null; }, + updateTag: ($_, { id, label }): Tag => { + throw new Error('Not implemented'); + }, + replaceAsset: ($_, { id, assetSourceId, file, options }): FileUploadResult => { + throw new Error('Not implemented'); + }, + editAsset: ($_, { id, assetSourceId, filename, options }): boolean => { + throw new Error('Not implemented'); + }, + tagAsset: ($_, { id, assetSourceId, tagId }): Asset => { + throw new Error('Not implemented'); + }, + untagAsset: ($_, { id, assetSourceId, tagId }): Asset => { + throw new Error('Not implemented'); + }, + uploadFile: ($_, { file, tagId, assetCollectionId }): FileUploadResult => { + throw new Error('Not implemented'); + }, + uploadFiles: ($_, { files, tagId, assetCollectionId }): FileUploadResult[] => { + throw new Error('Not implemented'); + }, + importAsset: ($_, { id, assetSourceId }): Asset => { + throw new Error('Not implemented'); + }, }, }; @@ -215,6 +329,7 @@ const Fixtures = require('./fixtures'); const server = new ApolloServer({ typeDefs, resolvers, uploads: false }); const app = express(); + // @ts-ignore server.applyMiddleware({ app, path: '/graphql' }); app.use((req, res, next) => { @@ -229,11 +344,15 @@ const Fixtures = require('./fixtures'); next(); }); app.use(express.static(path.join(__dirname, '../public'))); - app.use(bundler.middleware()); - app.listen(PORT, () => { + const parcelMiddleware = createProxyMiddleware({ + target: `http://localhost:${bundlerPort}`, + }); + app.use('/', parcelMiddleware); + + app.listen(frontendPort, () => { console.info( - `Media Module dev server running at http://localhost:${PORT} and GraphQL at http://localhost:${PORT}${server.graphqlPath}` + `Media Module dev server running at http://localhost:${frontendPort} and GraphQL at http://localhost:${frontendPort}${server.graphqlPath}` ); }); })(); diff --git a/Resources/Private/JavaScript/dev-server/tsconfig.json b/Resources/Private/JavaScript/dev-server/tsconfig.json index f0f86c773..f1e3cfc08 100644 --- a/Resources/Private/JavaScript/dev-server/tsconfig.json +++ b/Resources/Private/JavaScript/dev-server/tsconfig.json @@ -1,18 +1,31 @@ { "extends": "../../../../tsconfig.json", "include": [ - "src/**/*.ts", - "node_modules/@media-ui/feature-asset-usage/src/**/*", - "node_modules/@media-ui/feature-asset-variants/src/**/*", - "node_modules/@media-ui/feature-concurrent-editing/src/**/*" + "src/**/*.ts" ], "exclude": [ "node_modules" ], "compilerOptions": { - "types": ["node"] + "declaration": true, + "types": ["node"], + "target": "es2016", + "module": "NodeNext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true }, + "references": [ + { "path": "../core" } + ], "ts-node": { - "esm": true + "transpileOnly": true, + "files": true, + "esm": true, + "experimentalSpecifierResolution": "node", + "compilerOptions": { + "module": "es2020" + } } } diff --git a/Resources/Private/JavaScript/media-details-screen/package.json b/Resources/Private/JavaScript/media-details-screen/package.json index f8295e2a4..38054cbe9 100755 --- a/Resources/Private/JavaScript/media-details-screen/package.json +++ b/Resources/Private/JavaScript/media-details-screen/package.json @@ -9,8 +9,6 @@ "dependencies": { "@apollo/client": "^3.3.13", "@media-ui/core": "workspace:*", - "@media-ui/feature-asset-usage": "workspace:*", - "@media-ui/feature-clipboard": "workspace:*", "@media-ui/media-module": "workspace:*", "apollo-upload-client": "^14.1.3", "react": "^17.0.2", @@ -18,6 +16,9 @@ "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/media-details-screen/src/MediaDetailsScreen.module.css b/Resources/Private/JavaScript/media-details-screen/src/MediaDetailsScreen.module.css new file mode 100644 index 000000000..43fd85acb --- /dev/null +++ b/Resources/Private/JavaScript/media-details-screen/src/MediaDetailsScreen.module.css @@ -0,0 +1,5 @@ +.mediaDetailsScreen { + transform: translateZ(0); + height: 100%; + padding: 1rem; +} diff --git a/Resources/Private/JavaScript/media-details-screen/src/MediaDetailsScreen.tsx b/Resources/Private/JavaScript/media-details-screen/src/MediaDetailsScreen.tsx index d59c1537d..a7b25cd29 100755 --- a/Resources/Private/JavaScript/media-details-screen/src/MediaDetailsScreen.tsx +++ b/Resources/Private/JavaScript/media-details-screen/src/MediaDetailsScreen.tsx @@ -1,5 +1,4 @@ -import * as React from 'react'; -import { createRef } from 'react'; +import React, { createRef } from 'react'; import { connect } from 'react-redux'; import { RecoilRoot } from 'recoil'; import { ApolloClient, ApolloLink, ApolloProvider } from '@apollo/client'; @@ -12,43 +11,27 @@ import { neos } from '@neos-project/neos-ui-decorators'; import { actions } from '@neos-project/neos-ui-redux-store'; // Media UI dependencies -import { - I18nRegistry, - InteractionProvider, - IntlProvider, - MediaUiProvider, - MediaUiThemeProvider, - Notify, - NotifyProvider, -} from '@media-ui/core/src'; -import { Asset, 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 { InteractionProvider, IntlProvider, MediaUiProvider, NotifyProvider } from '@media-ui/core'; +import { createErrorHandler, CacheFactory } from '@media-ui/media-module/src/core'; import { Details } from './components'; // GraphQL type definitions -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'; +import { typeDefs as TYPE_DEFS_CORE } from '@media-ui/core'; +import { typeDefs as TYPE_DEFS_ASSET_USAGE } from '@media-ui/feature-asset-usage'; // GraphQL local resolvers -import buildClipboardResolver from '@media-ui/feature-clipboard/src/resolvers/mutation'; -import buildModuleResolver from '@media-ui/media-module/src/resolvers/mutation'; import { MediaDetailsScreenApprovalAttainmentStrategyFactory } from './strategy'; +import classes from './MediaDetailsScreen.module.css'; + let apolloClient = null; interface MediaDetailsScreenProps { 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: (localAssetIdentifier: string) => void; - isLeftSideBarHidden: boolean; - isNodeCreationDialogOpen: boolean; - toggleSidebar: () => void; addFlashMessage: (title: string, message: string, severity?: string, timeout?: number) => void; constraints?: SelectionConstraints; imageIdentity: string; @@ -60,6 +43,21 @@ interface MediaDetailsScreenState { } export class MediaDetailsScreen extends React.PureComponent { + notificationHandler: NeosNotification; + + constructor(props: MediaDetailsScreenProps) { + super(props); + + // The Neos.UI FlashMessages only support the levels 'success', 'error' and 'info' + 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: { @@ -77,22 +75,17 @@ export class MediaDetailsScreen extends React.PureComponent(); - const featureFlags: FeatureFlags = this.props.frontendConfiguration as FeatureFlags; - // The Neos.UI FlashMessages only support the levels 'success', 'error' and 'info' - const Notification: Notify = { + const Notification: NeosNotification = { info: (message) => addFlashMessage(message, message, 'info'), ok: (message) => addFlashMessage(message, message, 'success'), notice: (message) => addFlashMessage(message, message, 'info'), @@ -126,7 +117,7 @@ export class MediaDetailsScreen extends React.PureComponent +
@@ -135,25 +126,23 @@ export class MediaDetailsScreen extends React.PureComponent - -
- +
@@ -165,19 +154,11 @@ export class MediaDetailsScreen extends React.PureComponent ({ - isLeftSideBarHidden: state.ui.leftSideBar.isHidden, - isNodeCreationDialogOpen: state.ui.nodeCreationDialog.isOpen, -}); - -const mapDispatchToProps = () => ({ - addFlashMessage: actions.UI.FlashMessages.add, - toggleSidebar: actions.UI.LeftSideBar.toggle, -}); - const mapGlobalRegistryToProps = neos((globalRegistry: any) => ({ i18nRegistry: globalRegistry.get('i18n'), frontendConfiguration: globalRegistry.get('frontendConfiguration').get('Flowpack.Media.Ui'), })); -export default connect(mapStateToProps, mapDispatchToProps)(mapGlobalRegistryToProps(MediaDetailsScreen)); +export default connect(() => ({}), { + addFlashMessage: actions.UI.FlashMessages.add, +})(mapGlobalRegistryToProps(MediaDetailsScreen)); diff --git a/Resources/Private/JavaScript/media-details-screen/src/components/Details.module.css b/Resources/Private/JavaScript/media-details-screen/src/components/Details.module.css new file mode 100644 index 000000000..218b17a15 --- /dev/null +++ b/Resources/Private/JavaScript/media-details-screen/src/components/Details.module.css @@ -0,0 +1,29 @@ +.container { + height: calc(100vh - 3 * var(--theme-spacing-GoldenUnit) + var(--theme-spacing-Half)); + line-height: 1.5; + overflow: hidden; + padding-top: calc(var(--theme-spacing-GoldenUnit) - 1rem); /* To account for the top right button of the secondary inspector */ +} + +.main { + display: grid; + grid-template-columns: var(--theme-size-sidebarWidth) 1fr; + height: 100%; + gap: var(--theme-spacing-Full); +} + +.inspector { + height: 100%; + overflow: auto; +} + +.loading { +} + +.loading .container { + cursor: wait; +} + +.loading .main { + pointer-events: none; +} diff --git a/Resources/Private/JavaScript/media-details-screen/src/components/Details.tsx b/Resources/Private/JavaScript/media-details-screen/src/components/Details.tsx index b9991d9f4..a11cfe40b 100644 --- a/Resources/Private/JavaScript/media-details-screen/src/components/Details.tsx +++ b/Resources/Private/JavaScript/media-details-screen/src/components/Details.tsx @@ -1,50 +1,28 @@ -import * as React from 'react'; +import React from 'react'; import { useRecoilValue } from 'recoil'; import cx from 'classnames'; -import { createUseMediaUiStyles, InteractionDialogRenderer, MediaUiTheme, useMediaUi } from '@media-ui/core/src'; +import { InteractionDialogRenderer, useMediaUi } from '@media-ui/core'; import { useSelectAsset, useAssetQuery } from '@media-ui/core/src/hooks'; -import { Asset, AssetIdentity } from '@media-ui/core/src/interfaces'; -import { AssetUsagesModal, assetUsageDetailsModalState } from '@media-ui/feature-asset-usage/src'; -import { ClipboardWatcher } from '@media-ui/feature-clipboard/src'; -import { ConcurrentChangeMonitor } from '@media-ui/feature-concurrent-editing/src'; -import { SimilarAssetsModal, similarAssetsModalState } from '@media-ui/feature-similar-assets/src'; -import { uploadDialogVisibleState } from '@media-ui/feature-asset-upload/src/state'; +import { AssetUsagesModal, assetUsageDetailsModalState } from '@media-ui/feature-asset-usage'; +import { ClipboardWatcher } from '@media-ui/feature-clipboard'; +import { ConcurrentChangeMonitor } from '@media-ui/feature-concurrent-editing'; +import { SimilarAssetsModal, similarAssetsModalState } from '@media-ui/feature-similar-assets'; +import { uploadDialogState } from '@media-ui/feature-asset-upload/src/state'; import { UploadDialog } from '@media-ui/feature-asset-upload/src/components'; - import LoadingIndicator from '@media-ui/media-module/src/components/LoadingIndicator'; import ErrorBoundary from '@media-ui/media-module/src/components/ErrorBoundary'; -import { createAssetCollectionDialogState, createTagDialogState } from '@media-ui/media-module/src/state'; -import { CreateTagDialog, CreateAssetCollectionDialog } from '@media-ui/media-module/src/components/Dialogs'; import { AssetInspector } from '@media-ui/media-module/src/components/SideBarRight/Inspector'; +import { CreateTagDialog, createTagDialogState } from '@media-ui/feature-asset-tags'; +import { + CreateAssetCollectionDialog, + createAssetCollectionDialogVisibleState, +} from '@media-ui/feature-asset-collections'; + import Preview from './Preview'; -const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - container: { - height: `calc(100vh - 3 * ${theme.spacing.goldenUnit} + ${theme.spacing.half})`, - lineHeight: 1.5, - overflow: 'hidden', - paddingTop: `calc(${theme.spacing.goldenUnit} - 1rem)`, // To account for the top right button of the secondary inspector - }, - main: { - display: 'grid', - gridTemplateColumns: `${theme.size.sidebarWidth} 1fr`, - height: '100%', - gridGap: theme.spacing.full, - }, - inspector: { - height: '100%', - overflow: 'auto', - }, - loading: { - '&$container': { - cursor: 'wait', - }, - '&$main': { - pointerEvents: 'none', - }, - }, -})); +import theme from '@media-ui/core/src/Theme.module.css'; +import classes from './Details.module.css'; interface DetailsProps { assetIdentity: AssetIdentity; @@ -52,22 +30,21 @@ interface DetailsProps { } const Details = ({ assetIdentity, buildLinkToMediaUi }: DetailsProps) => { - const { selectionMode, isInNodeCreationDialog, containerRef } = useMediaUi(); - const { visible: showUploadDialog } = useRecoilValue(uploadDialogVisibleState); + const { containerRef } = useMediaUi(); + const { visible: showUploadDialog } = useRecoilValue(uploadDialogState); const { visible: showCreateTagDialog } = useRecoilValue(createTagDialogState); - const { visible: showCreateAssetCollectionDialog } = useRecoilValue(createAssetCollectionDialogState); + const showCreateAssetCollectionDialog = useRecoilValue(createAssetCollectionDialogVisibleState); const showAssetUsagesModal = useRecoilValue(assetUsageDetailsModalState); const showSimilarAssetsModal = useRecoilValue(similarAssetsModalState); const selectAsset = useSelectAsset(); const { asset, loading } = useAssetQuery(assetIdentity); - const classes = useStyles({ selectionMode, isInNodeCreationDialog }); React.useEffect(() => { selectAsset(assetIdentity); }, [assetIdentity, selectAsset]); return ( -
+
diff --git a/Resources/Private/JavaScript/media-details-screen/src/components/Preview.module.css b/Resources/Private/JavaScript/media-details-screen/src/components/Preview.module.css new file mode 100644 index 000000000..e2bd28437 --- /dev/null +++ b/Resources/Private/JavaScript/media-details-screen/src/components/Preview.module.css @@ -0,0 +1,65 @@ +.preview { + min-width: var(--theme-size-sidebarWidth); + margin: 0; + display: flex; + flex-direction: column; + position: relative; +} + +.loading { + opacity: 0.1; +} + +.loading img { + width: 100%; +} + +.picture { + width: 100%; + display: flex; + align-items: center; + align-content: center; + justify-content: center; + background-color: var(--theme-colors-assetBackground); + aspect-ratio: 16 / 9; +} + +.picture img { + max-width: 100%; + max-height: calc(100vh - 3 * var(--theme-spacing-GoldenUnit) - 2 * var(--theme-spacing-Full)); + display: block; + background-image: repeating-linear-gradient(45deg, #999999 25%, transparent 25%, transparent 75%, #999999 75%, #999999), repeating-linear-gradient(45deg, #999999 25%, #e5e5f7 25%, #e5e5f7 75%, #999999 75%, #999999); + background-position: 0 0, 10px 10px; + background-size: 20px 20px; +} + +.toolBar { + display: flex; + position: absolute; + top: var(--theme-spacing-Quarter); + right: var(--theme-spacing-Quarter); + background-color: rgba(0.15, 0.15, 0.15, 0.25); + transition: background-color .1s ease-in; +} + +.toolBar button { + opacity: 1; +} + +.toolBar button[disabled] { + opacity: 0.5; +} + +.toolBar button.button--active svg { + color: white; +} + +.label { + position: absolute; + top: var(--theme-spacing-Quarter); + left: var(--theme-spacing-Quarter); + font-size: var(--theme-fontSize-small); + border-radius: 3px; + padding: 2px 4px; + user-select: none; +} diff --git a/Resources/Private/JavaScript/media-details-screen/src/components/Preview.tsx b/Resources/Private/JavaScript/media-details-screen/src/components/Preview.tsx index f3de6e56e..babbba577 100644 --- a/Resources/Private/JavaScript/media-details-screen/src/components/Preview.tsx +++ b/Resources/Private/JavaScript/media-details-screen/src/components/Preview.tsx @@ -1,72 +1,11 @@ -import * as React from 'react'; +import React from 'react'; +import cx from 'classnames'; -import { useIntl, createUseMediaUiStyles, MediaUiTheme, useMediaUi } from '@media-ui/core/src'; -import { Asset } from '@media-ui/core/src/interfaces'; +import { useIntl, useMediaUi } from '@media-ui/core'; import PreviewActions from './PreviewActions'; -const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - preview: { - minWidth: theme.size.sidebarWidth, - margin: '0', - display: 'flex', - flexDirection: 'column', - position: 'relative', - }, - loading: { - opacity: 0.1, - '& img': { - width: '100%', - }, - }, - picture: { - width: '100%', - display: 'flex', - alignItems: 'center', - alignContent: 'center', - justifyContent: 'center', - backgroundColor: theme.colors.assetBackground, - aspectRatio: '16 / 9', - - '& img': { - maxWidth: '100%', - maxHeight: `calc(100vh - 3 * ${theme.spacing.goldenUnit} - 2 * ${theme.spacing.full})`, - display: 'block', - backgroundImage: - 'repeating-linear-gradient(45deg, #999999 25%, transparent 25%, transparent 75%, #999999 75%, #999999), repeating-linear-gradient(45deg, #999999 25%, #e5e5f7 25%, #e5e5f7 75%, #999999 75%, #999999)', - backgroundPosition: '0 0, 10px 10px', - backgroundSize: '20px 20px', - }, - }, - toolBar: { - display: 'flex', - position: 'absolute', - top: theme.spacing.quarter, - right: theme.spacing.quarter, - backgroundColor: 'rgba(0.15, 0.15, 0.15, 0.25)', - transition: 'background-color .1s ease-in', - '& button': { - opacity: 1, - '&[disabled]': { - opacity: 0.5, - }, - '&.button--active': { - '& svg': { - color: 'white', - }, - }, - }, - }, - label: { - position: 'absolute', - top: theme.spacing.quarter, - left: theme.spacing.quarter, - fontSize: theme.fontSize.small, - borderRadius: '3px', - padding: '2px 4px', - userSelect: 'none', - }, -})); +import classes from './Preview.module.css'; interface PreviewProps { asset: null | Asset; @@ -75,15 +14,11 @@ interface PreviewProps { } const Preview: React.FC = ({ asset, loading, buildLinkToMediaUi }: PreviewProps) => { - const classes = useStyles(); const { translate } = useIntl(); const { dummyImage } = useMediaUi(); return ( -
+
{asset?.imported && {translate('asset.label.imported', 'Imported')}} {asset?.label} diff --git a/Resources/Private/JavaScript/media-details-screen/src/components/PreviewActions.tsx b/Resources/Private/JavaScript/media-details-screen/src/components/PreviewActions.tsx index 3afb33ef0..d19a1973d 100644 --- a/Resources/Private/JavaScript/media-details-screen/src/components/PreviewActions.tsx +++ b/Resources/Private/JavaScript/media-details-screen/src/components/PreviewActions.tsx @@ -1,10 +1,10 @@ -import * as React from 'react'; +import React from 'react'; +import { useRecoilState } from 'recoil'; import { IconButton } from '@neos-project/react-ui-components'; -import { useIntl } from '@media-ui/core/src'; -import { Asset } from '@media-ui/core/src/interfaces'; -import { useClipboard } from '@media-ui/feature-clipboard/src'; +import { useIntl } from '@media-ui/core'; +import { clipboardItemState } from '@media-ui/feature-clipboard'; interface PreviewActionsProps { asset: Asset; @@ -13,7 +13,9 @@ interface PreviewActionsProps { const PreviewActions: React.FC = ({ asset, buildLinkToMediaUi }: PreviewActionsProps) => { const { translate } = useIntl(); - const { toggleClipboardState } = useClipboard(); + const [isInClipboard, toggleClipboardState] = useRecoilState( + clipboardItemState({ assetId: asset.id, assetSourceId: asset.assetSource.id }) + ); return ( <> @@ -43,12 +45,12 @@ const PreviewActions: React.FC = ({ asset, buildLinkToMedia {asset.localId && ( toggleClipboardState({ assetId: asset.id, assetSourceId: asset.assetSource.id })} + className={isInClipboard ? 'button--active' : ''} + onClick={toggleClipboardState} /> )} diff --git a/Resources/Private/JavaScript/media-module/package.json b/Resources/Private/JavaScript/media-module/package.json index 5be07d813..a6372cc1e 100644 --- a/Resources/Private/JavaScript/media-module/package.json +++ b/Resources/Private/JavaScript/media-module/package.json @@ -3,6 +3,17 @@ "version": "1.0.0", "license": "GNU GPLv3", "private": true, + "source": "src/index.tsx", + "app": "../../../Public/Assets/main.bundle.js", + "targets": { + "app": { + "distDir": "../../../Public/Assets" + } + }, + "scripts": { + "watch": "parcel watch", + "build": "parcel build --no-scope-hoist" + }, "dependencies": { "@apollo/client": "^3.3.13", "@fortawesome/fontawesome-svg-core": "^1.2.36", @@ -12,68 +23,40 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@friendsofreactjs/react-css-themr": "^4.3.3", "@media-ui/core": "workspace:*", + "@media-ui/feature-asset-collections": "workspace:*", "@media-ui/feature-asset-editing": "workspace:*", "@media-ui/feature-asset-preview": "workspace:*", + "@media-ui/feature-asset-sources": "workspace:*", + "@media-ui/feature-asset-tags": "workspace:*", "@media-ui/feature-asset-upload": "workspace:*", "@media-ui/feature-asset-usage": "workspace:*", "@media-ui/feature-asset-variants": "workspace:*", "@media-ui/feature-clipboard": "workspace:*", "@media-ui/feature-concurrent-editing": "workspace:*", "@media-ui/feature-similar-assets": "workspace:*", - "@neos-project/react-ui-components": "5.3.13", + "@neos-project/react-ui-components": "^8.3.0", "apollo-upload-client": "^14.1.3", "classnames": "^2.3.2", - "graphql": "^15.5.0", - "jss-plugin-cache": "^10.10.0", + "graphql": "^15.8.0", "lodash.clonedeep": "^4.5.0", - "lodash.isequal": "^4.5.0", - "lodash.omit": "^4.5.0", - "lodash.throttle": "^4.1.1", "media-type": "^0.3.1", - "moment": "^2.29.4", - "plow-js": "^3.0.0", - "raf": "^3.4.1", "react": "^17.0.2", - "react-close-on-escape": "^3.0.0", - "react-collapse": "^2.4.1", - "react-datetime": "^2.16.3", "react-dnd": "^10.0.2", "react-dnd-html5-backend": "^10.0.2", "react-dom": "^16.14.0", "react-dropzone": "^11.2.4", - "react-height": "^3.0.2", - "react-image-lightbox": "^5.1.1", - "react-jss": "^10.10.0", - "react-keydown": "^1.9.12", + "react-image-lightbox": "^5.1.4", "react-modal": "^3.12.1", - "react-motion": "^0.5.2", - "react-portal": "^4.2.2", "react-redux": "^5.1.2", - "react-textarea-autosize": "^5.2.1", "recoil": "^0.7.7" }, "devDependencies": { - "@babel/compat-data": "^7.14.0", - "@babel/core": "^7.14.0", "@types/apollo-upload-client": "^14.1.0", - "@types/react": "^17.0.53", - "@types/react-dom": "^17.0.19", + "@types/react": "^17.0.60", + "@types/react-dom": "^17.0.20", "@types/react-dropzone": "^5.1.0", - "@types/recoil": "^0.0.1", "apollo-server-express": "^2.26.1", - "babel-types": "^6.26.0", - "node-sass": "^7.0.3", - "parcel-bundler": "^1.12.5", - "postcss-custom-properties": "^9.2.0", - "postcss-modules": "^1.5.0", - "postinstall-postinstall": "^2.1.0", - "react-hot-loader": "^4.13.1" - }, - "alias": { - "@neos-project/react-ui-components/*": "@neos-project/react-ui-components/lib-esm/*" - }, - "scripts": { - "watch": "parcel watch src/index.tsx --public-url . --out-dir ../../../Public/Assets -o main.bundle.js", - "build": "parcel build src/index.tsx --public-url . --out-dir ../../../Public/Assets -o main.bundle.js" + "node-sass": "^8.0.0", + "parcel": "^2.9.1" } } diff --git a/Resources/Private/JavaScript/media-module/src/components/App.module.css b/Resources/Private/JavaScript/media-module/src/components/App.module.css index 8f5c8c28b..6d921eb55 100644 --- a/Resources/Private/JavaScript/media-module/src/components/App.module.css +++ b/Resources/Private/JavaScript/media-module/src/components/App.module.css @@ -1,9 +1,55 @@ -/** - * Define theme variables by using the ones provided by Neos with a fallback to the default values. - */ .mediaModuleApp { - --theme-spacing-GoldenUnit: var(--spacing-Half, 40px); - --theme-spacing-Full: var(--spacing-Half, 16px); - --theme-spacing-Half: var(--spacing-Half, 8px); - --theme-spacing-Quarter: var(--spacing-Half, 4px); +} + +.container { + /* TODO: Find a way to not calculate height to allow scrolling in main grid area */ + --grid-height: calc(100vh - 48px - 61px - 41px); /* Remove top bar; body padding and bottom bar */ + --grid-areas: "left top right" "left main right"; + --grid-columns: var(--theme-size-sidebarWidth) 1fr var(--theme-size-sidebarWidth); + --grid-area-left: left; + --grid-area-right: right; + --grid-area-top: top; + --grid-area-main: main; + + display: grid; + height: var(--grid-height); + grid-template-rows: 40px 1fr; + grid-template-columns: var(--grid-columns); + grid-template-areas: var(--grid-areas); + gap: var(--theme-spacing-Full); + line-height: 1.5; + overflow: hidden; +} + +.fullHeight { + --grid-height: calc(100% - 61px - 8px); /* Remove bottom bar and add padding */ +} + +.selectionMode { + --grid-columns: var(--theme-size-sidebarWidth) 1fr; + --grid-areas: "left top" "left main"; +} + +.gridColumn { + height: 100%; + overflow-y: auto; +} + +.gridRight { + composes: gridColumn; + grid-area: var(--grid-area-right); +} + +.gridLeft { + composes: gridColumn; + grid-area: var(--grid-area-left); +} + +.gridMain { + composes: gridColumn; + grid-area: var(--grid-area-main); +} + +.gridTop { + grid-area: var(--grid-area-top); } diff --git a/Resources/Private/JavaScript/media-module/src/components/App.tsx b/Resources/Private/JavaScript/media-module/src/components/App.tsx index 1a535dd53..60c2229f1 100644 --- a/Resources/Private/JavaScript/media-module/src/components/App.tsx +++ b/Resources/Private/JavaScript/media-module/src/components/App.tsx @@ -1,115 +1,50 @@ -import * as React from 'react'; -import { useRecoilValue } from 'recoil'; -import classNames from 'classnames'; +import React from 'react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import cx from 'classnames'; -import { createUseMediaUiStyles, InteractionDialogRenderer, MediaUiTheme, useMediaUi } from '@media-ui/core/src'; -import { useSelectAsset, useSelectAssetSource } from '@media-ui/core/src/hooks'; +import { InteractionDialogRenderer, useMediaUi } from '@media-ui/core'; +import { useSelectAsset } from '@media-ui/core/src/hooks'; import { searchTermState } from '@media-ui/core/src/state'; -import { AssetUsagesModal, assetUsageDetailsModalState } from '@media-ui/feature-asset-usage/src'; -import { ClipboardWatcher } from '@media-ui/feature-clipboard/src'; -import { ConcurrentChangeMonitor } from '@media-ui/feature-concurrent-editing/src'; -import { SimilarAssetsModal, similarAssetsModalState } from '@media-ui/feature-similar-assets/src'; -import { uploadDialogVisibleState } from '@media-ui/feature-asset-upload/src/state'; +import { AssetUsagesModal, assetUsageDetailsModalState } from '@media-ui/feature-asset-usage'; +import { ClipboardWatcher } from '@media-ui/feature-clipboard'; +import { ConcurrentChangeMonitor } from '@media-ui/feature-concurrent-editing'; +import { SimilarAssetsModal, similarAssetsModalState } from '@media-ui/feature-similar-assets'; +import { uploadDialogState } from '@media-ui/feature-asset-upload/src/state'; import { UploadDialog } from '@media-ui/feature-asset-upload/src/components'; -import { AssetPreview } from '@media-ui/feature-asset-preview/src'; - -import { SideBarLeft } from './SideBarLeft'; +import { AssetPreview } from '@media-ui/feature-asset-preview'; +import { EditAssetDialog, editAssetDialogState } from '@media-ui/feature-asset-editing'; +import { CreateTagDialog, createTagDialogState } from '@media-ui/feature-asset-tags'; +import { + CreateAssetCollectionDialog, + createAssetCollectionDialogVisibleState, +} from '@media-ui/feature-asset-collections'; +import { selectedAssetSourceState } from '@media-ui/feature-asset-sources'; + +import SideBarLeft from './SideBarLeft/SideBarLeft'; import { SideBarRight } from './SideBarRight'; import LoadingIndicator from './LoadingIndicator'; import { BottomBar } from './BottomBar'; import { TopBar } from './TopBar'; import { Main } from './Main'; import ErrorBoundary from './ErrorBoundary'; -import { createAssetCollectionDialogState, createTagDialogState } from '../state'; -import { CreateTagDialog, CreateAssetCollectionDialog } from './Dialogs'; -import { EditAssetDialog, editAssetDialogState } from '@media-ui/feature-asset-editing'; - -import * as styles from './App.module.css'; - -const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - container: ({ selectionMode, isInNodeCreationDialog }) => ({ - display: 'grid', - // TODO: Find a way to not calculate height to allow scrolling in main grid area - height: isInNodeCreationDialog - ? `calc(100% - 61px - 8px)` // Remove bottom bar and add padding - : `calc(100vh - 48px - 61px - 41px)`, // Remove top bar, body padding and bottom bar - gridTemplateRows: '40px 1fr', - gridTemplateColumns: selectionMode - ? theme.size.sidebarWidth + ' 1fr' - : theme.size.sidebarWidth + ' 1fr ' + theme.size.sidebarWidth, - gridTemplateAreas: selectionMode - ? ` - "left top" - "left main" - ` - : ` - "left top right" - "left main right" - `, - gridGap: theme.spacing.full, - lineHeight: 1.5, - overflow: 'hidden', - }), - gridColumn: { - height: '100%', - overflowY: 'auto', - }, - gridRight: { - extend: 'gridColumn', - gridArea: 'right', - }, - gridLeft: { - extend: 'gridColumn', - gridArea: 'left', - }, - gridMain: { - extend: 'gridColumn', - gridArea: 'main', - }, - gridTop: { - gridArea: 'top', - }, - '@global': { - '#media-ui-app': { - scrollbarWidth: 'thin', - scrollbarColor: `${theme.colors.scrollbarForeground} ${theme.colors.scrollbarBackground}`, - '& ::-webkit-scrollbar': { - width: theme.size.scrollbarSize, - }, - '& ::-webkit-scrollbar-track': { - background: theme.colors.scrollbarBackground, - }, - '& ::-webkit-scrollbar-thumb': { - backgroundColor: theme.colors.scrollbarForeground, - }, - }, - '.neos.neos-module-management-mediaui > .neos-module-wrap': { - paddingLeft: '1rem', - paddingRight: '1rem', - paddingTop: '3rem', - paddingBottom: '0', - }, - // Hack to prevent a dropdown to be behind the bottom bar - issue #79 - 'body > [class*="_selectBox__contents_"]': { - zIndex: 99999, - }, - }, -})); +import theme from '@media-ui/core/src/Theme.module.css'; +import classes from './App.module.css'; +import './Global.module.css'; const App = () => { const { selectionMode, isInNodeCreationDialog, containerRef } = useMediaUi(); - const { visible: showUploadDialog } = useRecoilValue(uploadDialogVisibleState); - const { visible: showCreateTagDialog } = useRecoilValue(createTagDialogState); - const { visible: showCreateAssetCollectionDialog } = useRecoilValue(createAssetCollectionDialogState); + const uploadDialog = useRecoilValue(uploadDialogState); + const createTagDialog = useRecoilValue(createTagDialogState); + const showCreateAssetCollectionDialog = useRecoilValue(createAssetCollectionDialogVisibleState); const showEditAssetDialog = useRecoilValue(editAssetDialogState); const showAssetUsagesModal = useRecoilValue(assetUsageDetailsModalState); const showSimilarAssetsModal = useRecoilValue(similarAssetsModalState); const searchTerm = useRecoilValue(searchTermState); const selectAsset = useSelectAsset(); - const [, selectAssetSource] = useSelectAssetSource(); - const classes = useStyles({ selectionMode, isInNodeCreationDialog }); + const selectAssetSource = useSetRecoilState(selectedAssetSourceState); + // TODO: Implement asset source selection via recoil an atom effect in `searchTermState` to avoid this dangerous effect React.useEffect(() => { const assetId = searchTerm.getAssetIdentifierIfPresent(); if (assetId) { @@ -121,7 +56,13 @@ const App = () => { }, [searchTerm]); return ( -
+
@@ -152,9 +93,9 @@ const App = () => { {showAssetUsagesModal && } - {showUploadDialog && } + {uploadDialog.visible && } {showEditAssetDialog && } - {showCreateTagDialog && } + {createTagDialog.visible && } {showCreateAssetCollectionDialog && } {showSimilarAssetsModal && } diff --git a/Resources/Private/JavaScript/media-module/src/components/BottomBar/AssetCount/AssetCount.module.css b/Resources/Private/JavaScript/media-module/src/components/BottomBar/AssetCount/AssetCount.module.css new file mode 100644 index 000000000..981a17e82 --- /dev/null +++ b/Resources/Private/JavaScript/media-module/src/components/BottomBar/AssetCount/AssetCount.module.css @@ -0,0 +1,8 @@ + .assetCount { + height: 100%; + align-self: flex-start; + display: flex; + justify-content: center; + align-items: center; + user-select: none; + } diff --git a/Resources/Private/JavaScript/media-module/src/components/BottomBar/AssetCount/AssetCount.tsx b/Resources/Private/JavaScript/media-module/src/components/BottomBar/AssetCount/AssetCount.tsx index 2b1e256bd..dd92c40a7 100644 --- a/Resources/Private/JavaScript/media-module/src/components/BottomBar/AssetCount/AssetCount.tsx +++ b/Resources/Private/JavaScript/media-module/src/components/BottomBar/AssetCount/AssetCount.tsx @@ -1,22 +1,12 @@ -import * as React from 'react'; +import React from 'react'; -import { useIntl, createUseMediaUiStyles } from '@media-ui/core/src'; +import { useIntl } from '@media-ui/core'; import { useAssetCount } from '../../../hooks'; -const useStyles = createUseMediaUiStyles({ - assetCount: { - height: '100%', - alignSelf: 'flex-start', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - userSelect: 'none', - }, -}); +import classes from './AssetCount.module.css'; const AssetCount: React.FC = () => { - const classes = useStyles(); const { translate } = useIntl(); const assetCount = useAssetCount(); diff --git a/Resources/Private/JavaScript/media-module/src/components/BottomBar/BottomBar.module.css b/Resources/Private/JavaScript/media-module/src/components/BottomBar/BottomBar.module.css new file mode 100644 index 000000000..ef630cada --- /dev/null +++ b/Resources/Private/JavaScript/media-module/src/components/BottomBar/BottomBar.module.css @@ -0,0 +1,22 @@ +.bottomBar { + display: grid; + grid-template-columns: 350px 1fr 350px; + gap: var(--theme-spacing-GoldenUnit); + position: fixed; + bottom: 0; + left: 0; + right: 0; + border-top: 1px solid var(--theme-colors-border); + background-color: var(--theme-colors-moduleBackground); + z-index: var(--theme-zIndex-pagination); +} + +.selectionMode { + grid-template-columns: repeat(3, 1fr); +} + +.isInNodeCreationDialog { + bottom: -16px; + left: -16px; + right: -16px; +} diff --git a/Resources/Private/JavaScript/media-module/src/components/BottomBar/BottomBar.tsx b/Resources/Private/JavaScript/media-module/src/components/BottomBar/BottomBar.tsx index cc78360e1..a74de4875 100644 --- a/Resources/Private/JavaScript/media-module/src/components/BottomBar/BottomBar.tsx +++ b/Resources/Private/JavaScript/media-module/src/components/BottomBar/BottomBar.tsx @@ -1,35 +1,25 @@ -import * as React from 'react'; -import { useMemo } from 'react'; +import React from 'react'; +import cx from 'classnames'; -import { createUseMediaUiStyles, MediaUiTheme, useMediaUi } from '@media-ui/core/src'; -import { ClipboardToggle } from '@media-ui/feature-clipboard/src'; +import { useMediaUi } from '@media-ui/core'; +import { ClipboardToggle } from '@media-ui/feature-clipboard'; import AssetCount from './AssetCount/AssetCount'; import Pagination from './Pagination/Pagination'; -const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - bottomBar: ({ isInNodeCreationDialog, selectionMode }) => ({ - display: 'grid', - gridTemplateColumns: isInNodeCreationDialog || selectionMode ? 'repeat(3, 1fr)' : '350px 1fr 350px', - gridGap: theme.spacing.goldenUnit, - position: 'fixed', - bottom: isInNodeCreationDialog ? -16 : 0, - left: isInNodeCreationDialog ? -16 : 0, - right: isInNodeCreationDialog ? -16 : 0, - borderTop: `1px solid ${theme.colors.border}`, - backgroundColor: theme.colors.moduleBackground, - zIndex: theme.paginationZIndex, - }), -})); +import classes from './BottomBar.module.css'; const BottomBar: React.FC = () => { const { isInNodeCreationDialog, selectionMode } = useMediaUi(); - const classes = useStyles({ isInNodeCreationDialog, selectionMode }); - - const components = useMemo(() => [AssetCount, Pagination, ClipboardToggle], []); + const components = [AssetCount, Pagination, ClipboardToggle]; return ( -
+
{components.map((Component, index) => ( ))} diff --git a/Resources/Private/JavaScript/media-module/src/components/BottomBar/Pagination/Pagination.module.css b/Resources/Private/JavaScript/media-module/src/components/BottomBar/Pagination/Pagination.module.css new file mode 100644 index 000000000..011e3ba91 --- /dev/null +++ b/Resources/Private/JavaScript/media-module/src/components/BottomBar/Pagination/Pagination.module.css @@ -0,0 +1,21 @@ +.pagination { + justify-self: center; +} + +.list { + display: flex; + justify-self: center; + list-style-type: none; + text-align: center; + padding: 0; + margin: 0; +} + +.ellipsis { + line-height: 2.4rem; + user-select: none; +} + +.disabled { + color: var(--theme-colors-ContrastBrighter); +} diff --git a/Resources/Private/JavaScript/media-module/src/components/BottomBar/Pagination/Pagination.tsx b/Resources/Private/JavaScript/media-module/src/components/BottomBar/Pagination/Pagination.tsx index a01e5f89b..1b737b819 100644 --- a/Resources/Private/JavaScript/media-module/src/components/BottomBar/Pagination/Pagination.tsx +++ b/Resources/Private/JavaScript/media-module/src/components/BottomBar/Pagination/Pagination.tsx @@ -1,43 +1,26 @@ -import * as React from 'react'; -import { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; -import { useIntl, createUseMediaUiStyles, useMediaUi } from '@media-ui/core/src'; -import { currentPageState } from '@media-ui/core/src/state'; +import { useIntl } from '@media-ui/core'; +import { currentPageState, featureFlagsState } from '@media-ui/core/src/state'; import PaginationItem from './PaginationItem'; -import { MainViewState, mainViewState } from '../../../state'; +import { MainViewMode, mainViewState } from '../../../state'; import { useAssetCount } from '../../../hooks'; -const useStyles = createUseMediaUiStyles({ - pagination: { - justifySelf: 'center', - }, - list: { - display: 'flex', - justifySelf: 'center', - listStyleType: 'none', - textAlign: 'center', - padding: 0, - }, - ellipsis: { - lineHeight: '2.4rem', - }, -}); +import classes from './Pagination.module.css'; +import cx from 'classnames'; const Pagination: React.FC = () => { - const classes = useStyles(); const [currentPage, setCurrentPage] = useRecoilState(currentPageState); const assetCount = useAssetCount(); const { - featureFlags: { - pagination: { assetsPerPage, maximumLinks }, - }, - } = useMediaUi(); + pagination: { assetsPerPage, maximumLinks }, + } = useRecoilValue(featureFlagsState); const { translate } = useIntl(); const mainView = useRecoilValue(mainViewState); - const disabled = ![MainViewState.DEFAULT, MainViewState.UNUSED_ASSETS].includes(mainView); + const disabled = ![MainViewMode.DEFAULT, MainViewMode.UNUSED_ASSETS].includes(mainView); const numberOfPages = Math.ceil(assetCount / assetsPerPage); const [displayRange, setDisplayRange] = useState({ start: 0, @@ -83,7 +66,7 @@ const Pagination: React.FC = () => { return (